perf(runtime): apply path parses the artifact once per actor
Both apply handlers (DriverHostActor.ApplyAndAck + HandleFetchedForApply)
and OpcUaPublishActor.HandleRebuild used to rebuild a JsonDocument over the
same artifact bytes for every question they asked of it — driver instances,
composition, cluster scope, data-plane authority. Each now opens ONE
ParsedDeploymentArtifact per apply and threads it through:
- ReconcileDriversForApply / ReconcileDriversFromParsed return the blob
plus the parse the rest of the apply reads; the handler owns it and
disposes it in its finally.
- PushDesiredSubscriptionsFromParsed and CacheAppliedArtifact take that
parse instead of re-parsing the bytes.
- HandleRebuild opens once after the #485 guard and feeds both the
data-plane authority and the composition from it.
The blob-based variants stay as the lenient wrappers the boot paths need
(BootstrapFromCache, RestoreApplied), so the three remaining static Parse*
call sites are boot-only — none on an apply path.
The RebuildAddressSpace message still carries raw BYTES, deliberately: the
publish actor is a different actor with its own mailbox and
ParsedDeploymentArtifact is a disposable, non-thread-safe object owned by
the sending handler. It opens its own single parse on the far side.
FeedDataPlaneAuthority still SHA-256s the RAW bytes for the revision id — a
re-serialisation of the parsed document would be a different byte sequence
and would not match the deployment's RevisionHash.
Behaviour note: Open() throws JsonException on bytes the static wrappers
swallowed to an empty result. That is routed into each path's EXISTING
failure handling — ApplyAndAck's catch (Failed ack, children left running)
and HandleRebuild's catch (Error log, last-known-good still served) — not
as a new outcome. An empty composition is not a benign no-op there: the
planner diffs it as a PureRemove and tears the address space down, so the
throw is the honest reading of unusable bytes.
This commit is contained in:
@@ -462,18 +462,30 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse the artifact ONCE for this rebuild. Every consumer below (data-plane authority, then the
|
||||
// composition) reads this one JsonDocument instead of rebuilding it per question — the rebuild
|
||||
// used to pay a full parse-and-throw-away per static call. Disposed when the handler returns.
|
||||
//
|
||||
// Open() throws on bytes that are not valid JSON, where the static wrappers swallowed to an
|
||||
// EMPTY result — and an empty composition is not a benign no-op here: the planner diffs it as a
|
||||
// PureRemove and the applier tears the served address space down. So the throw is the honest
|
||||
// outcome, and it lands in this method's catch below: logged at Error, _lastApplied untouched,
|
||||
// last-known-good still served. Same "no answer ≠ empty configuration" rule as the #485 guard
|
||||
// immediately above, applied to bytes that arrived unreadable rather than absent.
|
||||
using var parsed = DeploymentArtifact.Open(artifact);
|
||||
|
||||
// #520 stage 4 — install this artifact's API keys + ACL grants BEFORE the address-space work
|
||||
// below, and deliberately ahead of the `plan.IsEmpty` early-return: an artifact that revokes a
|
||||
// key or edits a grant without touching a single node produces an EMPTY address-space plan, so
|
||||
// feeding this after that return would leave a revoked key authenticating until some unrelated
|
||||
// structural change happened to redeploy. Authorization and topology change on their own
|
||||
// schedules.
|
||||
FeedDataPlaneAuthority(artifact);
|
||||
FeedDataPlaneAuthority(parsed, artifact);
|
||||
|
||||
var composition = _localNode is { } ln
|
||||
? DeploymentArtifact.ParseComposition(artifact, ln.Value,
|
||||
? parsed.ParseComposition(ln.Value,
|
||||
inconsistency => _log.Warning("OpcUaPublish {Node}: cross-cluster binding — {Message}", ln, inconsistency))
|
||||
: DeploymentArtifact.ParseComposition(artifact);
|
||||
: parsed.ParseComposition();
|
||||
var plan = AddressSpacePlanner.Compute(_lastApplied, composition);
|
||||
|
||||
if (plan.IsEmpty)
|
||||
@@ -560,17 +572,22 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
/// the safe direction for availability but means a revocation carried by an artifact this node
|
||||
/// could not decode has not taken effect. It is logged at Error for exactly that reason.
|
||||
/// </remarks>
|
||||
private void FeedDataPlaneAuthority(byte[] artifact)
|
||||
/// <param name="parsed">The rebuild's single parse of the artifact, read for the keys + grants.</param>
|
||||
/// <param name="artifactBytes">The RAW artifact bytes — hashed for the revision id, which must be
|
||||
/// SHA-256 of the exact blob (see below); never re-parsed here.</param>
|
||||
private void FeedDataPlaneAuthority(ParsedDeploymentArtifact parsed, byte[] artifactBytes)
|
||||
{
|
||||
if (_dataPlaneAuthority is null) return;
|
||||
|
||||
try
|
||||
{
|
||||
var nodeId = _localNode?.Value ?? string.Empty;
|
||||
var projection = DeploymentArtifact.ParseDataPlaneAuthority(artifact, nodeId);
|
||||
var projection = parsed.ParseDataPlaneAuthority(nodeId);
|
||||
// The revision hash is SHA-256 of the artifact bytes — the same derivation ConfigComposer uses,
|
||||
// which is what lets a node recognise a re-applied revision without the hash riding the wire.
|
||||
var revision = Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(artifact));
|
||||
// Hashed off the RAW bytes for exactly that reason: a re-serialisation of the parsed document
|
||||
// would be a different byte sequence and would not match the deployment's RevisionHash.
|
||||
var revision = Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(artifactBytes));
|
||||
var snapshot = _dataPlaneAuthority.Apply(
|
||||
projection.ClusterId, revision, projection.Keys, projection.Grants, projection.ScopePaths);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user