Add bulk read/write command family across worker, gateway, and clients
Adds five new MXAccess command kinds (WriteBulk, Write2Bulk,
WriteSecuredBulk, WriteSecured2Bulk, ReadBulk) that ride the existing
"one round-trip, per-entry results" bulk shape used by AddItemBulk and
SubscribeBulk today. MXAccess COM has no native bulk API; the worker
runs each bulk operation as a sequential loop on its STA, returning
one BulkWriteResult / BulkReadResult per requested entry so per-item
MXAccess failures surface as was_successful=false rather than throwing.
ReadBulk has no MXAccess analogue. The worker satisfies it by:
- Returning the last cached OnDataChange payload (was_cached=true)
when the requested tag is already in the session''s item registry
AND advised — the existing subscription is NOT touched, since the
caller did not create it.
- Otherwise taking the AddItem + Advise + wait-for-OnDataChange +
UnAdvise + RemoveItem snapshot lifecycle itself (was_cached=false)
and leaving the session exactly as it was. The wait pumps Windows
messages on the STA so the inbound MXAccess event can dispatch
while the executor still holds the thread.
The new MxAccessValueCache lives on each MxAccessSession, shared with
MxAccessBaseEventSink which populates it on every OnDataChange after
the event clears the outbound queue. Eviction on RemoveItem keeps
reused MXAccess handles from serving stale values from a previous
lifetime.
Gateway-side authorization wires WriteBulk/Write2Bulk to invoke:write,
WriteSecuredBulk/WriteSecured2Bulk to invoke:secure, ReadBulk to
invoke:read. The constraint-filter pipeline is refactored from a single
BulkConstraintPlan record into an abstract base plus three concretes
(SubscribeBulk, WriteBulk, ReadBulk), each owning its own denied-entry
merge so the dispatch site never branches on reply shape. A new
FilterWriteBulkAsync<TEntry> generic over the four write-entry shapes
runs CheckWriteHandleAsync per entry; denied entries surface as the
BulkWriteResult shape, preserving original-index order.
All five language clients (.NET, Go, Rust, Python, Java) gained the
five new methods following their existing bulk pattern, with regenerated
protobufs.
Tests added:
- MxAccessValueCacheTests (6 cases) — Set/TryGet, Remove resets the
version, TryWaitForUpdate signals on Set, pump step fires each poll.
- MxAccessBaseEventSinkTests — OnDataChange populates the cache,
ValueCache property exposes the bound instance.
- MxAccessCommandExecutorTests — four bulk-write variants (per-entry
success/failure, value+timestamp forwarding, secured user ids),
ReadBulk snapshot lifecycle on uncached tag (timeout surfaces as
was_successful=false), invalid-payload reply.
- GatewayGrpcScopeResolverTests — five new MxCommandKind cases.
- SessionManagerTests — WriteBulk and ReadBulk forwarding through
FakeWorkerHarness; ReadBulk forwards timeout_ms.
- Per-client (.NET, Go, Rust, Python, Java) — WriteBulk builds the
right command and returns per-entry results, ReadBulk forwards the
timeout and unpacks the was_cached flag.
Cross-language e2e CLI subcommands for the new bulks are deliberately
scoped out of this change (each of the five client CLIs would need
five new subcommands plus matching phases in
scripts/run-client-e2e-tests.ps1); coverage equivalent to the existing
bulk-subscribe coverage is provided by worker + gateway + per-client
unit tests.
Docs updated in the same commit: gateway.md (Public MXAccess Command
Surface), docs/DesignDecisions.md (new "Bulk Command Family" section
with the ReadBulk cache-then-snapshot rationale), and every client
README.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -109,7 +109,7 @@ public sealed class MxAccessGatewayService(
|
||||
MxCommand commandToInvoke = bulkConstraintPlan?.Command ?? command;
|
||||
if (bulkConstraintPlan is { HasAllowedItems: false })
|
||||
{
|
||||
return CreateDeniedBulkReply(request, bulkConstraintPlan);
|
||||
return bulkConstraintPlan.CreateDeniedReply(request);
|
||||
}
|
||||
|
||||
MxCommandRequest invokeRequest = request.Clone();
|
||||
@@ -122,7 +122,7 @@ public sealed class MxAccessGatewayService(
|
||||
MxCommandReply publicReply = mapper.MapCommandReply(workerReply);
|
||||
if (bulkConstraintPlan is not null)
|
||||
{
|
||||
publicReply = MergeDeniedBulkResults(publicReply, command.Kind, bulkConstraintPlan);
|
||||
publicReply = bulkConstraintPlan.MergeDeniedInto(publicReply);
|
||||
}
|
||||
|
||||
session.TrackCommandReply(commandToInvoke, publicReply);
|
||||
@@ -304,6 +304,54 @@ public sealed class MxAccessGatewayService(
|
||||
command.AdviseItemBulk.ItemHandles,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
case MxCommandKind.ReadBulk:
|
||||
return await FilterReadBulkAsync(
|
||||
identity,
|
||||
command,
|
||||
command.ReadBulk.ServerHandle,
|
||||
command.ReadBulk.TagAddresses,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
case MxCommandKind.WriteBulk:
|
||||
return await FilterWriteBulkAsync(
|
||||
identity,
|
||||
session,
|
||||
command,
|
||||
command.WriteBulk.ServerHandle,
|
||||
command.WriteBulk.Entries,
|
||||
entry => entry.ItemHandle,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
case MxCommandKind.Write2Bulk:
|
||||
return await FilterWriteBulkAsync(
|
||||
identity,
|
||||
session,
|
||||
command,
|
||||
command.Write2Bulk.ServerHandle,
|
||||
command.Write2Bulk.Entries,
|
||||
entry => entry.ItemHandle,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
case MxCommandKind.WriteSecuredBulk:
|
||||
return await FilterWriteBulkAsync(
|
||||
identity,
|
||||
session,
|
||||
command,
|
||||
command.WriteSecuredBulk.ServerHandle,
|
||||
command.WriteSecuredBulk.Entries,
|
||||
entry => entry.ItemHandle,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
case MxCommandKind.WriteSecured2Bulk:
|
||||
return await FilterWriteBulkAsync(
|
||||
identity,
|
||||
session,
|
||||
command,
|
||||
command.WriteSecured2Bulk.ServerHandle,
|
||||
command.WriteSecured2Bulk.Entries,
|
||||
entry => entry.ItemHandle,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
case MxCommandKind.Write:
|
||||
await EnforceWriteHandleAsync(
|
||||
identity,
|
||||
@@ -438,7 +486,136 @@ public sealed class MxAccessGatewayService(
|
||||
filtered.SubscribeBulk.TagAddresses.Add(allowed);
|
||||
}
|
||||
|
||||
return new BulkConstraintPlan(filtered, tagAddresses.Count, denied, allowed.Count > 0);
|
||||
return new SubscribeBulkConstraintPlan(filtered, tagAddresses.Count, denied, allowed.Count > 0);
|
||||
}
|
||||
|
||||
private async Task<BulkConstraintPlan?> FilterReadBulkAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
MxCommand command,
|
||||
int serverHandle,
|
||||
IReadOnlyList<string> tagAddresses,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Mirrors FilterTagBulkAsync but produces BulkReadResult denial entries
|
||||
// so the reply payload merges into BulkReadReply.Results, not
|
||||
// BulkSubscribeReply.Results.
|
||||
Dictionary<int, BulkReadResult> denied = [];
|
||||
List<string> allowed = [];
|
||||
for (int index = 0; index < tagAddresses.Count; index++)
|
||||
{
|
||||
string tagAddress = tagAddresses[index];
|
||||
ConstraintFailure? failure = await constraintEnforcer
|
||||
.CheckReadTagAsync(identity, tagAddress, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (failure is null)
|
||||
{
|
||||
allowed.Add(tagAddress);
|
||||
continue;
|
||||
}
|
||||
|
||||
await constraintEnforcer.RecordDenialAsync(identity, command.Kind.ToString(), tagAddress, failure, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
denied[index] = new BulkReadResult
|
||||
{
|
||||
ServerHandle = serverHandle,
|
||||
TagAddress = tagAddress,
|
||||
WasSuccessful = false,
|
||||
WasCached = false,
|
||||
ErrorMessage = failure.Message,
|
||||
};
|
||||
}
|
||||
|
||||
if (denied.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
MxCommand filtered = command.Clone();
|
||||
filtered.ReadBulk.TagAddresses.Clear();
|
||||
filtered.ReadBulk.TagAddresses.Add(allowed);
|
||||
|
||||
return new ReadBulkConstraintPlan(filtered, tagAddresses.Count, denied, allowed.Count > 0);
|
||||
}
|
||||
|
||||
private async Task<BulkConstraintPlan?> FilterWriteBulkAsync<TEntry>(
|
||||
ApiKeyIdentity? identity,
|
||||
GatewaySession session,
|
||||
MxCommand command,
|
||||
int serverHandle,
|
||||
Google.Protobuf.Collections.RepeatedField<TEntry> entries,
|
||||
Func<TEntry, int> getItemHandle,
|
||||
CancellationToken cancellationToken) where TEntry : class
|
||||
{
|
||||
// The four bulk-write families each carry a different per-entry message
|
||||
// shape (WriteBulkEntry / Write2BulkEntry / WriteSecuredBulkEntry /
|
||||
// WriteSecured2BulkEntry), but the constraint check itself is identical
|
||||
// — "is this caller allowed to write to this server+item handle?".
|
||||
// Parameterising on TEntry + getItemHandle keeps a single filter
|
||||
// routine for all four and avoids duplicating CheckWriteHandleAsync
|
||||
// calls.
|
||||
Dictionary<int, BulkWriteResult> denied = [];
|
||||
List<TEntry> allowed = [];
|
||||
for (int index = 0; index < entries.Count; index++)
|
||||
{
|
||||
TEntry entry = entries[index];
|
||||
int itemHandle = getItemHandle(entry);
|
||||
ConstraintFailure? failure = await constraintEnforcer
|
||||
.CheckWriteHandleAsync(identity, session, serverHandle, itemHandle, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (failure is null)
|
||||
{
|
||||
allowed.Add(entry);
|
||||
continue;
|
||||
}
|
||||
|
||||
await constraintEnforcer.RecordDenialAsync(
|
||||
identity,
|
||||
command.Kind.ToString(),
|
||||
itemHandle.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
failure,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
denied[index] = new BulkWriteResult
|
||||
{
|
||||
ServerHandle = serverHandle,
|
||||
ItemHandle = itemHandle,
|
||||
WasSuccessful = false,
|
||||
ErrorMessage = failure.Message,
|
||||
};
|
||||
}
|
||||
|
||||
if (denied.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
MxCommand filtered = command.Clone();
|
||||
ReplaceWriteBulkEntries(filtered, allowed);
|
||||
return new WriteBulkConstraintPlan(filtered, entries.Count, denied, allowed.Count > 0);
|
||||
}
|
||||
|
||||
private static void ReplaceWriteBulkEntries<TEntry>(MxCommand command, IReadOnlyList<TEntry> allowed)
|
||||
where TEntry : class
|
||||
{
|
||||
switch (command.Kind)
|
||||
{
|
||||
case MxCommandKind.WriteBulk:
|
||||
command.WriteBulk.Entries.Clear();
|
||||
command.WriteBulk.Entries.Add((IEnumerable<WriteBulkEntry>)allowed);
|
||||
break;
|
||||
case MxCommandKind.Write2Bulk:
|
||||
command.Write2Bulk.Entries.Clear();
|
||||
command.Write2Bulk.Entries.Add((IEnumerable<Write2BulkEntry>)allowed);
|
||||
break;
|
||||
case MxCommandKind.WriteSecuredBulk:
|
||||
command.WriteSecuredBulk.Entries.Clear();
|
||||
command.WriteSecuredBulk.Entries.Add((IEnumerable<WriteSecuredBulkEntry>)allowed);
|
||||
break;
|
||||
case MxCommandKind.WriteSecured2Bulk:
|
||||
command.WriteSecured2Bulk.Entries.Clear();
|
||||
command.WriteSecured2Bulk.Entries.Add((IEnumerable<WriteSecured2BulkEntry>)allowed);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<BulkConstraintPlan?> FilterHandleBulkAsync(
|
||||
@@ -483,90 +660,221 @@ public sealed class MxAccessGatewayService(
|
||||
filtered.AdviseItemBulk.ItemHandles.Clear();
|
||||
filtered.AdviseItemBulk.ItemHandles.Add(allowed);
|
||||
|
||||
return new BulkConstraintPlan(filtered, itemHandles.Count, denied, allowed.Count > 0);
|
||||
return new SubscribeBulkConstraintPlan(filtered, itemHandles.Count, denied, allowed.Count > 0);
|
||||
}
|
||||
|
||||
private static MxCommandReply CreateDeniedBulkReply(
|
||||
MxCommandRequest request,
|
||||
BulkConstraintPlan plan)
|
||||
/// <summary>
|
||||
/// Polymorphic constraint plan returned from <see cref="ApplyConstraintsAsync"/>.
|
||||
/// Each concrete subtype is keyed to a specific bulk-reply shape — the
|
||||
/// SubscribeResult-based AddItem/Advise/Subscribe family, the
|
||||
/// BulkWriteResult-based Write* bulk family, and the BulkReadResult-based
|
||||
/// ReadBulk command. Subtypes own their own merge / denied-reply build
|
||||
/// logic so the Invoke dispatch site never branches on reply shape.
|
||||
/// </summary>
|
||||
private abstract record BulkConstraintPlan(
|
||||
MxCommand Command,
|
||||
int OriginalCount,
|
||||
bool HasAllowedItems)
|
||||
{
|
||||
MxCommandReply reply = new()
|
||||
/// <summary>Builds a reply containing only the denied entries (used when no items survived filtering).</summary>
|
||||
public abstract MxCommandReply CreateDeniedReply(MxCommandRequest request);
|
||||
|
||||
/// <summary>Splices denied entries back into the worker's allowed-only reply in original-index order.</summary>
|
||||
public abstract MxCommandReply MergeDeniedInto(MxCommandReply reply);
|
||||
}
|
||||
|
||||
private sealed record SubscribeBulkConstraintPlan(
|
||||
MxCommand Command,
|
||||
int OriginalCount,
|
||||
IReadOnlyDictionary<int, SubscribeResult> DeniedResults,
|
||||
bool HasAllowedItems)
|
||||
: BulkConstraintPlan(Command, OriginalCount, HasAllowedItems)
|
||||
{
|
||||
public override MxCommandReply CreateDeniedReply(MxCommandRequest request)
|
||||
{
|
||||
SessionId = request.SessionId,
|
||||
CorrelationId = request.ClientCorrelationId,
|
||||
Kind = request.Command.Kind,
|
||||
ProtocolStatus = MxAccessGrpcMapper.Ok(),
|
||||
};
|
||||
SetBulkPayload(reply, request.Command.Kind, BuildMergedBulkReply(new BulkSubscribeReply(), plan));
|
||||
return reply;
|
||||
}
|
||||
|
||||
private static MxCommandReply MergeDeniedBulkResults(
|
||||
MxCommandReply reply,
|
||||
MxCommandKind commandKind,
|
||||
BulkConstraintPlan plan)
|
||||
{
|
||||
BulkSubscribeReply allowed = GetBulkPayload(reply, commandKind) ?? new BulkSubscribeReply();
|
||||
SetBulkPayload(reply, commandKind, BuildMergedBulkReply(allowed, plan));
|
||||
return reply;
|
||||
}
|
||||
|
||||
private static BulkSubscribeReply BuildMergedBulkReply(
|
||||
BulkSubscribeReply allowed,
|
||||
BulkConstraintPlan plan)
|
||||
{
|
||||
Queue<SubscribeResult> allowedResults = new(allowed.Results);
|
||||
BulkSubscribeReply merged = new();
|
||||
for (int index = 0; index < plan.OriginalCount; index++)
|
||||
{
|
||||
if (plan.DeniedResults.TryGetValue(index, out SubscribeResult? denied))
|
||||
MxCommandReply reply = new()
|
||||
{
|
||||
merged.Results.Add(denied);
|
||||
}
|
||||
else if (allowedResults.TryDequeue(out SubscribeResult? allowedResult))
|
||||
{
|
||||
merged.Results.Add(allowedResult);
|
||||
}
|
||||
SessionId = request.SessionId,
|
||||
CorrelationId = request.ClientCorrelationId,
|
||||
Kind = request.Command.Kind,
|
||||
ProtocolStatus = MxAccessGrpcMapper.Ok(),
|
||||
};
|
||||
SetPayload(reply, BuildMerged(new BulkSubscribeReply()));
|
||||
return reply;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
public override MxCommandReply MergeDeniedInto(MxCommandReply reply)
|
||||
{
|
||||
BulkSubscribeReply allowed = GetPayload(reply) ?? new BulkSubscribeReply();
|
||||
SetPayload(reply, BuildMerged(allowed));
|
||||
return reply;
|
||||
}
|
||||
|
||||
private static BulkSubscribeReply? GetBulkPayload(MxCommandReply reply, MxCommandKind commandKind)
|
||||
{
|
||||
return commandKind switch
|
||||
private BulkSubscribeReply BuildMerged(BulkSubscribeReply allowed)
|
||||
{
|
||||
Queue<SubscribeResult> allowedResults = new(allowed.Results);
|
||||
BulkSubscribeReply merged = new();
|
||||
for (int index = 0; index < OriginalCount; index++)
|
||||
{
|
||||
if (DeniedResults.TryGetValue(index, out SubscribeResult? denied))
|
||||
{
|
||||
merged.Results.Add(denied);
|
||||
}
|
||||
else if (allowedResults.TryDequeue(out SubscribeResult? allowedResult))
|
||||
{
|
||||
merged.Results.Add(allowedResult);
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
private BulkSubscribeReply? GetPayload(MxCommandReply reply) => Command.Kind switch
|
||||
{
|
||||
MxCommandKind.AddItemBulk => reply.AddItemBulk,
|
||||
MxCommandKind.AdviseItemBulk => reply.AdviseItemBulk,
|
||||
MxCommandKind.SubscribeBulk => reply.SubscribeBulk,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static void SetBulkPayload(
|
||||
MxCommandReply reply,
|
||||
MxCommandKind commandKind,
|
||||
BulkSubscribeReply payload)
|
||||
{
|
||||
switch (commandKind)
|
||||
private void SetPayload(MxCommandReply reply, BulkSubscribeReply payload)
|
||||
{
|
||||
case MxCommandKind.AddItemBulk:
|
||||
reply.AddItemBulk = payload;
|
||||
break;
|
||||
case MxCommandKind.AdviseItemBulk:
|
||||
reply.AdviseItemBulk = payload;
|
||||
break;
|
||||
case MxCommandKind.SubscribeBulk:
|
||||
reply.SubscribeBulk = payload;
|
||||
break;
|
||||
switch (Command.Kind)
|
||||
{
|
||||
case MxCommandKind.AddItemBulk:
|
||||
reply.AddItemBulk = payload;
|
||||
break;
|
||||
case MxCommandKind.AdviseItemBulk:
|
||||
reply.AdviseItemBulk = payload;
|
||||
break;
|
||||
case MxCommandKind.SubscribeBulk:
|
||||
reply.SubscribeBulk = payload;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record BulkConstraintPlan(
|
||||
private sealed record WriteBulkConstraintPlan(
|
||||
MxCommand Command,
|
||||
int OriginalCount,
|
||||
IReadOnlyDictionary<int, SubscribeResult> DeniedResults,
|
||||
bool HasAllowedItems);
|
||||
IReadOnlyDictionary<int, BulkWriteResult> DeniedResults,
|
||||
bool HasAllowedItems)
|
||||
: BulkConstraintPlan(Command, OriginalCount, HasAllowedItems)
|
||||
{
|
||||
public override MxCommandReply CreateDeniedReply(MxCommandRequest request)
|
||||
{
|
||||
MxCommandReply reply = new()
|
||||
{
|
||||
SessionId = request.SessionId,
|
||||
CorrelationId = request.ClientCorrelationId,
|
||||
Kind = request.Command.Kind,
|
||||
ProtocolStatus = MxAccessGrpcMapper.Ok(),
|
||||
};
|
||||
SetPayload(reply, BuildMerged(new BulkWriteReply()));
|
||||
return reply;
|
||||
}
|
||||
|
||||
public override MxCommandReply MergeDeniedInto(MxCommandReply reply)
|
||||
{
|
||||
BulkWriteReply allowed = GetPayload(reply) ?? new BulkWriteReply();
|
||||
SetPayload(reply, BuildMerged(allowed));
|
||||
return reply;
|
||||
}
|
||||
|
||||
private BulkWriteReply BuildMerged(BulkWriteReply allowed)
|
||||
{
|
||||
Queue<BulkWriteResult> allowedResults = new(allowed.Results);
|
||||
BulkWriteReply merged = new();
|
||||
for (int index = 0; index < OriginalCount; index++)
|
||||
{
|
||||
if (DeniedResults.TryGetValue(index, out BulkWriteResult? denied))
|
||||
{
|
||||
merged.Results.Add(denied);
|
||||
}
|
||||
else if (allowedResults.TryDequeue(out BulkWriteResult? allowedResult))
|
||||
{
|
||||
merged.Results.Add(allowedResult);
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
private BulkWriteReply? GetPayload(MxCommandReply reply) => Command.Kind switch
|
||||
{
|
||||
MxCommandKind.WriteBulk => reply.WriteBulk,
|
||||
MxCommandKind.Write2Bulk => reply.Write2Bulk,
|
||||
MxCommandKind.WriteSecuredBulk => reply.WriteSecuredBulk,
|
||||
MxCommandKind.WriteSecured2Bulk => reply.WriteSecured2Bulk,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private void SetPayload(MxCommandReply reply, BulkWriteReply payload)
|
||||
{
|
||||
switch (Command.Kind)
|
||||
{
|
||||
case MxCommandKind.WriteBulk:
|
||||
reply.WriteBulk = payload;
|
||||
break;
|
||||
case MxCommandKind.Write2Bulk:
|
||||
reply.Write2Bulk = payload;
|
||||
break;
|
||||
case MxCommandKind.WriteSecuredBulk:
|
||||
reply.WriteSecuredBulk = payload;
|
||||
break;
|
||||
case MxCommandKind.WriteSecured2Bulk:
|
||||
reply.WriteSecured2Bulk = payload;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record ReadBulkConstraintPlan(
|
||||
MxCommand Command,
|
||||
int OriginalCount,
|
||||
IReadOnlyDictionary<int, BulkReadResult> DeniedResults,
|
||||
bool HasAllowedItems)
|
||||
: BulkConstraintPlan(Command, OriginalCount, HasAllowedItems)
|
||||
{
|
||||
public override MxCommandReply CreateDeniedReply(MxCommandRequest request)
|
||||
{
|
||||
MxCommandReply reply = new()
|
||||
{
|
||||
SessionId = request.SessionId,
|
||||
CorrelationId = request.ClientCorrelationId,
|
||||
Kind = request.Command.Kind,
|
||||
ProtocolStatus = MxAccessGrpcMapper.Ok(),
|
||||
};
|
||||
reply.ReadBulk = BuildMerged(new BulkReadReply());
|
||||
return reply;
|
||||
}
|
||||
|
||||
public override MxCommandReply MergeDeniedInto(MxCommandReply reply)
|
||||
{
|
||||
BulkReadReply allowed = reply.ReadBulk ?? new BulkReadReply();
|
||||
reply.ReadBulk = BuildMerged(allowed);
|
||||
return reply;
|
||||
}
|
||||
|
||||
private BulkReadReply BuildMerged(BulkReadReply allowed)
|
||||
{
|
||||
Queue<BulkReadResult> allowedResults = new(allowed.Results);
|
||||
BulkReadReply merged = new();
|
||||
for (int index = 0; index < OriginalCount; index++)
|
||||
{
|
||||
if (DeniedResults.TryGetValue(index, out BulkReadResult? denied))
|
||||
{
|
||||
merged.Results.Add(denied);
|
||||
}
|
||||
else if (allowedResults.TryDequeue(out BulkReadResult? allowedResult))
|
||||
{
|
||||
merged.Results.Add(allowedResult);
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
}
|
||||
|
||||
private RpcException MapException(Exception exception)
|
||||
{
|
||||
|
||||
@@ -99,6 +99,11 @@ public sealed class MxAccessGrpcRequestValidator
|
||||
MxCommandKind.UnAdviseItemBulk => MxCommand.PayloadOneofCase.UnAdviseItemBulk,
|
||||
MxCommandKind.SubscribeBulk => MxCommand.PayloadOneofCase.SubscribeBulk,
|
||||
MxCommandKind.UnsubscribeBulk => MxCommand.PayloadOneofCase.UnsubscribeBulk,
|
||||
MxCommandKind.WriteBulk => MxCommand.PayloadOneofCase.WriteBulk,
|
||||
MxCommandKind.Write2Bulk => MxCommand.PayloadOneofCase.Write2Bulk,
|
||||
MxCommandKind.WriteSecuredBulk => MxCommand.PayloadOneofCase.WriteSecuredBulk,
|
||||
MxCommandKind.WriteSecured2Bulk => MxCommand.PayloadOneofCase.WriteSecured2Bulk,
|
||||
MxCommandKind.ReadBulk => MxCommand.PayloadOneofCase.ReadBulk,
|
||||
MxCommandKind.Ping => MxCommand.PayloadOneofCase.Ping,
|
||||
MxCommandKind.GetSessionState => MxCommand.PayloadOneofCase.GetSessionState,
|
||||
MxCommandKind.GetWorkerInfo => MxCommand.PayloadOneofCase.GetWorkerInfo,
|
||||
|
||||
@@ -31,10 +31,14 @@ public sealed class GatewayGrpcScopeResolver
|
||||
return kind switch
|
||||
{
|
||||
MxCommandKind.Write or
|
||||
MxCommandKind.Write2 => GatewayScopes.InvokeWrite,
|
||||
MxCommandKind.Write2 or
|
||||
MxCommandKind.WriteBulk or
|
||||
MxCommandKind.Write2Bulk => GatewayScopes.InvokeWrite,
|
||||
|
||||
MxCommandKind.WriteSecured or
|
||||
MxCommandKind.WriteSecured2 or
|
||||
MxCommandKind.WriteSecuredBulk or
|
||||
MxCommandKind.WriteSecured2Bulk or
|
||||
MxCommandKind.AuthenticateUser => GatewayScopes.InvokeSecure,
|
||||
|
||||
MxCommandKind.ArchestraUserToId or
|
||||
|
||||
@@ -590,6 +590,116 @@ public sealed class GatewaySession
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a bulk Write command for the specified server and per-item entries.
|
||||
/// </summary>
|
||||
public Task<IReadOnlyList<BulkWriteResult>> WriteBulkAsync(
|
||||
int serverHandle,
|
||||
IReadOnlyList<WriteBulkEntry> entries,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entries);
|
||||
|
||||
WriteBulkCommand bulkCommand = new() { ServerHandle = serverHandle };
|
||||
bulkCommand.Entries.Add(entries);
|
||||
return InvokeBulkWriteAsync(
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.WriteBulk,
|
||||
WriteBulk = bulkCommand,
|
||||
},
|
||||
reply => reply.WriteBulk,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Executes a bulk Write2 (timestamped) command.</summary>
|
||||
public Task<IReadOnlyList<BulkWriteResult>> Write2BulkAsync(
|
||||
int serverHandle,
|
||||
IReadOnlyList<Write2BulkEntry> entries,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entries);
|
||||
|
||||
Write2BulkCommand bulkCommand = new() { ServerHandle = serverHandle };
|
||||
bulkCommand.Entries.Add(entries);
|
||||
return InvokeBulkWriteAsync(
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.Write2Bulk,
|
||||
Write2Bulk = bulkCommand,
|
||||
},
|
||||
reply => reply.Write2Bulk,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Executes a bulk WriteSecured command.</summary>
|
||||
public Task<IReadOnlyList<BulkWriteResult>> WriteSecuredBulkAsync(
|
||||
int serverHandle,
|
||||
IReadOnlyList<WriteSecuredBulkEntry> entries,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entries);
|
||||
|
||||
WriteSecuredBulkCommand bulkCommand = new() { ServerHandle = serverHandle };
|
||||
bulkCommand.Entries.Add(entries);
|
||||
return InvokeBulkWriteAsync(
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.WriteSecuredBulk,
|
||||
WriteSecuredBulk = bulkCommand,
|
||||
},
|
||||
reply => reply.WriteSecuredBulk,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Executes a bulk WriteSecured2 command.</summary>
|
||||
public Task<IReadOnlyList<BulkWriteResult>> WriteSecured2BulkAsync(
|
||||
int serverHandle,
|
||||
IReadOnlyList<WriteSecured2BulkEntry> entries,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entries);
|
||||
|
||||
WriteSecured2BulkCommand bulkCommand = new() { ServerHandle = serverHandle };
|
||||
bulkCommand.Entries.Add(entries);
|
||||
return InvokeBulkWriteAsync(
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.WriteSecured2Bulk,
|
||||
WriteSecured2Bulk = bulkCommand,
|
||||
},
|
||||
reply => reply.WriteSecured2Bulk,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a bulk Read command — see <c>ReadBulkCommand</c>'s doc
|
||||
/// comment in the .proto for the cached-vs-snapshot semantics.
|
||||
/// </summary>
|
||||
public Task<IReadOnlyList<BulkReadResult>> ReadBulkAsync(
|
||||
int serverHandle,
|
||||
IReadOnlyList<string> tagAddresses,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tagAddresses);
|
||||
|
||||
ReadBulkCommand bulkCommand = new()
|
||||
{
|
||||
ServerHandle = serverHandle,
|
||||
TimeoutMs = timeout <= TimeSpan.Zero ? 0u : (uint)Math.Min(timeout.TotalMilliseconds, uint.MaxValue),
|
||||
};
|
||||
bulkCommand.TagAddresses.Add(tagAddresses);
|
||||
return InvokeBulkReadAsync(
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.ReadBulk,
|
||||
ReadBulk = bulkCommand,
|
||||
},
|
||||
reply => reply.ReadBulk,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads events from the worker as an asynchronous enumerable stream.
|
||||
/// </summary>
|
||||
@@ -690,6 +800,36 @@ public sealed class GatewaySession
|
||||
MxCommand command,
|
||||
Func<MxCommandReply, BulkSubscribeReply?> payloadAccessor,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
MxCommandReply reply = await InvokeBulkInternalAsync(command, cancellationToken).ConfigureAwait(false);
|
||||
return payloadAccessor(reply)?.Results.ToArray() ?? [];
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<BulkWriteResult>> InvokeBulkWriteAsync(
|
||||
MxCommand command,
|
||||
Func<MxCommandReply, BulkWriteReply?> payloadAccessor,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
MxCommandReply reply = await InvokeBulkInternalAsync(command, cancellationToken).ConfigureAwait(false);
|
||||
return payloadAccessor(reply)?.Results.ToArray() ?? [];
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<BulkReadResult>> InvokeBulkReadAsync(
|
||||
MxCommand command,
|
||||
Func<MxCommandReply, BulkReadReply?> payloadAccessor,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
MxCommandReply reply = await InvokeBulkInternalAsync(command, cancellationToken).ConfigureAwait(false);
|
||||
return payloadAccessor(reply)?.Results.ToArray() ?? [];
|
||||
}
|
||||
|
||||
// Single round-trip + protocol-status check shared by every bulk variant.
|
||||
// Callers project the typed reply payload out via their own accessor — the
|
||||
// outer envelope handling is identical across SubscribeResult-based bulks,
|
||||
// BulkWriteResult-based writes, and BulkReadResult-based reads.
|
||||
private async Task<MxCommandReply> InvokeBulkInternalAsync(
|
||||
MxCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
WorkerCommandReply workerReply = await InvokeAsync(
|
||||
new WorkerCommand { Command = command },
|
||||
@@ -712,7 +852,7 @@ public sealed class GatewaySession
|
||||
string.IsNullOrWhiteSpace(message) ? "Bulk MXAccess command failed." : message);
|
||||
}
|
||||
|
||||
return payloadAccessor(reply)?.Results.ToArray() ?? [];
|
||||
return reply;
|
||||
}
|
||||
|
||||
private IWorkerClient GetReadyWorkerClient()
|
||||
|
||||
Reference in New Issue
Block a user