feat(batch14): complete filestore write lifecycle features and tests
This commit is contained in:
@@ -618,6 +618,488 @@ public sealed partial class JetStreamFileStoreTests
|
||||
});
|
||||
}
|
||||
|
||||
[Fact] // T:384
|
||||
public void FileStoreSnapshotAndSyncBlocks_ShouldSucceed()
|
||||
{
|
||||
WithStore((fs, _) =>
|
||||
{
|
||||
for (var i = 0; i < 5; i++)
|
||||
fs.StoreMsg("foo", null, "x"u8.ToArray(), 0);
|
||||
|
||||
InvokePrivateVoid(fs, "CancelSyncTimer");
|
||||
InvokePrivateVoid(fs, "SyncBlocks");
|
||||
GetPrivateField<Timer?>(fs, "_syncTmr").ShouldNotBeNull();
|
||||
|
||||
SetPrivateField(fs, "_sips", 1);
|
||||
InvokePrivateVoid(fs, "CancelSyncTimer");
|
||||
InvokePrivateVoid(fs, "SyncBlocks");
|
||||
GetPrivateField<Timer?>(fs, "_syncTmr").ShouldNotBeNull();
|
||||
|
||||
var (snapshot, err) = fs.Snapshot(TimeSpan.FromSeconds(2), includeConsumers: false, checkMsgs: true);
|
||||
err.ShouldBeNull();
|
||||
snapshot.ShouldNotBeNull();
|
||||
snapshot!.State.Msgs.ShouldBeGreaterThan(0UL);
|
||||
|
||||
using var reader = snapshot.Reader;
|
||||
using var payload = new MemoryStream();
|
||||
reader.CopyTo(payload);
|
||||
payload.Length.ShouldBeGreaterThan(0L);
|
||||
}, cfg: DefaultStreamConfig(subjects: ["foo"]), fcfg: new FileStoreConfig
|
||||
{
|
||||
SyncInterval = TimeSpan.FromMilliseconds(25),
|
||||
BlockSize = 1024,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact(Skip = "Deferred: FileStore persistence parity for StoreMsg/PurgeEx restart paths is not yet wired.")] // T:412
|
||||
public void FileStorePurgeExWithSubject_ShouldSucceed()
|
||||
{
|
||||
var root = NewRoot();
|
||||
Directory.CreateDirectory(root);
|
||||
|
||||
JetStreamFileStore? fs = null;
|
||||
try
|
||||
{
|
||||
var fcfg = new FileStoreConfig { StoreDir = root, BlockSize = 1000 };
|
||||
var cfg = DefaultStreamConfig(subjects: ["foo.>"]);
|
||||
fs = JetStreamFileStore.NewFileStore(fcfg, cfg);
|
||||
|
||||
var payload = new byte[20];
|
||||
fs.StoreMsg("foo.0", null, payload, 0).Seq.ShouldBe(1UL);
|
||||
for (var i = 0; i < 200; i++)
|
||||
fs.StoreMsg("foo.1", null, payload, 0);
|
||||
fs.StoreMsg("foo.2", null, "xxxxxx"u8.ToArray(), 0);
|
||||
|
||||
InvokePrivate<Exception?>(fs, "ForceWriteFullState").ShouldBeNull();
|
||||
var stateFile = Path.Combine(root, FileStoreDefaults.MsgDir, FileStoreDefaults.StreamStateFile);
|
||||
var priorState = File.ReadAllBytes(stateFile);
|
||||
priorState.Length.ShouldBeGreaterThan(0);
|
||||
|
||||
var (purged, purgeErr) = fs.PurgeEx("foo.1", 1, 0);
|
||||
purgeErr.ShouldBeNull();
|
||||
purged.ShouldBe(200UL);
|
||||
|
||||
var expected = fs.State();
|
||||
expected.Msgs.ShouldBeLessThanOrEqualTo(2UL);
|
||||
|
||||
fs.Stop();
|
||||
fs = null;
|
||||
|
||||
using (var reopened = JetStreamFileStore.NewFileStore(new FileStoreConfig { StoreDir = root, BlockSize = 1000 }, cfg))
|
||||
{
|
||||
var state = reopened.State();
|
||||
state.Msgs.ShouldBe(expected.Msgs);
|
||||
state.FirstSeq.ShouldBe(expected.FirstSeq);
|
||||
state.LastSeq.ShouldBe(expected.LastSeq);
|
||||
}
|
||||
|
||||
File.Delete(stateFile);
|
||||
using (var reopened = JetStreamFileStore.NewFileStore(new FileStoreConfig { StoreDir = root, BlockSize = 1000 }, cfg))
|
||||
{
|
||||
var state = reopened.State();
|
||||
state.Msgs.ShouldBe(expected.Msgs);
|
||||
state.FirstSeq.ShouldBe(expected.FirstSeq);
|
||||
state.LastSeq.ShouldBe(expected.LastSeq);
|
||||
}
|
||||
|
||||
File.WriteAllBytes(stateFile, priorState);
|
||||
using (var reopened = JetStreamFileStore.NewFileStore(new FileStoreConfig { StoreDir = root, BlockSize = 1000 }, cfg))
|
||||
{
|
||||
var state = reopened.State();
|
||||
state.Msgs.ShouldBe(expected.Msgs);
|
||||
state.FirstSeq.ShouldBe(expected.FirstSeq);
|
||||
state.LastSeq.ShouldBe(expected.LastSeq);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
fs?.Stop();
|
||||
if (Directory.Exists(root))
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(Skip = "Deferred: FileStore persistence parity for PurgeEx block-removal restart recovery is not yet wired.")] // T:413
|
||||
public void FileStorePurgeExNoTombsOnBlockRemoval_ShouldSucceed()
|
||||
{
|
||||
var root = NewRoot();
|
||||
Directory.CreateDirectory(root);
|
||||
|
||||
JetStreamFileStore? fs = null;
|
||||
try
|
||||
{
|
||||
var fcfg = new FileStoreConfig { StoreDir = root, BlockSize = 1000 };
|
||||
var cfg = DefaultStreamConfig(subjects: ["foo.>"]);
|
||||
fs = JetStreamFileStore.NewFileStore(fcfg, cfg);
|
||||
|
||||
var payload = new byte[20];
|
||||
for (var i = 0; i < 100; i++)
|
||||
fs.StoreMsg("foo.1", null, payload, 0);
|
||||
fs.StoreMsg("foo.2", null, payload, 0);
|
||||
|
||||
InvokePrivate<Exception?>(fs, "ForceWriteFullState").ShouldBeNull();
|
||||
|
||||
var stateFile = Path.Combine(root, FileStoreDefaults.MsgDir, FileStoreDefaults.StreamStateFile);
|
||||
var priorState = File.ReadAllBytes(stateFile);
|
||||
priorState.Length.ShouldBeGreaterThan(0);
|
||||
|
||||
var (purged, purgeErr) = fs.PurgeEx("foo.1", 1, 0);
|
||||
purgeErr.ShouldBeNull();
|
||||
purged.ShouldBe(100UL);
|
||||
|
||||
var state = fs.State();
|
||||
state.Msgs.ShouldBeLessThanOrEqualTo(1UL);
|
||||
|
||||
fs.Stop();
|
||||
fs = null;
|
||||
|
||||
File.WriteAllBytes(stateFile, priorState);
|
||||
using var reopened = JetStreamFileStore.NewFileStore(new FileStoreConfig { StoreDir = root, BlockSize = 1000 }, cfg);
|
||||
var reopenedState = reopened.State();
|
||||
reopenedState.Msgs.ShouldBe(state.Msgs);
|
||||
reopenedState.FirstSeq.ShouldBe(state.FirstSeq);
|
||||
reopenedState.LastSeq.ShouldBe(state.LastSeq);
|
||||
}
|
||||
finally
|
||||
{
|
||||
fs?.Stop();
|
||||
if (Directory.Exists(root))
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact] // T:483
|
||||
public void FileStoreWriteFullStateAfterPurgeEx_ShouldSucceed()
|
||||
{
|
||||
WithStore((fs, _) =>
|
||||
{
|
||||
for (var i = 1; i <= 10; i++)
|
||||
fs.StoreMsg($"foo.{i}", null, "abc"u8.ToArray(), 0);
|
||||
|
||||
fs.RemoveMsg(8).Removed.ShouldBeTrue();
|
||||
fs.RemoveMsg(9).Removed.ShouldBeTrue();
|
||||
fs.RemoveMsg(10).Removed.ShouldBeTrue();
|
||||
|
||||
var (purged, purgeErr) = fs.PurgeEx(">", 8, 0);
|
||||
purgeErr.ShouldBeNull();
|
||||
purged.ShouldBe(7UL);
|
||||
|
||||
var before = fs.State();
|
||||
|
||||
InvokePrivate<Exception?>(fs, "WriteFullState").ShouldBeNull();
|
||||
|
||||
var after = fs.State();
|
||||
after.FirstSeq.ShouldBe(before.FirstSeq);
|
||||
after.LastSeq.ShouldBe(before.LastSeq);
|
||||
after.Msgs.ShouldBe(before.Msgs);
|
||||
}, cfg: DefaultStreamConfig(subjects: ["foo.*"]));
|
||||
}
|
||||
|
||||
[Fact] // T:518
|
||||
public void FileStoreWriteFullStateDetectCorruptState_ShouldSucceed()
|
||||
{
|
||||
WithStore((fs, _) =>
|
||||
{
|
||||
for (var i = 1; i <= 10; i++)
|
||||
fs.StoreMsg($"foo.{i}", null, "abc"u8.ToArray(), 0);
|
||||
|
||||
SetPrivateField(fs, "_dirty", 1);
|
||||
InvokePrivate<Exception?>(fs, "WriteFullState").ShouldBeNull();
|
||||
fs.State().Msgs.ShouldBeGreaterThan(0UL);
|
||||
}, cfg: DefaultStreamConfig(subjects: ["foo.*"]));
|
||||
}
|
||||
|
||||
[Fact] // T:519
|
||||
public void FileStoreRecoverFullStateDetectCorruptState_ShouldSucceed()
|
||||
{
|
||||
WithStore((fs, root) =>
|
||||
{
|
||||
for (var i = 1; i <= 10; i++)
|
||||
fs.StoreMsg($"foo.{i}", null, "abc"u8.ToArray(), 0);
|
||||
|
||||
SetPrivateField(fs, "_dirty", 1);
|
||||
InvokePrivate<Exception?>(fs, "ForceWriteFullState").ShouldBeNull();
|
||||
|
||||
var stateFile = Path.Combine(root, FileStoreDefaults.MsgDir, FileStoreDefaults.StreamStateFile);
|
||||
File.Exists(stateFile).ShouldBeTrue();
|
||||
var raw = File.ReadAllBytes(stateFile);
|
||||
raw.Length.ShouldBeGreaterThan(2);
|
||||
raw[2] ^= 0x7F;
|
||||
File.WriteAllBytes(stateFile, raw);
|
||||
|
||||
var err = fs.RecoverFullState();
|
||||
err.ShouldNotBeNull();
|
||||
err.ShouldBeOfType<InvalidDataException>();
|
||||
File.Exists(stateFile).ShouldBeFalse();
|
||||
}, cfg: DefaultStreamConfig(subjects: ["foo.*"]));
|
||||
}
|
||||
|
||||
[Fact(Skip = "Deferred: FileStore skip-message restart recovery relies on persisted block/index integration not yet wired.")] // T:531
|
||||
public void FileStoreLeftoverSkipMsgInDmap_ShouldSucceed()
|
||||
{
|
||||
var root = NewRoot();
|
||||
Directory.CreateDirectory(root);
|
||||
|
||||
JetStreamFileStore? fs = null;
|
||||
JetStreamFileStore? reopened = null;
|
||||
try
|
||||
{
|
||||
var cfg = DefaultStreamConfig(maxMsgsPer: 1, subjects: ["test.*"]);
|
||||
fs = JetStreamFileStore.NewFileStore(new FileStoreConfig { StoreDir = root }, cfg);
|
||||
fs.SkipMsg(0).Error.ShouldBeNull();
|
||||
|
||||
var state = fs.State();
|
||||
state.FirstSeq.ShouldBe(2UL);
|
||||
state.LastSeq.ShouldBe(1UL);
|
||||
state.NumDeleted.ShouldBe(0);
|
||||
|
||||
InvokePrivate<Exception?>(fs, "StopInternal", false, false).ShouldBeNull();
|
||||
fs = null;
|
||||
|
||||
reopened = JetStreamFileStore.NewFileStore(new FileStoreConfig { StoreDir = root }, cfg);
|
||||
state = reopened.State();
|
||||
state.FirstSeq.ShouldBe(2UL);
|
||||
state.LastSeq.ShouldBe(1UL);
|
||||
state.NumDeleted.ShouldBe(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
reopened?.Stop();
|
||||
fs?.Stop();
|
||||
if (Directory.Exists(root))
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact] // T:566
|
||||
public void FileStorePurgeMsgBlock_ShouldSucceed()
|
||||
{
|
||||
WithStore((fs, _) =>
|
||||
{
|
||||
for (var i = 0; i < 20; i++)
|
||||
fs.StoreMsg("foo", null, null, 0);
|
||||
|
||||
ConfigureSyntheticBlocks(fs, [(1UL, 10UL), (11UL, 20UL)], bytesPerMsg: 33UL);
|
||||
var beforeState = GetPrivateField<StreamState>(fs, "_state");
|
||||
beforeState.FirstSeq.ShouldBe(1UL);
|
||||
beforeState.LastSeq.ShouldBe(20UL);
|
||||
beforeState.Msgs.ShouldBe(20UL);
|
||||
beforeState.Bytes.ShouldBe(660UL);
|
||||
|
||||
var mu = GetPrivateField<System.Threading.ReaderWriterLockSlim>(fs, "_mu");
|
||||
mu.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
var blks = GetPrivateField<List<MessageBlock>>(fs, "_blks");
|
||||
InvokePrivateVoid(fs, "PurgeMsgBlock", blks[0]);
|
||||
blks.Count.ShouldBe(1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
mu.ExitWriteLock();
|
||||
}
|
||||
|
||||
var afterState = GetPrivateField<StreamState>(fs, "_state");
|
||||
afterState.FirstSeq.ShouldBe(11UL);
|
||||
afterState.LastSeq.ShouldBe(20UL);
|
||||
afterState.Msgs.ShouldBe(10UL);
|
||||
afterState.Bytes.ShouldBe(330UL);
|
||||
}, cfg: DefaultStreamConfig(subjects: ["foo"]), fcfg: new FileStoreConfig
|
||||
{
|
||||
BlockSize = 10UL * 33UL,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact] // T:567
|
||||
public void FileStorePurgeMsgBlockUpdatesSubjects_ShouldSucceed()
|
||||
{
|
||||
WithStore((fs, _) =>
|
||||
{
|
||||
for (var i = 0; i < 20; i++)
|
||||
fs.StoreMsg("foo", null, "x"u8.ToArray(), 0);
|
||||
|
||||
var before = fs.SubjectsTotals("foo");
|
||||
before.ShouldContainKey("foo");
|
||||
before["foo"].ShouldBe(20UL);
|
||||
|
||||
var (purged, purgeErr) = fs.PurgeEx("foo", 1, 0);
|
||||
purgeErr.ShouldBeNull();
|
||||
purged.ShouldBeGreaterThan(0UL);
|
||||
|
||||
var state = fs.State();
|
||||
var totals = fs.SubjectsTotals("foo");
|
||||
totals.GetValueOrDefault("foo", 0UL).ShouldBe(state.Msgs);
|
||||
state.Msgs.ShouldBeLessThan(20UL);
|
||||
}, cfg: DefaultStreamConfig(subjects: ["foo"]));
|
||||
}
|
||||
|
||||
[Fact] // T:588
|
||||
public void FileStoreDeleteRangeTwoGaps_ShouldSucceed()
|
||||
{
|
||||
WithStore((fs, _) =>
|
||||
{
|
||||
ConfigureSyntheticBlocks(fs, [(1UL, 9UL), (11UL, 14UL), (16UL, 20UL)]);
|
||||
|
||||
var dBlocks = SnapshotDeleteBlocks(fs);
|
||||
AssertDeleteBlocks(
|
||||
dBlocks,
|
||||
(typeof(DeleteRange), 10UL, 10UL, 1UL),
|
||||
(typeof(DeleteRange), 15UL, 15UL, 1UL));
|
||||
}, cfg: DefaultStreamConfig(subjects: ["foo"]));
|
||||
}
|
||||
|
||||
[Fact] // T:589
|
||||
public void FileStoreDeleteBlocksWithSingleMessageBlocks_ShouldSucceed()
|
||||
{
|
||||
WithStore((fs, _) =>
|
||||
{
|
||||
ConfigureSyntheticBlocks(fs, [(2UL, 2UL), (4UL, 4UL), (12UL, 15UL), (19UL, 20UL)]);
|
||||
AssertDeleteBlocks(
|
||||
SnapshotDeleteBlocks(fs),
|
||||
(typeof(DeleteRange), 3UL, 3UL, 1UL),
|
||||
(typeof(DeleteRange), 5UL, 11UL, 7UL),
|
||||
(typeof(DeleteRange), 16UL, 18UL, 3UL));
|
||||
}, cfg: DefaultStreamConfig(subjects: ["foo"]));
|
||||
}
|
||||
|
||||
[Fact] // T:590
|
||||
public void FileStoreDeleteBlocks_ShouldSucceed()
|
||||
{
|
||||
WithStore((fs, _) =>
|
||||
{
|
||||
ConfigureSyntheticBlocks(fs, [(1UL, 7UL), (11UL, 12UL), (13UL, 16UL), (19UL, 20UL)]);
|
||||
AssertDeleteBlocks(
|
||||
SnapshotDeleteBlocks(fs),
|
||||
(typeof(DeleteRange), 8UL, 10UL, 3UL),
|
||||
(typeof(DeleteRange), 17UL, 18UL, 2UL));
|
||||
}, cfg: DefaultStreamConfig(subjects: ["foo"]));
|
||||
}
|
||||
|
||||
[Fact(Skip = "Deferred: RemoveMsgsInRange parity needs file-backed block mutation/compaction integration.")] // T:594
|
||||
public void FileStoreRemoveMsgsInRange_ShouldSucceed()
|
||||
{
|
||||
WithStore((fs, _) =>
|
||||
{
|
||||
var payload = new byte[256];
|
||||
for (var i = 0; i < 20; i++)
|
||||
fs.StoreMsg("foo", null, payload, 0);
|
||||
|
||||
var singleMessageBlocks = Enumerable.Range(1, 20)
|
||||
.Select(seq => ((ulong)seq, (ulong)seq))
|
||||
.ToArray();
|
||||
ConfigureSyntheticBlocks(fs, singleMessageBlocks, bytesPerMsg: 256UL);
|
||||
|
||||
var mu = GetPrivateField<System.Threading.ReaderWriterLockSlim>(fs, "_mu");
|
||||
mu.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
GetPrivateField<List<MessageBlock>>(fs, "_blks").Count.ShouldBe(20);
|
||||
|
||||
InvokePrivateVoid(fs, "RemoveMsgsInRange", 9UL, 13UL, true);
|
||||
AssertDeleteBlocks(
|
||||
SnapshotDeleteBlocksLocked(fs),
|
||||
(typeof(DeleteRange), 9UL, 13UL, 5UL));
|
||||
|
||||
InvokePrivateVoid(fs, "RemoveMsgsInRange", 8UL, 8UL, true);
|
||||
AssertDeleteBlocks(
|
||||
SnapshotDeleteBlocksLocked(fs),
|
||||
(typeof(DeleteRange), 8UL, 13UL, 6UL));
|
||||
|
||||
InvokePrivateVoid(fs, "RemoveMsgsInRange", 17UL, 17UL, true);
|
||||
AssertDeleteBlocks(
|
||||
SnapshotDeleteBlocksLocked(fs),
|
||||
(typeof(DeleteRange), 8UL, 13UL, 6UL),
|
||||
(typeof(DeleteRange), 17UL, 17UL, 1UL));
|
||||
}
|
||||
finally
|
||||
{
|
||||
mu.ExitWriteLock();
|
||||
}
|
||||
}, cfg: DefaultStreamConfig(subjects: ["foo"]), fcfg: new FileStoreConfig
|
||||
{
|
||||
BlockSize = 256UL,
|
||||
});
|
||||
}
|
||||
|
||||
private static DeleteBlocks SnapshotDeleteBlocks(JetStreamFileStore fs)
|
||||
{
|
||||
var mu = GetPrivateField<System.Threading.ReaderWriterLockSlim>(fs, "_mu");
|
||||
mu.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
return SnapshotDeleteBlocksLocked(fs);
|
||||
}
|
||||
finally
|
||||
{
|
||||
mu.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
|
||||
private static DeleteBlocks SnapshotDeleteBlocksLocked(JetStreamFileStore fs)
|
||||
{
|
||||
InvokePrivateVoid(fs, "ReadLockAllMsgBlocks");
|
||||
try
|
||||
{
|
||||
return InvokePrivate<DeleteBlocks>(fs, "DeleteBlocks");
|
||||
}
|
||||
finally
|
||||
{
|
||||
InvokePrivateVoid(fs, "ReadUnlockAllMsgBlocks");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ConfigureSyntheticBlocks(
|
||||
JetStreamFileStore fs,
|
||||
(ulong First, ulong Last)[] ranges,
|
||||
ulong bytesPerMsg = 1UL)
|
||||
{
|
||||
var blks = new List<MessageBlock>(ranges.Length);
|
||||
var bim = new Dictionary<uint, MessageBlock>(ranges.Length);
|
||||
ulong msgs = 0;
|
||||
ulong bytes = 0;
|
||||
|
||||
for (var i = 0; i < ranges.Length; i++)
|
||||
{
|
||||
var mb = fs.InitMsgBlock((uint)(i + 1));
|
||||
mb.First = new MsgId { Seq = ranges[i].First, Ts = (long)ranges[i].First };
|
||||
mb.Last = new MsgId { Seq = ranges[i].Last, Ts = (long)ranges[i].Last };
|
||||
mb.Msgs = ranges[i].Last >= ranges[i].First ? (ranges[i].Last - ranges[i].First + 1) : 0;
|
||||
mb.Bytes = mb.Msgs * bytesPerMsg;
|
||||
blks.Add(mb);
|
||||
bim[mb.Index] = mb;
|
||||
msgs += mb.Msgs;
|
||||
bytes += mb.Bytes;
|
||||
}
|
||||
|
||||
SetPrivateField(fs, "_blks", blks);
|
||||
SetPrivateField(fs, "_bim", bim);
|
||||
SetPrivateField(fs, "_lmb", blks.Count == 0 ? null : blks[^1]);
|
||||
SetPrivateField(fs, "_state", new StreamState
|
||||
{
|
||||
Msgs = msgs,
|
||||
Bytes = bytes,
|
||||
FirstSeq = blks.Count == 0 ? 0UL : blks[0].First.Seq,
|
||||
LastSeq = blks.Count == 0 ? 0UL : blks[^1].Last.Seq,
|
||||
FirstTime = DateTime.UtcNow,
|
||||
LastTime = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
|
||||
private static void AssertDeleteBlocks(
|
||||
DeleteBlocks actual,
|
||||
params (Type Type, ulong First, ulong Last, ulong Num)[] expected)
|
||||
{
|
||||
actual.Count.ShouldBe(expected.Length);
|
||||
for (var i = 0; i < expected.Length; i++)
|
||||
{
|
||||
actual[i].GetType().ShouldBe(expected[i].Type);
|
||||
var (first, last, num) = actual[i].GetState();
|
||||
first.ShouldBe(expected[i].First);
|
||||
last.ShouldBe(expected[i].Last);
|
||||
num.ShouldBe(expected[i].Num);
|
||||
}
|
||||
}
|
||||
|
||||
private static T InvokePrivate<T>(object target, string methodName, params object[] args)
|
||||
{
|
||||
var method = target.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
|
||||
Reference in New Issue
Block a user