Enumerate the subtree: batched node listing, snapshot walks, one progress vocabulary #12065 - #12281
Conversation
NodeService.list answered with the whole listing at once, however large: the scroll behind it accumulates every entry before anything is returned, so listing a tree of millions of nodes holds millions of entries in memory at both levels. A caller may now set a batch size and repeat the call with the cursor each batch answers with. The cursor names the position after the last entry scanned, compared the way the index compares paths, so a continuation is one sized query rather than a scroll, entries hidden by permissions or depth still advance it, and ground already passed is never revisited - deletions between batches included, which is what a clean-up loop needs. The cursor is taken from the scan rather than from the entries kept, so a batch may be empty while the listing is not finished; the contract is therefore to continue until a batch answers with no cursor, and the javadoc says so.
CleanUpAuditLogCommand resolved every batch through the search index and had to refresh it after each one, since the next search would otherwise answer with the nodes just deleted. Enumerate the log in batches instead: the cursor only moves forward over ground the deletions leave behind, so one storage refresh up front is the only refresh the whole clean-up needs, and the search index is not consulted at all. A record is now aged by its node timestamp - the moment it was written - where the search filtered on the time field of the record, which a caller may set freely. The two coincide for every record the system writes, and the timestamp is the only moment storage holds.
Codacy's Analysis Summary0 new issue (≤ 0 issue)
|
There was a problem hiding this comment.
Pull request overview
This PR is a follow-up to #12247 that (1) adds cursor-based batching to NodeService.list(...) to avoid building huge in-memory listings, and (2) rewrites the audit log vacuum to enumerate records via batched listing rather than repeated search+refresh cycles.
Changes:
- Add batched listing support (
batchSize+cursor) toNodeService.list, including API docs and integration tests covering empty batches, mixed-case cursors, and delete-while-scanning. - Update node-branch listing internals to support cursor continuation via an exclusive range bound (
gt/lt) on the path field. - Rework audit log cleanup to scan entries in batches from storage and delete those older than the threshold, updating unit tests accordingly.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| modules/itest/itest-core/src/test/java/com/enonic/xp/core/node/NodeServiceImplTest_list.java | Adds integration coverage for cursor batching semantics (empty batches, mixed-case cursor continuation, deletion during scan, permission-filter advancement). |
| modules/core/core-repo/src/main/java/com/enonic/xp/repo/impl/node/NodeServiceImpl.java | Switches list(...) implementation to use the new batched execution path and propagate cursor into ListNodesResult. |
| modules/core/core-repo/src/main/java/com/enonic/xp/repo/impl/node/FindNodeBranchEntriesByParentCommand.java | Implements executeBatch() with size limiting and cursor continuation via a path range filter; keeps execute() for unbatched callers. |
| modules/core/core-audit/src/test/java/com/enonic/xp/core/impl/audit/AuditLogServiceImplTest.java | Updates audit cleanup tests to mock nodeService.list(...) batches instead of findByQuery(...), and adds a “keeps newer than threshold” case. |
| modules/core/core-audit/src/main/java/com/enonic/xp/core/impl/audit/CleanUpAuditLogCommand.java | Replaces the find-and-refresh loop with batched storage enumeration + timestamp check, preserving listener contract. |
| modules/core/core-api/src/main/java/com/enonic/xp/node/NodeService.java | Documents the new batching contract for list(...) (cursor-based continuation until cursor is null). |
| modules/core/core-api/src/main/java/com/enonic/xp/node/ListNodesResult.java | Adds nullable cursor to represent continuation for batched listings and updates result semantics/docs for empty batches. |
| modules/core/core-api/src/main/java/com/enonic/xp/node/ListNodesParams.java | Adds batchSize + cursor parameters and builder-time validation for invalid combinations. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The javadoc promised a positive number while the builder accepts zero as the default that turns batching off, so say so. Also take the last scanned entry through Iterables.getLast instead of a reduction over the whole batch.
The two build-time refusals and the defaults of ListNodesParams were asserted nowhere.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #12281 +/- ##
============================================
+ Coverage 87.01% 87.10% +0.08%
- Complexity 20754 20842 +88
============================================
Files 2593 2610 +17
Lines 69055 69349 +294
Branches 5723 5749 +26
============================================
+ Hits 60091 60406 +315
+ Misses 6290 6270 -20
+ Partials 2674 2673 -1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Both walkers held the whole listing at once and used nothing of it but the ids: an entry weighs a whole path, and in a deep tree every descendant repeats its ancestry, so the paths dominated the memory of walks that never read them. Consume the listing in batches and keep bare ids - the set a dump needs anyway to dump each node once across branches, and the list an export needs for the exact total its progress listener is owed, which a count from the index could not give since it ignores what the caller is permitted to read. The batches replace a scroll, which pinned a point-in-time view of the listing. A node moved across the cursor while a walk runs may now be listed twice, which the ids absorb, or escape the listing - the trade the batches make for never holding an entry per node.
A path names a position that a move takes away, so a path-ordered scan let a node moved across the cursor be listed twice or escape the listing altogether. An id is the one thing about a node that never changes: scanned by id, a node moved within the listing keeps its place and is observed exactly once, and the cursor is compared without normalization, since ids are held by the index exactly as the node exposes them. Order was the only thing path gave a batched scan, and none of the batched walkers reads the listing in order - the vacuum filters by age, the dump feeds a set, the export reads by id. An unbatched listing keeps its path order, which the application and job listings show to people. The batched permission itest gave the hidden node an id chosen by chance, which chose its scan position too; it is now set explicitly.
The batching loops narrate themselves; what stays is what a reader cannot recover from the code - why one refresh suffices, why the count comes from the entries, why the cursor is the last scanned id, and what bounds the batch size.
Both walkers enumerated full branch entries and read two fields of them: the id names the node to rewrite or copy, the path names its parent in the map that keeps the walk parent-first. The version id, the three blob keys and the timestamp of every descendant were fetched, parsed and held for nothing. The branch query now takes the fields a caller wants of a hit, and the command answers a walker with bare id-and-path pairs. A permission requirement is refused on that projection, since deciding one costs exactly the access control key it exists to not fetch - and neither walker filters: move runs its walk with full rights because authority over the root is authority over the subtree, and duplicate decides readability on the nodes it loads anyway.
…listeners #12065 A single upfront total cannot say the amount of work is unknown, and obliges the operation to resolve all of it before starting. resolved(int) carries the total resolved so far - called any number of times, each call replacing the previous value, -1 or silence meaning unknown. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
… unknown #12065 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
Every tree walker already holds its work list before touching anything, so each now tells its listener how much was resolved - move and delete exactly, duplicate as a running total that shrinks as the permission cascade skips entries. Content commands forward the totals to their own listeners, which recover the reach of the 7.16 setTotal in the running-total shape. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
The command resolved the active versions of the whole subtree - one ACL read and two storage lookups per node and branch - before reporting anything or applying anything, and held every resolved version in memory at once. It now walks the subtree in strides over the batched branch listing: each stride is resolved, reported through the growing resolved total, and applied before the next is fetched. First progress moves after one stride instead of after the whole tree, and memory holds one stride instead of all of it. Applying node-by-node instead of branch-by-branch preserves the version origin: the caller-supplied branch order still decides, per node, which branch stores the new version and which ones it is pushed to. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
Move and delete assert the exact total their walks report; duplicate asserts the shrinking sequence around an unreadable child. The content-level tests drive the listener delegates with plain listeners that override nothing but the processed counts, pinning that the total callbacks are optional. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
A per-stride total made progress a sawtooth: the bar neared its end and the next stride grew the denominator by another stride. The batched listing already knows how many raw entries the scan has in front of it, so each stride now projects what remains at the weight per node of what it has resolved, and the reported total settles near its final value at once, corrected exactly by the last stride. The listener javadoc now says a projected total is a legitimate resolved report. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
…2065 A search that requested fields still shipped every hit's whole source and picked the fields out on this side of the wire. The requested names now ride the request as a source filter, so a walker's listing ships ids and paths instead of whole branch entries, and a path-only search stops shipping whole documents. Nothing reads a hit's document id, so the branch index id staying nodeId_branch is not disturbed - the node id remains a filtered source field. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
The stored source is one blob, so the filter saved neither the read nor the one full parse - it only moved the parse to the other side of a fetch that is in-process anyway, and the branch documents it was aimed at are small. The real gain would be a query that asks for node ids alone and derives them from the branch document id without touching the source - none exists today. This reverts commit e67f1c6. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
The lean branch-query projection served nothing the walkers could not do themselves: the query layer answers whole branch entries again, and move and duplicate repack them into their own two-field pairs, letting the entries go as soon as they are mapped - the same retained memory for a walk, none of the query-layer machinery. Duplicate keeps deciding what is permitted exactly as before: the caller-context reads omit unreadable nodes, and an omitted parent takes its whole subtree out of the duplication. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
Duplicate repacked entries into id-and-path pairs only to join them straight back to the full nodes it reads for copying - and those nodes, like every copy accumulating in the result, dominate the memory of the walk regardless. The storage get answers in the requested order, so the path order of the listing survives into the fetched nodes and the walk iterates them directly: no repacking, no id map. Unreadable nodes are absent from the get's answer, which shrinks the resolved total at once; a readable node below an unreadable parent is still skipped one by one by the cascade, pinned in the test by a readable grandchild under the hidden child. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
… in stride #12065 The bulk node get re-fetched from the index the very branch entries the listing had just answered, read every subtree version upfront to decide read access the access blobs - almost always cached - already carry, and held every full node until the walk ended. The walk now settles the duplicable set from the entries and access blobs alone, where a prohibited node takes its whole subtree with it as the contiguous run of path-ordered entries right behind it, reports the exact resolved total once, and completes each node from its version blobs only when its turn comes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
…12065 Every step of the walk re-fetched from the index the branch entry the listing had just answered, and fetched the version metadata even when no attributes resolver was there to read it. The walk now carries the entries themselves - they hold the version keys - completes each node from its version blobs when its turn comes, and touches the version metadata only for a resolver that asks for the attributes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
Resolving a stride ran a command per node that looked up the branch entry and the version metadata per branch, one by one; denying ran a full node read per node to see permissions the cached access blob already carries; and applying read the full node by id again, re-fetching the branch entry a third time. A stride now resolves with one bulk get per branch, denies from the access blob - the walk only ever reaches nodes the caller may read, settled upstream - completes each node from its version blobs when rewriting it, and touches the version metadata only for a resolver that asks for attributes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
A multi-get fans out to one get per document on its shard anyway, so bulking the stride bought nothing and inherited a request-size ceiling. Better: the stride's entries already answer the context branch - the listing returned them - so a single-branch apply resolves without a single lookup, and only the other branches cost one entry get per node. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
The clean-up reported no amount of work at all: its listener's only number is the batch size handed to start, which the task handler uses as a logging interval rather than as a total, so a progress reporter had nothing to report. It now reports what it knows it has to delete as the batches uncover it - a running total, since the enumeration cannot say how much still lies ahead of the cursor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
…12065 The audit clean-up task held a task id but reported no progress, having no total to report; now that the clean-up says what it has found, the task handler forwards it - on the interval it already logs at, since a report per deleted record would publish an event per record. And the batch size stops being something every walk has to state. A walk that hands each batch on and forgets it has no reason to ask for less than the index will answer, so that is the default; the one caller that knows fewer entries settle its question still says so. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
Nothing outside needs to name the largest batch: a caller wanting the most the index will answer says nothing at all, which is now the default. So the ceiling is the class's own business - it exists to be enforced - and the clean-up, which reports the size it runs with to its listener, reads it back from the parameters it built. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
The reporting had no test, where the neighbouring vacuum adapter has one. It now drives the listener the way a two-batch clean-up would and pins the four reports that come out of it: the total moving as the batches uncover it, and the count following on the interval the clean-up logs at. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
The index counts the matches while it cuts a batch, and that count was being thrown away - so the audit clean-up, which had no other way of knowing, reported a total that climbed with every batch as though the work were being discovered rather than merely walked. A batch now carries what is left of the enumeration, itself included. The clean-up adds it to what it has already deleted, so the first batch states the whole amount and every batch after it confirms that figure instead of raising it. An empty run reports zero rather than staying silent: nothing to do is worth knowing, where unknown is not. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
…2065 The export counted its nodes by enumerating the whole subtree and only then told its listener how many there were - so a progress bar over a large export sat at an unknown total for as long as the enumeration took, which is the part that has nothing to show. Each batch now states the total: the node asked for, plus what the enumeration has handed over already and what it says is still to come. The first batch therefore carries the whole figure, and the listener already assigns rather than accumulates it, so a later batch correcting the number costs nothing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
Ten of the operation listeners already said resolved(int) for the amount of work and named the done-items method after their domain. Four did not: the export, import and publish listeners each spelled the total differently - nodeResolved, contentResolved - and the node push listener had no total at all, though its content-level twin did. They all say resolved(int) now. The old names stay as deprecated defaults that the new one calls, so a listener implementing only the old name keeps hearing about it while producers name it once. That same delegation retires the last double-call: resolved carries the deprecated setTotal itself, rather than apply-permissions and its two adapters saying both. The push listener gains the total its walk always had in hand. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
The deprecated total and its replacement forward one way round and not the other: producers call resolved, so the old method keeps being heard only because resolved calls it. Inverting the pair would keep every signature and silently stop telling a listener that implements the old name alone - worth stating where someone reading the pair might think either direction would do. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
It does when the enumeration is bounded by a timestamp: those batches are ordered by timestamp and arrive oldest first, which is the whole reason the clean-up deletes in the order the records accumulated. Only an unbounded enumeration orders by node id, and only there is the order without meaning. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
The bridges were the one part of this with nothing verifying it: production listeners implement the new name, so the forwarding body never ran in a test, and a mock does not run a default method at all. A listener implementing only the old name is exactly what the bridge exists for, so that is what these implement - and calling resolved has to reach it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
The javadoc of list, enumerate and their types explained how the answer is produced - storage rather than search index, scrolls, sized requests, node-id ordering, what the index counts while cutting a batch. A caller cannot rely on any of that, and it goes stale the moment the mechanism changes. What is left is what a caller can hold the API to: what the answer holds, what it costs, when a node becomes visible, how far the at-most-once guarantee reaches, what a bound and a cursor promise, which exceptions come out. The numbers are named where they used to be described - a batch is at most 10 000 rather than "as much as the index will answer in one request", and the remaining count says what it counts instead of calling itself exact. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
Implementation classes, tests and the benchmark carried running commentary on what the code next to it was doing and why. It says nothing the code does not, and it is one more thing to keep true. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
The remaining count came straight out of the index as a long, which is more range than a node count can use and more than a script can hold: JavaScript has no long, and every listener, batch size and count already in the API is an int. It is now an int, saturated where the index's number crosses into the API, and the two callers that had to clamp it themselves add plainly instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
Every other operation listener is a running total plus one domain-named method carrying how many items are done: resolved(int) and nodesMoved, nodesDeleted, nodesPushed, permissionsApplied. The audit clean-up had resolved(int) plus start(int batchSize), a countless processed() called once per record, and finished(). processed() is deprecated for recordsDeleted(int), which the default forwards to once per record, so a listener implementing only the old name hears exactly what it heard before. start(int) is deprecated for two reasons: resolved already announces the work before the first delete, and the batch size it publishes is the ceiling this API deliberately keeps private. finished() is deprecated because the clean-up is over when it returns, which the caller that started it already knows - the task handler logs the two lines around its own call now, and its listener reports progress on a report interval of its own rather than on whatever batch size it was handed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
The task handler reports progress once every thousand records, so a clean-up that ended mid-interval left the task showing the count of the last full thousand - a scheduled clean-up of nine hundred records showed none of them at all. The handler now reports once more when cleanUp returns, which is also where it logs that the clean-up is over. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
Deleting the listing's non-recursive mode quietly widened three system listings from the children of a folder to its whole subtree. VirtualAppService.list was wrong from that moment: a virtual application keeps its resources in a subtree of its own node, so every folder and every yaml file below an application came back as an application, each mapped to whatever key its path happened to yield. The application repo and the scheduled job listings hold flat folders today and so answered the same as before, but they asked the wrong question and would break the day anything gains a child. All three now keep the entries whose parent is the folder they listed - which is what the deleted mode did, since the branch index carries no parent field and answered the mode by filtering the same scan. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
The export enumerated in batches and then accumulated every entry into one list before writing anything, so it held an entry per node in the subtree - the whole point of enumerating in batches, given up one line later. It now writes each batch as it arrives: the first batch states the total, the root goes out, and every batch is exported and forgotten. Only the child order of manually ordered parents outlives a batch, which is what the order files are written from at the end. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
A manually ordered parent has its children's order synced through a search of the target level. Where the parent's order was already equal to the source's, nothing was sorted first, so nothing had refreshed the index either - and children written earlier in the same synchronization were invisible to that search, leaving their order unsynced with no sign of it. The sync now refreshes before reading the level, so everything written up to that point counts. Concurrent modifications remain out of reach, as they are for every search. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
Archiving a content takes it offline first, and so does deleting a published one - both commands unpublish the subtree before they touch it, and neither said a word about that half of the work. A caller counting progress from the listener it passed in was told about the move, or the delete, and nothing else. Both params now take an unpublishListener, forwarded to the unpublish the command already performs. PushContentListener has one abstract method, so a caller can hand over a lambda for that phase and keep its own listener implementing one interface - where until now the only way to hear unpublishing was to run it yourself and pass a PushContentListener to it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs
Follow-up to #12247. It began as the two gaps that PR left open — a listing that had to be held whole, and an audit log vacuumed through the search index — and grew into the walk that both of those wanted: enumerate the branch entries, decide from what the entries already carry, and complete nodes from version blobs only where a node is really needed. Every whole-subtree operation in the repository now works that way, and every one of them reports its progress in the same words.
1.
listandenumerateare two methods, not one with a modeThe listing API had grown a batch size and a cursor, which turned one method into two contracts wearing one name. They are now separate:
liststreams. It is used to collect ids more often than anything else, so it answers with aStream<NodeListEntry>that maps straight into whatever the caller collects, without the intermediate result objectListNodesResultused to be. That class is gone; so is itsrecursiveflag. The branch index carries no parent field, so the flag never bought a cheaper scan — the non-recursive form was the same scan with a filter on top. The three system listings that do want the children of a folder (virtual applications, the application repo, scheduled jobs) now carry that filter themselves, where the cost of it is visible.enumerateis always batched, and asks for the administrator role. It serves dump, export, clean-up and synchronization, all of which need every node the subtree holds — so instead of filtering each entry against the caller's permissions, it refuses a caller without the role up front. Entries therefore cost no ACL resolution at all, and a batch is exactly as large as it says.An enumeration can be bounded, and then it means something
enumeratetakesmodifiedBefore. A bounded enumeration holds only the nodes whose timestamp falls before the bound, and it arrives oldest first — a consumer working through a backlog gets it in the order the backlog accumulated, for nothing. The cursor is then(timestamp, id)rather than the id alone, and a cursor that never came from a bounded batch is refused with a plain message rather than a raw parse error.Every entry names the version it was observed at
NodeEnumerationEntrycarries aNodeVersionId. A walker that reads by it —getByIdAndVersionId,getVersion— works on the snapshot its enumeration described: the path and timestamp of the entry are those of what is read, whatever the node has become since. The export and the dump both do this now, so neither can write a node under a path it no longer has, or a timestamp it no longer carries.A batch says how much is left
EnumerateNodesResult.getRemaining()is the number of entries this batch holds plus everything after it. It costs nothing (the index counts it while answering) and, since an enumeration filters nothing away, it is the real number rather than a guess. Consumers add it to what they have consumed already, so the first batch states the size of the whole walk and later batches correct it if the subtree changes. It is anint, saturated where the index's ownlongcrosses into the API — the range a node count can use, and the range a script can hold.2. The audit log vacuum stops using find — and stops refreshing
CleanUpAuditLogCommandwas the worst find-and-delete loop left: query the search index, delete 10 000 records one at a time, refresh the search index, query again. It now enumerates the log from storage, bounded by the age threshold, and deletes what it is handed. There is no refresh anywhere in it: the cursor only moves forward over ground the deletions leave behind, and a record too fresh to be visible without a refresh is far too fresh to be expired.One deliberate behavior change: a record is aged by its node timestamp — when it was written — where the search filtered the record's own
timefield, which a caller ofAuditLogService.logmay set freely. The log is add-only and written once, so the two coincide for every record the system writes; a backdated record now lives until its write time passes the threshold.The clean-up also reports what it is doing, in the words the rest of the family uses.
CleanUpAuditLogListenergainedresolved, so the vacuum states how many records it has to delete — computed from the first batch's remaining count, not raised one batch at a time — andprocessed()becamerecordsDeleted(int), the count-carrying, domain-named method every other listener has.start(int batchSize)andfinished()are deprecated:resolvedalready announces the work before the first delete, the batch size they published is the ceiling this API keeps private, and the clean-up is over when it returns.AuditLogCleanupTaskHandlerlogs those two moments around its own call and feeds the running count plus the total into the task'sProgressReporter— on a report interval of its own, with a final report when the clean-up returns, so a run that ends mid-interval still lands on its exact count.3. Dump and export walk enumerations and read version blobs
Both walkers held the whole listing at once and used nothing of it but ids. Both now enumerate:
NodeExporterenumerates the subtree, states its total from the first batch (1 + consumed + remaining), and reads each node by the version its entry named. It also collects the manual child order of each parent as the walk passes its children rather than reading the children again per ordered parent — the walk had them in hand.ExportNodesParams.batchSizeno longer influences anything and is deprecated.RepoDumperkeepsMap<NodeId, Map<Branch, NodeVersionId>>: the version each branch's scan observed. Version dumping then dumps exactly those versions instead of asking for whatever is active by the time it gets there, andgetActiveVersionsis gone from the dump path.4. Move, duplicate, delete and apply-permissions walk what they already hold
These four rebuilt the whole subtree as
Nodesbefore doing anything. They now work from the branch entries and the access-control blobs the entries point at, and fetch version data only for the nodes they actually touch:Companion PR #12282 carries the same benchmark on
master(NodeTreeOperationsBenchmark: move, duplicate, applyPermissions, delete over one parent with 10 000 children) so the two runs can be compared. Allocation churn is expected to match — every node is still read, rebuilt and re-indexed — and peak live set is the column this work moves.5. Every operation listener names its resolved work the same way
There were three vocabularies for one idea:
setTotal,nodeResolved/contentResolved, and nothing at all. All of them are nowresolved( int )— a running total that may be called any number of times, corrected in either direction, with-1for "unknown again":MoveNodeListener,DeleteNodeListener,DuplicateNodeListener,PushNodesListener,ArchiveContentListener,RestoreContentListener,DeleteContentListener,DuplicateContentListener,MoveContentListenerandCleanUpAuditLogListener, and fired by the commands behind them.ApplyNodePermissionsListener.setTotal/ApplyPermissionsListener.setTotalare deprecated: a single upfront total cannot say "unknown", and it obliges the operation to resolve everything before it starts.NodeExportListener.nodeResolved,NodeImportListener.nodeResolved,PushContentListener.contentResolvedandCleanUpAuditLogListener.processed()are deprecated but still reached: the new method's default forwards to the old one, never the other way round. Producers call the new name only, so inverting that direction would silently stop notifying any consumer that implements the old one — four bridge tests pin the direction, and the javadoc says which way it goes.6. Layer synchronization stops rescanning the subtree
ParentContentSynchronizerdescended level by level, and each level cost another subtree scan. It now takes the subtree once, grouped by parent, and slices it into levels — the descent still syncs a parent before its children.cleanDeletedContentsdoes the same, from one upfront grouped fetch whose snapshot survives its own walk. Everything else in the sync consumes its listings in batches.The delete guard was renamed after what it decides:
hasNoChildren(which examined the whole subtree and inbound references) is nownoDescendantSurvivesandnoInboundReferenceSurvives. The descendant check no longer walks the subtree either — one descendant outside the removal set settles it, so it asks foridsToRemove.size() + 1entries, which for the clean-up flows is a couple.Notes for review
RangeFilter.from/toare inclusive (they setincludeLower/includeUpper); the cursors use the exclusivegt/ltforms. The first itest run caught the difference as duplicated entries at every batch boundary.NodeQuery's_sourcewas briefly filtered down to the requested return fields; the commit is reverted in place — complexity without a measurable win, since the whole_sourcecrosses the wire per hit either way.Verification
core-api(incl.EnumerateNodesParamsTestand the four listener-bridge tests),core-repo,core-content,core-audit,core-export,core-scheduler,core-app,core-security,app-system(incl. the clean-up progress test, which fails if the final report goes missing) andlib-projectunit suites; the node, export, dump, audit, apply-permissions, push and content-publish itest packages, including newNodeServiceImplTest_enumeratecases for the admin gate, unfiltered entries, the timestamp bound, cursor semantics under concurrent deletes and moves, and a malformed cursor. The virtual-application and scheduled-job listings are pinned against descendants leaking into them — both assertions were verified to fail without the fix. The only local failures are the three known container-locale/charset cases (import_special_characters,ZipVirtualFileTest,ZipExportWriterTest), which pass in CI.🤖 Generated with Claude Code
https://claude.ai/code/session_01PT3t5hDXj91Sk6idKRQwBs