Skip to content

Enumerate the subtree: batched node listing, snapshot walks, one progress vocabulary #12065 - #12281

Merged
rymsha merged 64 commits into
masterfrom
claude/xp-issue-12065-alternative-pur3l4
Aug 24, 2026
Merged

Enumerate the subtree: batched node listing, snapshot walks, one progress vocabulary #12065#12281
rymsha merged 64 commits into
masterfrom
claude/xp-issue-12065-alternative-pur3l4

Conversation

@rymsha

@rymsha rymsha commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

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. list and enumerate are two methods, not one with a mode

The listing API had grown a batch size and a cursor, which turned one method into two contracts wearing one name. They are now separate:

// the whole subtree, path-ordered, filtered by what the caller may read
final ContentIds ids = nodeService.list( ListNodesParams.create().parentPath( parent ).build() )
    .map( entry -> ContentId.from( entry.nodeId() ) )
    .collect( ContentIds.collector() );

// the whole subtree, one bounded batch at a time
String cursor = null;
do {
    final EnumerateNodesResult batch = nodeService.enumerate(
        EnumerateNodesParams.create().parentPath( parent ).cursor( cursor ).build() );
    // consume batch.getEntries()
    cursor = batch.getCursor();
} while ( cursor != null );
  • list streams. It is used to collect ids more often than anything else, so it answers with a Stream<NodeListEntry> that maps straight into whatever the caller collects, without the intermediate result object ListNodesResult used to be. That class is gone; so is its recursive flag. 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.
  • enumerate is 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.
  • The cursor is a position, not a page. An unbounded enumeration positions on the node id, which never changes: a node moved between batches is still observed exactly once, and deletions behind the cursor cost nothing. Both are pinned by itests that move and delete entries mid-enumeration.
  • A batch may be empty before the enumeration is over, so the contract is to continue until a batch answers with no cursor.
  • The batch ceiling is not API. 10 000 is the most the index will answer in one request; it is the default and the maximum, and it is private — a caller sets a smaller batch only where fewer entries settle the question.

An enumeration can be bounded, and then it means something

enumerate takes modifiedBefore. 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

NodeEnumerationEntry carries a NodeVersionId. 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 an int, saturated where the index's own long crosses 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

CleanUpAuditLogCommand was 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 time field, which a caller of AuditLogService.log may 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. CleanUpAuditLogListener gained resolved, 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 — and processed() became recordsDeleted(int), the count-carrying, domain-named method every other listener has. start(int batchSize) and finished() are deprecated: resolved already 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. AuditLogCleanupTaskHandler logs those two moments around its own call and feeds the running count plus the total into the task's ProgressReporter — 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:

  • NodeExporter enumerates 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.batchSize no longer influences anything and is deprecated.
  • RepoDumper keeps Map<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, and getActiveVersions is gone from the dump path.

4. Move, duplicate, delete and apply-permissions walk what they already hold

These four rebuilt the whole subtree as Nodes before 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:

  • Move and duplicate walk entries in path order — a parent is always handled before its children — and complete nodes from version blobs a stride at a time. Duplicate settles what the caller may copy from the blobs alone, which are almost always cached, so its resolved total is exact from the first report; an unreadable node takes its subtree with it, which path order serves as the contiguous run behind it.
  • Apply-permissions streams in strides. It resolves a bounded stride, applies it, reports, and moves on, instead of resolving the whole subtree first — so progress moves while the operation runs and the versions held at once are bounded. The total it reports is projected from the first stride rather than growing stride by stride.
  • Version metadata is not fetched where nothing reads it. Only an attribute resolver reads it, so without one the walk skips it entirely.

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 now resolved( int ) — a running total that may be called any number of times, corrected in either direction, with -1 for "unknown again":

  • Added to MoveNodeListener, DeleteNodeListener, DuplicateNodeListener, PushNodesListener, ArchiveContentListener, RestoreContentListener, DeleteContentListener, DuplicateContentListener, MoveContentListener and CleanUpAuditLogListener, and fired by the commands behind them.
  • ApplyNodePermissionsListener.setTotal / ApplyPermissionsListener.setTotal are deprecated: a single upfront total cannot say "unknown", and it obliges the operation to resolve everything before it starts.
  • NodeExportListener.nodeResolved, NodeImportListener.nodeResolved, PushContentListener.contentResolved and CleanUpAuditLogListener.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.
  • The audit clean-up's own shape was the last one out of line, and is covered in section 2.

6. Layer synchronization stops rescanning the subtree

ParentContentSynchronizer descended 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. cleanDeletedContents does 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 now noDescendantSurvives and noInboundReferenceSurvives. The descendant check no longer walks the subtree either — one descendant outside the removal set settles it, so it asks for idsToRemove.size() + 1 entries, which for the clean-up flows is a couple.

Notes for review

  • RangeFilter.from/to are inclusive (they set includeLower/includeUpper); the cursors use the exclusive gt/lt forms. The first itest run caught the difference as duplicated entries at every batch boundary.
  • Public-API javadoc states the contract only — what the answer holds, what it costs, when a node becomes visible, how far at-most-once reaches, which exceptions come out — and no longer describes how any of it is produced.
  • Id providers are listed through a search again: a listing of the providers would have to scan every user and group in the system, where a query narrows on the parent like every other principal lookup there.
  • NodeQuery's _source was briefly filtered down to the requested return fields; the commit is reverted in place — complexity without a measurable win, since the whole _source crosses the wire per hit either way.

Verification

core-api (incl. EnumerateNodesParamsTest and 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) and lib-project unit suites; the node, export, dump, audit, apply-permissions, push and content-publish itest packages, including new NodeServiceImplTest_enumerate cases 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

claude added 2 commits August 18, 2026 16:55
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.
Copilot AI lite review requested due to automatic review settings August 18, 2026 16:57
@codacy-production

codacy-production Bot commented Aug 18, 2026

Copy link
Copy Markdown

Codacy's Analysis Summary

0 new issue (≤ 0 issue)
0 new security issue
130 complexity
More details

AI Reviewer: run a review on demand. To trigger the first review automatically, go to your organization or repository integration settings. AI can make mistakes. Always validate suggestions.

Run reviewer

TIP This summary will be updated as you push new changes. Give us feedback

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 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) to NodeService.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.

Comment thread modules/core/core-api/src/main/java/com/enonic/xp/node/ListNodesParams.java Outdated
Comment thread modules/core/core-api/src/main/java/com/enonic/xp/node/ListNodesParams.java Outdated
claude added 2 commits August 18, 2026 17:06
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

codecov Bot commented Aug 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.15068% with 40 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.10%. Comparing base (b0341bd) to head (4526108).
⚠️ Report is 9 commits behind head on master.

Files with missing lines Patch % Lines
...a/com/enonic/xp/core/impl/export/NodeExporter.java 81.53% 8 Missing and 4 partials ⚠️
...c/xp/core/impl/app/ApplicationRepoServiceImpl.java 0.00% 6 Missing ⚠️
...nic/xp/core/impl/security/SecurityServiceImpl.java 0.00% 5 Missing ⚠️
...xp/repo/impl/node/ApplyNodePermissionsCommand.java 96.20% 0 Missing and 3 partials ⚠️
.../xp/core/impl/content/DeletedEventSyncCommand.java 81.81% 0 Missing and 2 partials ⚠️
...ic/xp/lib/project/SetProjectPublicReadHandler.java 0.00% 2 Missing ⚠️
...ava/com/enonic/xp/content/PushContentListener.java 66.66% 1 Missing ⚠️
.../java/com/enonic/xp/export/NodeExportListener.java 66.66% 1 Missing ⚠️
.../java/com/enonic/xp/export/NodeImportListener.java 66.66% 1 Missing ⚠️
...nic/xp/core/impl/content/LayersContentService.java 96.42% 0 Missing and 1 partial ⚠️
... and 6 more
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

claude added 5 commits August 18, 2026 17:36
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
claude added 3 commits August 19, 2026 15:14
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
Comment thread modules/core/core-api/src/main/java/com/enonic/xp/archive/ArchiveContentListener.java Dismissed
Comment thread modules/core/core-api/src/main/java/com/enonic/xp/archive/RestoreContentListener.java Dismissed
Comment thread modules/core/core-api/src/main/java/com/enonic/xp/content/DeleteContentListener.java Dismissed
Comment thread modules/core/core-api/src/main/java/com/enonic/xp/content/MoveContentListener.java Dismissed
claude added 2 commits August 19, 2026 17:37
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
claude added 8 commits August 19, 2026 18:53
…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
claude added 6 commits August 20, 2026 18:40
…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
Comment thread modules/core/core-api/src/main/java/com/enonic/xp/node/PushNodesListener.java Dismissed
claude added 5 commits August 20, 2026 19:57
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
@rymsha rymsha changed the title Batched node listing, audit log vacuum from storage #12065 Enumerate the subtree: batched node listing, snapshot walks, one progress vocabulary #12065 Aug 21, 2026
claude added 2 commits August 21, 2026 05:12
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
Comment thread modules/core/core-api/src/main/java/com/enonic/xp/audit/CleanUpAuditLogListener.java Dismissed
claude added 4 commits August 21, 2026 06:33
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
@rymsha
rymsha merged commit 932bfd3 into master Aug 24, 2026
10 checks passed
@rymsha
rymsha deleted the claude/xp-issue-12065-alternative-pur3l4 branch August 24, 2026 09:10
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.

4 participants