Skip to content

fix: batch the import activity log per phase — the O(n²) CPU burner throttling memex-cloud - #1341

Merged
rbuergi merged 1 commit into
mainfrom
fix/import-activity-log-quadratic-writes
Aug 12, 2026
Merged

rbuergi merged 1 commit into
mainfrom
fix/import-activity-log-quadratic-writes

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

The live production CPU burner

memex-cloud sits at 51% of CFS periods throttled. The dominant term is the static-repo
importer writing its own progress log once per item.

NodeTypeCompilationActivity.AppendLog does one stream.Update per message, and
StaticRepoImporter called it per pruned / kept / failed node at four sites. Each stream.Update
runs CreateSingleObjectPatch, which SerializeToNodes the entire node content (O(n)) and diffs
all n elements (O(n))
— on every write. Over n appends that is O(n²) CPU and allocation.

Live attribution, by node path:

path writes content est. serialised
AgenticEngineering/_Activity/import-69f6248b… 5,239 141 kB 719 MB
UWDeepfield/_Activity/import-af545232… 2,581 114 kB 288 MB
MeshWeaver/_Activity/import-1d1a65… 637 204 kB 127 MB

230,674 writes / ~7.1 GB serialised across 25,579 activity nodes; no non-import node in the top
20
. Downstream: ~7.8 cores demanded against a 6-core quota → CFS throttling → turns, timers and
Dequeue all slip together (thread-pool delay #1284, routing pile-up #1172). No deadlock.

The patch output was never the problem. PatchExtensions.DiffArray is element-wise, so an
append walks the equal prefix and emits a single O(1) add op. The cost is on the input side of
the patcher — which is exactly why replacing the patch library in #1292 (1.2× faster diff, 17–19%
fewer allocations) did not move the needle: the dominant cost is re-serialising a growing document.

The fix — batch per phase

  • New NodeTypeCompilationActivity.AppendLogs(...) writes N messages in one Update
    (Messages.AddRange). AppendLog becomes the one-message call into it, and its doc comment now
    states the cost model so the per-item shape does not come back.
  • StaticRepoImporter.Run carries each per-file line along with its per-file result tuple —
    the same trick WrittenPaths already used — and appends the whole upsert phase in one write.
    The prune phase likewise appends its kept-from-prune + pruned lines in one write once the
    deletes have settled.
  • Complete was not the right vehicle on its own. It writes the terminal status, so folding
    the phase lines into it would defer every line past the prune, content-sync and manifest-write
    phases — no live progress at all. The phase-level append sits alongside it.

Nothing leaves the audit trail: every kept and every pruned path is still named on the activity
log (issue #604's requirement), and still lands before the terminal status — the per-path write
queue is serial, so a reader that sees a non-Running status has already seen every line.

Accepted cost (owner's call): per-item live progress during a long import is replaced by
per-phase progress. The satellite-node design that would keep per-item progress alters the
ActivityLog contract, its readers and the GUI, and was deliberately deferred as a separate change.

No band-aids: no quota change, no scheduler hop, no recursion-depth bound, no throttle/debounce on
the writes. The work itself is gone, not moved.

Verification — before / after write counts

New test StaticRepoImportActivityWriteCountTest runs a real import into a fresh partition, then
re-imports the same paths under a two-way policy so every node takes the "kept local change" branch
— a phase that writes no content at all, so the attempt-activity node's revisions are exactly
the import's own logging. It asserts on MeshNode.Version (the node's own "+1 per real
modification" counter), never on log text, so a reworded message cannot satisfy it.

items in the phase activity writes before after
4 8 5
16 20 5

Writes grew 1:1 with the item count; they are now a constant (opening line, root line, one phase
line, terminal summary). Fail-before/pass-after was verified by reverting the src/ diff, rebuilding
and re-running: RED with 4 -> 8; 16 -> 20, GREEN with 4 -> 5; 16 -> 5.

Extrapolating the measured production shape: the 5,239-write / 719 MB activity becomes a handful of
writes, and the ~7.1 GB of serialisation across the 25,579 activity nodes collapses by the same
order.

Test runs (local, this branch)

What's New entry: Category: Fix — users notice imports being far cheaper.

🤖 Generated with Claude Code

…n memex-cloud

`NodeTypeCompilationActivity.AppendLog` does one `stream.Update` per message, and
`StaticRepoImporter` called it PER ITEM at four sites (kept-on-conflict, failed
upsert, kept-from-prune, pruned). Every `stream.Update` runs
`CreateSingleObjectPatch`, which `SerializeToNode`s the ENTIRE node content (O(n))
and diffs all n elements (O(n)) — on every write. Over n appends that is O(n²) CPU
and allocation.

Live attribution on memex-cloud: 5,239 writes / ~719 MB serialised on ONE import
activity (141 kB content); 230,674 writes and ~7.1 GB across 25,579 activity nodes;
no non-import node in the top 20. Downstream: ~7.8 cores demanded against a 6-core
quota, so the process sat at 51% of CFS periods throttled, dragging turns, timers
and Dequeue together (thread-pool delay #1284 → routing pile-up #1172).

The patch OUTPUT was never the problem — `PatchExtensions.DiffArray` is
element-wise, so an append emits a single O(1) `add` op. The cost is on the INPUT
side of the patcher, which is why swapping the patch library (#1292) moved nothing.

Fix: append once per PHASE instead of once per item.
- New `NodeTypeCompilationActivity.AppendLogs(...)` writes N messages in ONE Update
  (`Messages.AddRange`); `AppendLog` is now a one-message call into it, with a doc
  comment spelling out why it must never be called in a loop.
- `StaticRepoImporter.Run` carries each per-file line along with its per-file result
  tuple (as `WrittenPaths` already did) and appends the whole upsert phase in one
  write; the prune phase likewise appends its kept-from-prune + pruned lines in one
  write once the deletes have settled.
- `Complete` was not the right vehicle on its own: it writes the TERMINAL status, so
  folding the phase lines into it would defer all progress past the prune, content
  sync and manifest write. The phase-level append sits alongside it.

Nothing is lost from the audit trail — every kept and pruned path is still named on
the activity log, and still lands before the terminal status. The accepted cost,
stated by the owner, is per-phase rather than per-file live progress.

Measured by the new test (`StaticRepoImportActivityWriteCountTest`), which counts the
attempt activity node's own revision counter (`MeshNode.Version`), never log text:

  items in the phase │ activity writes before │ after
  ─────────────────────────────────────────────────────
   4                 │  8                     │ 5
  16                 │ 20                     │ 5

i.e. writes grew 1:1 with the item count and are now a constant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 12, 2026 20:31

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.

Pull request overview

This PR reduces CPU and allocation pressure during large static-repo imports by batching activity-log writes per phase, avoiding the previous “one stream.Update per item” pattern that caused O(n²) re-serialization/diff work on the growing activity node content.

Changes:

  • Add NodeTypeCompilationActivity.AppendLogs(...) to append many log messages in a single stream.Update, and route AppendLog through it.
  • Refactor StaticRepoImporter to accumulate per-item log lines during upsert/prune and emit them once per phase via AppendLogs.
  • Add an integration-style regression test that asserts the import activity’s write count stays constant as the number of “kept local change” items increases, using MeshNode.Version as the write counter.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

File Description
test/MeshWeaver.Graph.Test/StaticRepoImportActivityWriteCountTest.cs Adds a regression guard ensuring activity-log writes don’t scale with per-item conflict logging.
src/MeshWeaver.Graph/StaticRepoImporter.cs Carries per-item log lines along with per-item results and writes them once per phase to avoid O(n²) activity-node patch input cost.
src/MeshWeaver.Graph/Configuration/NodeTypeCompilationActivity.cs Introduces AppendLogs (batched append) and routes single-message appends through it.
src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-12-imports-no-longer-burn-cpu-on-their-own-progress-log.md Adds a “What’s New” entry describing the user-visible impact and tradeoff (phase-level progress).

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

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

722 tests  ±0   722 ✅ ±0   14m 30s ⏱️ -17s
 10 suites ±0     0 💤 ±0 
 10 files   ±0     0 ❌ ±0 

Results for commit 2a305c3. ± Comparison against base commit 82eaff4.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

1 899 tests  ±0   1 707 ✅ ±0   8m 9s ⏱️ +4s
   11 suites ±0     192 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit 2a305c3. ± Comparison against base commit 82eaff4.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

   11 files  ±0     11 suites  ±0   8m 10s ⏱️ -5s
1 814 tests ±0  1 711 ✅ ±0  103 💤 ±0  0 ❌ ±0 
2 078 runs  +1  1 975 ✅ +1  103 💤 ±0  0 ❌ ±0 

Results for commit 2a305c3. ± Comparison against base commit 82eaff4.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

1 353 tests  ±0   1 353 ✅ ±0   6m 56s ⏱️ +3s
   11 suites ±0       0 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit 2a305c3. ± Comparison against base commit 82eaff4.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

1 851 tests  ±0   1 841 ✅ ±0   13m 27s ⏱️ +20s
   11 suites ±0      10 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit 2a305c3. ± Comparison against base commit 82eaff4.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

2 414 tests  +1   2 413 ✅ +1   8m 42s ⏱️ -1s
   12 suites ±0       1 💤 ±0 
   12 files   ±0       0 ❌ ±0 

Results for commit 2a305c3. ± Comparison against base commit 82eaff4.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results

    66 files  ±0      66 suites  ±0   59m 56s ⏱️ +4s
10 053 tests +1   9 747 ✅ +1  306 💤 ±0  0 ❌ ±0 
10 317 runs  +2  10 011 ✅ +2  306 💤 ±0  0 ❌ ±0 

Results for commit 2a305c3. ± Comparison against base commit 82eaff4.

@rbuergi

rbuergi commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Correction: "the patch output was never the problem" holds for the own-stream path only

Recording this here because it is the sentence a future reader will rely on, and it is true for
exactly half the write paths — the half that is not the common case for these writes.

Surfaced while root-causing #1351 (OutOfMemoryException serializing a MessageDelivery<RawJson>
onto an Orleans memory stream).

The two patch paths differ

Own-stream — RFC 6902 JSON Patch. As this PR says: PatchExtensions.DiffArray is element-wise,
so an append walks the equal prefix and emits a single O(1) add op. The cost is entirely on the
input side (re-serialising a growing document), which is what batching fixed. ✅

Cross-hub — RFC 7396 JSON merge patch. MeshNodeStreamExtensions.ComputeMergePatchDiff
recurses into JSON objects only; any changed array is emitted whole
(patch[key] = updatedValue?.DeepClone()). MeshNodePatchMerge.ExtractBaseValues:303 then mirrors
that shape and clones the previous array whole into BaseValues.

So a single append to an N-element collection in MeshNode.Content produces a PatchDataRequest
carrying ~2N elements. Here the patch output very much is the problem: message size is
O(collection size)
, and it is unbounded because ActivityLog.Messages has no cap — unlike every
other log-shaped collection in node content (LogIncident.Samples → MaxSamples,
CompletionMemory.Entries → MaxEntries = 300).

This matters because cross-hub is the common case for activity writes: the writer is rarely the
activity node's own hub.

A closed door, so nobody re-opens it

BaseValues cannot simply be dropped for arrays. It is genuinely consumed —
MeshNodePatchMerge.MergeArray(liveArr, patchArr, baseArr) performs a real three-way array merge by
element identity, and the base arm is what distinguishes "the writer added this" from "the writer
never had it". Dropping it would silently regress cross-hub array merges into last-write-wins. The
obvious halving is not available.

Where this goes

Not a regression in this PR — batching was the right fix for the CPU term and it holds. The message-size
term is a separate axis that batching cannot reach (fewer writes, but each still O(N)), and it is
what actually OOMs a serializer. The cure is the satellite migration for growing in-content
collections, which is now scoped and in progress for activities first. Cross-referencing #1359,
#1351 and #1200.

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.

2 participants