Skip to content

fix(#3233): a content file over the per-delivery budget travels out of band - #3286

Merged
meshweaver-cloud[bot] merged 4 commits into
mainfrom
fix/3233-out-of-band-content-transfer
Sep 4, 2026
Merged

meshweaver-cloud[bot] merged 4 commits into
mainfrom
fix/3233-out-of-band-content-transfer

Conversation

@rbuergi

@rbuergi rbuergi commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Closes #3233

The residual, re-measured

A GitSync content sync carries a Space's content/** binaries inline. #3097/#2885 partitions the
write so a delivery is ≤ budget + largest single file — which fixed every Space whose aggregate
was the problem. A file is the atom the receiving handler writes and is never split, so a file
whose packaged (base64) cost alone exceeds ContentDeliveryBudget.BudgetBytes (= Orleans'
MemoryStreamBlockBytes, 1,048,576, hard-coded with no configuration surface) travelled whole and
was refused wherever the Orleans transport was in the path.

Re-ran the issue's own cost function on Systemorph/MeshWeaver.Education@f7ae723 (2026-09-04) —
unchanged, 25 files over budget across all 7 Spaces:

Space files over budget total largest packaged
AgenticEngineering 25 12 101.2 MB 13,188,871
AgenticBusiness 9 4 27.2 MB 10,910,243
AgenticPrimerDe 7 3 10.9 MB 4,291,888
AgenticPrimer 7 3 9.8 MB 3,873,652
DataModeling 3 1 8.6 MB 10,929,144
AdvancedBusinessRules 2 1 9.5 MB 12,540,448
AgenticOffice 3 1 8.2 MB 10,224,527

The axis is "has a video", not "is large" — AdvancedBusinessRules is one of the smallest Spaces
and carries the second-largest single file.

The design

Out-of-band asset transfer. A file over the budget no longer rides the message: its bytes are
written into the destination collection's reserved _staging/ folder before the request is
posted, and the delivery carries a content-addressed StagedContentFile(Path, Handle, Length)
handle. The receiver streams the blob into the destination path exactly where the inline file would
have gone.

Where the bytes land — the destination collection itself. The producer reaches it the way
MeshOperations.Upload and the /api/content route already do: it resolves the collection's
config from the owning node's hub (a few hundred bytes) and writes through it locally. The
content store is already required to be reachable from a hub other than the collection's owner —
that is what the content route does — so this adds no assumption, and needs no well-known
collection name, no deployment key and nothing new to provision
. A mesh-level staging store would
have needed all three for the same physical bytes on the same share.

Lifetime. The producer owns every blob it stages and deletes them in the post chain, after
the last delivery is answered (which is exactly when nothing can still name a handle) and before the
caller's answer — so "this sync answered" means "its staging area is clean", not a race a caller has
to poll. Only a crashed producer leaves residue; that is reclaimed by an age sweep (24 h) that runs
before staging and therefore cannot see a live transfer. The mirror never prunes _staging/
(excluded by name — it is framework state) and never prunes a staged file (its path is in the full
MirrorKeepPaths keep set, now mandatory whenever anything is staged, not only when the write is
split). Without both, the prune — which rides the first delivery — would delete the blobs the
following deliveries still have to read.

Idempotence. The handle is the SHA-256 of the bytes. Two identical files stage once; a re-run
addresses the same blob at the same key and the same file at the same destination path, so nothing is
duplicated, and a blob already present with the right length is not rewritten.

Failure behaviour stays honest — #3101 is not undone

  • A handle the receiver cannot resolve, or whose length does not match, fails by name with the
    handle and the path. Never "zero files"; never a truncated or empty asset.
  • When staging cannot run (a store the producer cannot reach), the file falls back to inline —
    today's behaviour, including today's refusal — and the failure names both halves: the A Space whose content sync is refused stops syncing SILENTLY — AgenticEngineering's assets are over the frame limit outright #3101
    budget measurement and why the out-of-band road was unavailable.
  • ContentDeliveryBudget.DescribeOverBudget is now measured on the files that actually travelled
    inline
    , so a file that went out of band is not reported as an over-budget payload nobody built.
  • StaticRepoImporter is untouched: a failed transfer still lands Warning with its reason on the
    Space's _Activity/content-sync ledger, and the fingerprint still does not stamp Succeeded.

What did not change

The budget is untouched, the inline partitioning of #2885 is untouched, a file is still never split
(no chunking, no reassembly, no partial-write story), and authorization is unchanged — the content
write still happens on the owning node's hub under [SyncContentFilesPermission], and nothing lands
in the collection before that check runs. A sync with no over-budget file produces a request
byte-for-byte identical to before (no config round-trip, no staging pass, StagedFiles null).

Tests — and what they do without the implementation

test/MeshWeaver.ContentCollections.Test/OversizedContentTravelsOutOfBandTest.cs, driven through the
real SyncContentFilesRequest handler over a real file-system collection (no mocking, no
Task.Delay):

  1. A_file_over_the_budget_travels_out_of_band_and_arrives_intact — the file arrives byte-for-byte
    (SHA-256 compared), its bytes are on no delivery, exactly one staged handle equals the content
    hash, every delivery fits the budget outright (no "plus one file" allowance), staging is clean.
  2. A_rerun_duplicates_nothing_and_leaves_no_residue — two runs, one folder, both runs address the
    same handle, no residue.
  3. The_mirror_keeps_the_out_of_band_file_prunes_the_stale_and_preserves_an_upload — the transfer
    survives the mirror in the same operation, the retired source file is pruned, the user upload
    (Silent loss of uploaded collection files after hub teardown; mesh search returns zero results on deployed portal #435) survives, one prune pass, the staged path is in MirrorKeepPaths.
  4. Staging_that_cannot_run_falls_back_inline_and_the_failure_names_both_halves.
  5. A_handle_nothing_staged_fails_by_name_and_writes_nothing.

Revert experiment (disable the staging decision, rebuild src/ and the test project):
3 of 5 fail, with exactly the pre-fix evidence —
Did not expect collection {…, "videos/module1-intro.mp4", …} to contain "videos/module1-intro.mp4",
Expected collection to have 2 item(s) … but found 0, and the #3101 sentence with the #3233 half
missing. Restored: 27/27 in that project, 9/9 on the Graph content-sync tests, 250/250 on
the Documentation guard suite, DocumentationLinkIntegrityTest green.

Built -c Release -warnaserror, one project per invocation, 0 Error(s) / 0 Warning(s):
MeshWeaver.Mesh.Contract, MeshWeaver.ContentCollections, MeshWeaver.Documentation,
MeshWeaver.Graph, MeshWeaver.PluginCatalog, MeshWeaver.Mesh.Operations,
MeshWeaver.Hosting.AspNetCore, MeshWeaver.Connection.Orleans, and both test projects.

Docs

  • New: Doc/Architecture/OutOfBandContentTransfer — the design, the lifetime rules, the
    idempotence argument, and the failure contract.
  • Doc/Architecture/OversizedDeliveryRefusal — its own "a single file larger than the budget travels
    whole" residual is now closed and points here.
  • Doc/Architecture/ContentSyncVisibility — records that its observability contract is what the new
    transfer's fallback rules preserve.
  • What's New (Category: Fix): Course videos sync again.

No public type or member is removed — additions only — so no Pairs-with: counterpart is needed.

🤖 Generated with Claude Code

…f band

#2885 partitions a content sync so a delivery is `<= budget + largest single
file`, which fixed every Space whose AGGREGATE was the problem. A file is the
atom the receiving handler writes and is never split, so a file whose packaged
(base64) cost ALONE exceeds the budget travelled whole and was refused wherever
the Orleans transport was in the path. Re-measured 2026-09-04 on
MeshWeaver.Education@f7ae723: still 25 files across all 7 Spaces, unchanged.
The budget is Orleans' memory-stream block size, hard-coded with no
configuration surface, and raising it is the move OversizedDeliveryRefusal
exists to forbid.

Such a file now goes AROUND the message. Its bytes are written into the
destination collection's reserved `_staging/` folder before the request is
posted, and the delivery carries a content-addressed StagedContentFile handle;
the receiver streams the blob into its destination path.

Where the bytes land: the destination collection itself, reached by resolving
its CONFIG from the owning node's hub (a few hundred bytes) — the mechanism
MeshOperations.Upload and the /api/content route already use. The content store
is already required to be reachable from a hub other than the collection's
owner, so this adds no assumption, no well-known name, no deployment key and
nothing to provision.

Lifetime: the producer owns every blob it stages and deletes them IN the post
chain, so the answer means "the staging area is clean"; only a crashed producer
leaves residue, reclaimed by an age sweep (24 h) that runs before staging and
therefore cannot see a live transfer. The mirror never prunes `_staging/`
(excluded by name) and never prunes a staged file (its path is in the full
MirrorKeepPaths keep set) — without both, the prune, which rides the FIRST
delivery, would delete the blobs the following deliveries still have to read.

Idempotence: the handle is the SHA-256 of the bytes. Two identical files stage
once; a re-run addresses the same blob and the same destination path, so
nothing is duplicated and an already-present blob is not rewritten.

Failure stays honest (#3101 is not undone):
- a handle the receiver cannot resolve, or whose length does not match, FAILS by
  name and writes nothing — never "zero files", never a truncated asset;
- when staging cannot run, the file falls back to inline (a monolith carries it
  fine) and the refusal names BOTH halves: the #3101 budget measurement over the
  files that actually travelled inline, plus why the out-of-band road was
  unavailable;
- DescribeOverBudget is now measured on the INLINE remainder, so a file that went
  out of band is not reported as an over-budget payload nobody built.

Tests (MeshWeaver.ContentCollections.Test, real handler + real FS collection):
the over-budget file arrives byte-for-byte and is on NO delivery; every delivery
fits the budget outright; a re-run duplicates nothing and both runs address one
handle; the mirror keeps it, prunes the stale, preserves a user upload; staging
that cannot run reports both halves; an unresolvable handle fails by name and
writes no empty file. Reverting the staging decision fails 3 of the 5.

Design committed as Doc/Architecture/OutOfBandContentTransfer;
OversizedDeliveryRefusal's residual and ContentSyncVisibility updated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 4, 2026 07:13
@meshweaver-cloud
meshweaver-cloud Bot enabled auto-merge September 4, 2026 07:14
…owed

The reclaim's per-blob Catch continued past a failed delete — correct, so one
stubborn blob cannot strand the rest or fail a sync that succeeded — but it
swallowed the exception with nothing written down, which is exactly the
silent-swallow shape AGENTS.md forbids. What it leaves behind is a blob on the
content share that only the age sweep will now collect, so it says so.

Both reclaim paths now take the builder's logger: Discard warns per blob it
could not delete, and SweepStale logs what it reclaimed (Information — crash
residue is rare and worth a line), warns on a blob it could not, and reports at
Debug when the staging folder cannot be enumerated at all. The outer catch in
Reclaiming stays as the defensive arm it is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Two correctness/operability issues remain in the new staged-write and reclaim paths (unknown-size GetContentSize == -1 handling, and silent reclaim failures) that should be addressed before approval.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR fixes content sync failures for single binary assets whose base64-packaged size exceeds the per-delivery payload budget by transferring those oversized files out-of-band through the destination content store’s reserved staging folder (_staging/), while keeping existing delivery chunking/mirroring semantics for the remaining inline files.

Changes:

  • Add a content-addressed staged-file handle (StagedContentFile) and plumb it through SyncContentFilesRequest as StagedFiles.
  • Implement producer-side staging + receiver-side streamed write for oversized files, and ensure mirror pruning keeps staged destinations and never touches _staging/.
  • Add end-to-end tests plus architecture/docs/What’s New updates describing the design, lifecycle, and failure contract.
File summaries
File Description
test/MeshWeaver.ContentCollections.Test/OversizedContentTravelsOutOfBandTest.cs New integration tests covering out-of-band transfer, idempotence, mirror/prune behavior, and failure reporting.
src/MeshWeaver.Mesh.Contract/MeshExtensions.cs Registers StagedContentFile in the type registry for correct typed deserialization across hubs.
src/MeshWeaver.Mesh.Contract/ImportDeleteRequests.cs Introduces StagedContentFile and adds StagedFiles to SyncContentFilesRequest.
src/MeshWeaver.ContentCollections/ContentStaging.cs Adds staging folder constants and producer-side staging/sweep/reclaim helpers.
src/MeshWeaver.ContentCollections/ContentImportExtensions.cs Adds receiver-side staged-handle resolution + write path, updates mirroring keep-set and prune exclusions, and stages oversized files before posting deliveries.
src/MeshWeaver.ContentCollections/ContentDeliveryBudget.cs Updates budget documentation and clarifies that over-budget measurement applies only to files that actually traveled inline.
src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-04-course-videos-sync-again.md What’s New entry describing the user-visible fix.
src/MeshWeaver.Documentation/Data/Architecture/OversizedDeliveryRefusal.md Updates prior “residual” section to point to out-of-band transfer as the closure.
src/MeshWeaver.Documentation/Data/Architecture/OutOfBandContentTransfer.md New architecture page documenting the design, lifecycle, idempotence, and failure behavior.
src/MeshWeaver.Documentation/Data/Architecture/ContentSyncVisibility.md Updates visibility doc to reflect the new transfer path and preserved honesty contract.
src/MeshWeaver.Documentation/Data/Architecture.md Adds the new architecture page to the Reliability & wedges index.
Review details
  • Files reviewed: 11/11 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +303 to +319
return target.GetContentSize(blob)
.SelectMany(size => size switch
{
null => Observable.Throw<int>(new InvalidOperationException(
$"Staged content '{staged.Handle}' for '{staged.Path}' is not in collection "
+ $"'{target.Collection}' staging area ('{blob}') — the out-of-band transfer "
+ "did not complete, so the file was NOT written.")),
>= 0 when size != staged.Length => Observable.Throw<int>(new InvalidOperationException(
$"Staged content '{staged.Handle}' for '{staged.Path}' is {size:N0} bytes but "
+ $"the handle declares {staged.Length:N0} — refusing to write a truncated asset.")),
_ => target.GetContent(blob)
.SelectMany(stream => stream is null
? Observable.Throw<int>(new InvalidOperationException(
$"Staged content '{staged.Handle}' for '{staged.Path}' disappeared from "
+ $"'{blob}' between the probe and the read, so the file was NOT written."))
: target.SaveFile(dir, name, () => stream).Select(_ => 1))
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and the contract was the part that was wrong rather than the branch. Refusing on -1 would reject content that is almost certainly intact on every non-seekable store, for ever — so the write still proceeds, but it is no longer silent: CopyStaged logs a warning naming the handle, the path and the declared length when a write goes out unverified, and both the StagedContentFile.Length xmldoc and Doc/Architecture/OutOfBandContentTransfer now state the limit instead of implying a guarantee. Fixed in f946f07. (Every file-system-backed collection reports a size, which is every store a content sync writes to today — the two failing shapes, a missing blob and a genuine mismatch, still throw by name.)

Comment on lines +190 to +203
internal static IObservable<Unit> Discard(ContentCollection destination, IEnumerable<string> handles)
{
var blobs = handles.Distinct(StringComparer.OrdinalIgnoreCase)
.Select(h => ContentStaging.BlobPath(h))
.ToArray();
return blobs.Length == 0
? Observable.Return(Unit.Default)
: blobs
.Select(blob => destination.DeleteFile(blob)
.Catch<Unit, Exception>(_ => Observable.Return(Unit.Default)))
.Concat()
.DefaultIfEmpty(Unit.Default)
.LastAsync();
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — that was a silent swallow, and it was fixed in 49e8f7d (pushed before this review landed). Discard and SweepStale both take the builder's logger now: a blob that will not delete is logged as a warning naming the blob and the collection, a reclaimed stale blob logs at Information, and a staging folder that cannot be enumerated logs at Debug. Continuing past a failed delete is still deliberate — one stubborn blob must not strand the rest or turn a successful sync into a failure — but what it leaves behind is now visible rather than inferred.

@github-actions

github-actions Bot commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

474 tests  ±0   474 ✅ ±0   59s ⏱️ +3s
  1 suites ±0     0 💤 ±0 
  1 files   ±0     0 ❌ ±0 

Results for commit af25acd. ± Comparison against base commit bc9b516.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

    4 files  ±0      4 suites  ±0   1m 35s ⏱️ -16s
1 447 tests ±0  1 447 ✅ ±0  0 💤 ±0  0 ❌ ±0 
1 448 runs  ±0  1 448 ✅ ±0  0 💤 ±0  0 ❌ ±0 

Results for commit af25acd. ± Comparison against base commit bc9b516.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

368 tests  ±0   366 ✅ ±0   48s ⏱️ -2s
  3 suites ±0     2 💤 ±0 
  3 files   ±0     0 ❌ ±0 

Results for commit af25acd. ± Comparison against base commit bc9b516.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

251 tests  ±0   251 ✅ ±0   1m 39s ⏱️ -35s
  1 suites ±0     0 💤 ±0 
  1 files   ±0     0 ❌ ±0 

Results for commit af25acd. ± Comparison against base commit bc9b516.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

2 267 tests  ±0   2 267 ✅ ±0   2m 32s ⏱️ +7s
    3 suites ±0       0 💤 ±0 
    3 files   ±0       0 ❌ ±0 

Results for commit af25acd. ± Comparison against base commit bc9b516.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

586 tests  +5   394 ✅ +5   2m 37s ⏱️ ±0s
  3 suites ±0   192 💤 ±0 
  3 files   ±0     0 ❌ ±0 

Results for commit af25acd. ± Comparison against base commit bc9b516.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

   15 files  ±0     15 suites  ±0   10m 13s ⏱️ -43s
5 393 tests +5  5 199 ✅ +5  194 💤 ±0  0 ❌ ±0 
5 394 runs  +5  5 200 ✅ +5  194 💤 ±0  0 ❌ ±0 

Results for commit af25acd. ± Comparison against base commit bc9b516.

♻️ This comment has been updated with latest results.

The receiver's contract claimed StagedContentFile.Length is "VERIFIED before
the file is written", and for the two failing shapes it is: a missing blob and
a size mismatch each throw by name. But GetContentSize answers -1 for a store
that HAS the blob and cannot report its size, and that fell through to the
write arm — so the guarantee quietly did not hold on such a store, and nothing
said which case a given write was.

Refusing on -1 would reject content that is almost certainly intact, on every
non-seekable store, for ever — so the write still proceeds. What changes is
that it is no longer silent: the copy is split out and logs a warning naming
the handle, the path and the declared length when it goes out unverified. The
record's xmldoc and Doc/Architecture/OutOfBandContentTransfer now state the
limit instead of implying a guarantee. Every file-system-backed collection
reports a size, which is every store a content sync writes to today.

Found by the automatic Copilot review on #3286; its second finding (Discard
swallowing per-blob delete failures) was already addressed in 49e8f7d.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rbuergi

rbuergi commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Status: this change is green; the merge is blocked on fleet state it cannot reach

Everything this PR is responsible for passes. On f946f073a: all six test shards, Build solution (once), Doc content compiles and runs, Cross-repo pair (public surface), Public surface (binary compatibility), Dependency licences, Clients workflow verdict, Chart invariants, Operator image builds — SUCCESS.

The two reds are one red. AGENTS.md shared rule blocks fails because four satellites' AGENTS.md on their default branches already open a rest-not-graphql shared-rule block that core's .github/shared-rules.json does not declare:

[rest-not-graphql] Systemorph/MeshWeaver.Education:   … the manifest does not declare it
[rest-not-graphql] Systemorph/MeshWeaver.SocialMedia: … the manifest does not declare it
[rest-not-graphql] Systemorph/MeshWeaver.Manufacturing: … the manifest does not declare it
[rest-not-graphql] Systemorph/Memex:                  … the manifest does not declare it

Consolidate test results (the one required check) then fails on its Fail if the shared rule blocks drifted step alone — nothing to do with tests.

That is the cross-repo halves shape: the satellite halves merged, and core's half is #3287 (docs/rest-not-graphql), which is still a draft and is itself red on the same gate. Until #3287 lands, this gate reds every core PR — #3284 and #3285 are in the same position. The gate says so itself: "The fix is always a pull request in the repo the error names — never a bypass here."

Not fixing it here, deliberately. Declaring rest-not-graphql in core's manifest is #3287's change set; doing it in this PR would collide with theirs and take a fleet-policy scope call that belongs to that change, not to a content-sync fix.

What this PR needs to merge, once #3287 is on main: merge main in (so this checkout's manifest carries the declaration — this is the sanctioned "CI fails on something your diff cannot reach" case), push, and let the re-run go green. Auto-merge is already armed.

On the earlier red (4619377ee/49e8f7dea)

MeshWeaver.Graph.Test.NackReachesTheWaiterDuringTeardownTest.OwnerDisposingUnderMeshTeardown_StillAnswersTheWaitingCaller failed on shard 4. Attributed rather than dismissed:

  • Mechanically unreachable from this diff — that test configures no content collection and posts no SyncContentFilesRequest, so nothing in ContentStaging/SyncContentFilesBuilder/SyncFiles can execute in it.
  • The signature is the test's own design: it parks the owner's merge executor under a 60 s SpinWait and then disposes the mesh; the log shows DISPOSAL DEADLOCK DETECTED … Executing(UpdateStreamRequest, 8094ms) followed by an 8 s forced teardown, which consumed the assertion's 10 s budget.
  • Base rate: of the last 30 MeshWeaver Build and Test runs, that PR's was the only failure; the test was ported two days ago (51c158b) and is not in .github/known-flakes.json.
  • Measured, not re-run to dismiss: it passes locally on this branch (8 s), and shard 4 is green on f946f073a without any change to it.

@meshweaver-cloud
meshweaver-cloud Bot added this pull request to the merge queue Sep 4, 2026
Merged via the queue into main with commit 6163dfa Sep 4, 2026
27 checks passed
@rbuergi
rbuergi deleted the fix/3233-out-of-band-content-transfer branch September 13, 2026 10:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Content assets over the per-delivery budget need out-of-band transfer — 25 files across every Education Space still cannot sync

2 participants