Skip to content

feat(engine): let the schema dictionary grow past a single page - #5560

Merged
lvca merged 12 commits into
mainfrom
dictionary-multipage
Jul 30, 2026
Merged

feat(engine): let the schema dictionary grow past a single page#5560
lvca merged 12 commits into
mainfrom
dictionary-multipage

Conversation

@lvca

@lvca lvca commented Jul 29, 2026

Copy link
Copy Markdown
Member

Important

Release note / upgrade requirement: in a cluster, upgrade followers before, or together with, the leader.

Dictionary pages replicate as raw pages. Once a leader's dictionary grows past its first page it ships page 1 and
beyond to its followers, and a follower still running a build without multi-page support writes those pages but
reloads only page 0. Its in-RAM dictionary is then missing every name past the first page, and each record
referencing one fails with Dictionary item with id N is not valid.

Related: a database that has rolled over can no longer be opened by an older ArcadeDB at all. To check whether a
given database has rolled over, divide its dictionary file size by the page size in its own file name:
ls -l <db>/dictionary.*.dict - 131072 / 65536 = 2 pages means rolled over. A file at or under one page is still
single-page and remains downgradable.

Full detail in docs/5560-dictionary-multipage.md, section Upgrade notes.

The limit

com.arcadedb.engine.Dictionary maps every type name, property name and enumerated string value to a small integer id, and that id is what records carry instead of the name. Despite the class javadoc saying "CONTENT-PAGES", it only ever used page 0, so a database was capped at whatever fitted there: pageSize - 8 - 4 = 327,668 bytes of names, measured at 48,396 short names before the write was refused.

Past that point CREATE PROPERTY, or inserting a document with a new field name, failed permanently. There was no path to grow, and entries are never reclaimed, so a schemaless workload with dynamic field names walked steadily toward it.

What changed

Names roll over to a new page. Every page, page 0 included, carries the same 4-byte legacy counter, so a dictionary written before this change is exactly a dictionary of one page and loads unchanged: no migration, no rewrite, no format flag needed to read an existing database.

The invariant everything follows from: an id is the ordinal of the name in page order, and that id sits inside records on disk. So the layout is strictly append-only across pages. Names go only to the LAST page, and a page left behind is never revisited even when it still has room. Filling a gap would renumber every name after it and silently repoint every record referencing them, which is the one way this change could corrupt data rather than throw. A name is never split across pages, so the wasted tail is under 1% at identifier lengths, and the only limit left is a single name larger than one page, which is now refused by name.

Three details worth review attention:

  • addItemToPage reads the tail page before deciding where to write. Asking for it as modifiable and then finding it full would bump its version, rewrite it at commit for nothing, and make it false-conflict with concurrent transactions.
  • reload() walks the committed page count, not getTotalPages(). TransactionContext.rollback() calls reload() while the transaction's own page counter still counts pages being thrown away. The floor of 1 keeps the previous behaviour of always reading page 0 of a non-empty file.
  • updateName() re-lays the dictionary out from page 0 in the same order, so no id moves, and empties the pages the new content no longer reaches. reload() walks every committed page, so a stale tail page would otherwise re-add its old names.

Page size and format version

DEF_PAGE_SIZE drops from 327,680 to 65,536 for new dictionaries. That size existed only to make the single available page hold as many names as possible. Now that pages roll over, a new name dirties and eventually flushes one page, so a smaller page is 5x less write amplification per name. Existing databases keep the page size they were created with, which is read back from the file name.

CURRENT_VERSION goes to 1, with a guard refusing a dictionary written by a newer ArcadeDB rather than misreading it.

Downgrade note: once a database grows past page 0 it can no longer be opened by an older ArcadeDB, which reads page 0 only and reports Dictionary item with id N is not valid. Loud, not silent, and such a database could not have existed before this change since the write would have been refused.

Tests

New DictionaryMultiPageTest (in com.arcadedb.engine, so it can inspect the on-page layout):

  • rollover instead of refusal, with every id resolvable both ways
  • ids survive a reopen
  • page 0 still reads with the pre-multi-page algorithm, which is the compatibility guarantee
  • a name too big for an empty page is refused clearly and leaves the dictionary usable
  • documents whose property names span pages read back after a reopen (the serializer round trip)
  • updateName rewrites every page and keeps every other id
  • the follower path: a replicated WAL transaction carrying a brand new dictionary page creates it, makes its names visible, and the next local name continues after it

DictionaryLimitsTest had a test asserting the old "no space left in dictionary file" behaviour; it now asserts that filling well past one page keeps working.

Local run: 920 engine test classes, 0 failures, at the time of pushing (the full run was still going through an unrelated long test).

The dictionary held every type name, property name and enumerated string value
in page 0 alone, so a database was capped at whatever fitted there: 327,668
bytes, measured at 48,396 short names. Past that, CREATE PROPERTY and inserting
a document with a new field name failed permanently, with no way to grow.

Names now roll over to a new page. Every page, page 0 included, carries the same
4 byte legacy counter, so a dictionary written before this change is exactly a
dictionary of one page and loads with no special case: no migration, no rewrite,
no format flag needed to read an existing database.

An id is the ordinal of the name in page order and that id is written inside
records on disk, so the layout is strictly append-only across pages. Names go
only to the LAST page; a page left behind is never revisited even when it still
has room. Filling a gap would renumber every name after it and silently repoint
every record that referenced them, which is the one way this change could
corrupt data rather than throw. The wasted tail is under 1% at identifier
lengths, and a name is never split across pages, so the only limit left is a
single name larger than one page, which is now refused by name.

addItemToPage reads the tail page before deciding where to write: asking for it
as modifiable and then finding it full would bump its version, rewrite it at
commit for nothing, and make it false-conflict with concurrent transactions.

reload() walks the committed page count rather than getTotalPages(), because
TransactionContext.rollback() calls it while the transaction's own page counter
still counts pages that are being thrown away. The floor of 1 keeps the previous
behaviour of always reading page 0 of a non-empty file.

updateName() re-lays the whole dictionary out from page 0 in the same order, so
no id moves, and empties the pages the new content no longer reaches: reload()
walks every committed page, and a stale tail page would re-add its old names.

The page size drops from 327,680 to 65,536 for new dictionaries. That size
existed only to make the one available page hold as many names as possible; now
that pages roll over, a new name dirties and eventually flushes one page, so a
smaller page is five times less write amplification per name. Existing databases
keep the page size they were created with, read back from the file name.

Format version goes to 1, with a guard refusing a dictionary written by a newer
ArcadeDB instead of misreading it. Note the reverse: once a database grows past
page 0 it can no longer be opened by an older ArcadeDB, which reads page 0 only
and reports "Dictionary item with id N is not valid". Such a database could not
have existed before this change, since the write would have been refused.
@mergify

mergify Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

@codacy-production

codacy-production Bot commented Jul 29, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 10 complexity

Metric Results
Complexity 10

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@lvca lvca self-assigned this Jul 29, 2026
@lvca lvca added this to the 26.8.1 milestone Jul 29, 2026
@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review: feat(engine): let the schema dictionary grow past a single page

Reviewed the full diff plus the surrounding engine code (PaginatedComponent, BasePage, TransactionManager.applyChanges, TransactionContext.rollback). This is a clean, well-reasoned change with unusually good in-code documentation of the invariants. The append-only-across-pages model, the read-before-modify in addItemToPage, and the use of the committed pageCount (with floor of 1) in reload() are all correct, and I verified the compatibility and follower claims against the code:

  • Layout math is exact. checkNameFitsAPage computes usable = pageSize - PAGE_HEADER_SIZE - DICTIONARY_HEADER_SIZE, which equals freeSpaceIn() of a freshly reset page (getMaxContentSize() == size - PAGE_HEADER_SIZE, getContentSize() == 4 after updateCounters). So any name passing the check is guaranteed to fit an empty page.
  • Follower path is sound. TransactionManager.applyChanges calls component.updatePageCount(pageNumber+1) before the final dictionary.reload(), so a replicated new page is included by reload()'s pageCount.get() walk. Matches the new test.
  • Rollback reasoning holds. TransactionContext.rollback() reloads the dictionary from committed state, and pageCount only advances on commit, so walking pageCount.get() rather than getTotalPages() is correct.
  • No stale references. getAvailableSpace / "No space left in dictionary" / the old test name have no remaining references, so compilation is clean.

Suggestions (all minor)

  1. getAvailableSpaceInLastPage() is now dead code. After the rename it has no callers anywhere in the repo (the only caller was the removed DictionaryLimitsTest assertion). Either drop it or, if it is intended as public API, that is fine as-is - just flagging it.

  2. updateName's IOException catch drops the cause. throw new SchemaException("Error on updating name in dictionary") does not chain e, while you correctly fixed addItemToPage to pass the cause (new SchemaException(..., e)). Worth making consistent for debuggability.

  3. updateName mutates the in-RAM entries before validating the new name fits a page. The dictionaryMap.remove(oldName) and the dictionary.set(old -> new) happen first; checkNameFitsAPage only fires inside the rewrite loop. If newName is larger than one page it throws DatabaseMetadataException (not IOException, so the local catch does not run), leaving the RAM view inconsistent until the caller's transaction rollback triggers reload() to repair it. It is self-healing via rollback (confirmed) and updateName currently has no production callers (only tests), so this is low priority - but validating newName up front, before touching entries, would be cleaner and avoid depending on the rollback-reload contract.

  4. Orphaned pages after a shrink. When updateName shrinks the content, the emptied tail pages stay physically in the file and are walked on every reload(). Fine given how rare updateName is; noting it for completeness.

Test coverage

Strong: rollover-instead-of-refusal, id stability across reopen, the legacy page-0 layout guarantee, oversized-name rejection leaving the dictionary usable, the serializer round trip, updateName rewrite, and the follower/WAL apply path. Two gaps worth considering:

  • updateNameRewritesEveryPage only exercises the shrink branch (name(1) -> "short"). The grow branch of resetPageForRewrite (pageNumber >= getTotalPages() -> addPage) is not covered - a rename of a short name to a multi-page-spanning one would hit it.
  • No test asserts the new version > CURRENT_VERSION guard throws DatabaseMetadataException.

Overall: LGTM. The suggestions are polish, not blockers.

Drop getAvailableSpaceInLastPage(). It had no caller left once the test that
used it was rewritten, and dead public API on an engine class is a liability;
the one test that needs the tail page's free space lives in the same package and
reads the page directly.

Validate newName up front in updateName(), before anything is mutated. The
rewrite edits the in-RAM view first, so a name too big for a page used to leave
the dictionary renamed but unwritten until the caller's rollback happened to
repair it. Every other name is already stored on a page of this size, so newName
is the only one that can fail. The per-name check inside the rewrite loop stays:
it costs two arithmetic operations and is the only thing standing between a
corrupt page and a raw "cannot write outside the page space".

Chain the cause into the SchemaException updateName() throws on IOException, as
addItemToPage() already does.

Two tests for paths that were not covered: renaming to a name that no longer
fits the existing pages, which is the branch of resetPageForRewrite that adds
one, and opening a database whose dictionary claims a newer format version,
exercised through the real load path by renaming the file rather than by calling
the constructor directly.

Not changed: the pages updateName() empties when the content shrinks stay in the
file. Truncating would be the complete fix, but page counts are tracked by
PaginatedComponent.pageCount, TransactionContext.newPageCounters and
TransactionManager.applyChanges on the follower, so shrinking a live file id
risks a follower replaying a WAL entry for a page number that no longer exists.
That is real risk against a method with no production callers, to reclaim pages
that cost one zero-entry read each.
@lvca

lvca commented Jul 29, 2026

Copy link
Copy Markdown
Member Author

Thanks, all six points reviewed. Four applied, one applied slightly differently from the suggestion, one declined with reasoning.

1. getAvailableSpaceInLastPage() dead code — dropped rather than kept as public API. The review offered either option; dropping is the better one. Dead public API on an engine class is a maintenance liability, and the "diagnostics" justification I had for keeping it was speculative. The one place that genuinely needs the tail page's free space is a test, and that test lives in com.arcadedb.engine, so it reads the page directly with no production API at all.

2. IOException cause dropped — fixed, now consistent with addItemToPage.

3. updateName mutates RAM before validating — applied, and taken one step further than suggested. newName is now validated before anything is touched, which is sufficient on its own: every other name is already stored on a page of this same size, so newName is the only one that can fail. I deliberately kept the per-name check inside the rewrite loop rather than replacing it. It is two arithmetic operations per name and it is the only thing between a corrupt page and writeString failing with a raw cannot write outside the page space. Cheap defence in depth on a path that is now otherwise unreachable.

4. Orphaned pages after a shrinkdeclining, and I want to be explicit about why rather than just calling it rare. Truncating the file is the obvious "complete" fix, but page counts are tracked in three places that would disagree with a shrunk file: PaginatedComponent.pageCount, TransactionContext.newPageCounters, and TransactionManager.applyChanges, which calls updatePageCount(pageNumber+1) on the follower. Shrinking a live file id risks a follower replaying a WAL entry for a page number that no longer exists — a version-gap cascade, which is exactly the failure class the surrounding code goes to some length to avoid. That is real risk taken on against a method with no production callers, to reclaim pages whose entire cost is one zero-entry read per reload. Leaving them is the better trade.

5. Grow branch of resetPageForRewrite untested — agreed, that was a genuinely uncovered path. Added updateNameGrowingBeyondTheExistingPagesAddsOne: renames an entry to a name that nearly fills a page on its own, so the re-layout cannot fit in the pages already there, then asserts the page count grew and every other id survived, before and after a reopen.

6. No test for the version guard — added, but not as a constructor unit test. Calling the constructor directly would prove the if fires while skipping everything that makes the guard matter. aDictionaryFromANewerFormatIsRefusedInsteadOfMisread renames the dictionary file to .v99. on disk and attempts a real factory.open(), asserting it fails with the version in the message and registers no active instance, then restores the file so the database is dropped normally. That covers the guard and the load path that reaches it.

Local: 18 dictionary tests, then 623 tests across com.arcadedb.schema, com.arcadedb.serializer, com.arcadedb.database and com.arcadedb.engine. 0 failures, 0 errors.

@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review: feat(engine): let the schema dictionary grow past a single page

Reviewed statically against the surrounding engine (PaginatedComponent, BasePage/MutablePage, TransactionManager.applyChanges, BinarySerializer). I was unable to run the Maven build in the review sandbox, so I relied on the author's reported local run (920 engine test classes, 0 failures) plus code analysis.

Overall this is a strong, carefully reasoned change. The invariant is stated crisply ("an id is the ordinal of the name in page order"), the on-disk backward compatibility is genuinely zero-cost (page 0 keeps the exact legacy shape, version 0 loads unchanged), and the comments explain the why behind each subtle decision rather than restating the code. The tests are unusually thorough for this kind of format change.

Things I verified and agree with

  • Space accounting is self-consistent. checkNameFitsAPage uses pageSize - PAGE_HEADER_SIZE - DICTIONARY_HEADER_SIZE, and freeSpaceIn = getMaxContentSize() - getContentSize() reduces to size - content.size(). A fresh page after updateCounters() has getContentSize()==4, so a name that passes checkNameFitsAPage is guaranteed to fit an empty page. Good that getMaxContentSize() was chosen over getAvailableContentSize(), which over-reports by exactly PAGE_HEADER_SIZE.
  • addItemToPage read-before-modify is correct and worthwhile. Using getPage() to test free space before getPageToModify() avoids bumping the tail page's version and false-conflicting with concurrent transactions (matches the page-is-the-conflict-unit rule in engine/CLAUDE.md).
  • The follower path holds together. applyChanges calls component.updatePageCount(pageNumber+1) per modified page BEFORE dictionary.reload(), so a replicated brand-new page is included when reload() walks pageCount.get(). The dedicated test exercises exactly this.
  • The ordinal invariant survives emptied middle/tail pages. After updateName shrinks the content, trailing pages are emptied but retained; empty pages contribute zero names on reload(), and addItemToPage still appends to the last (possibly empty) page, so newPos = entries.names().size() stays equal to the reload ordinal. Nice that this falls out of the design rather than needing a special case.
  • reload() using pageCount.get() (committed) rather than getTotalPages() (tx-local) is the right call for the rollback path, and the Math.max(1, ...) floor preserves the always-read-page-0 behaviour.
  • getAvailableSpace() removal is safe - no production callers; the only user was the rewritten test.

Minor points (non-blocking)

  1. DEF_PAGE_SIZE drop shrinks the max single dictionary entry from ~327 KB to ~65 KB for new databases. I checked this is safe: the only getIdByName(..., create=true) callers store identifiers (type/property names, BinarySerializer line 936); the value-compression path (BinarySerializer line 988) uses create=false, so arbitrary user strings are never inserted. Worth a one-line note in the PR body that the reduced limit only ever applies to identifiers, since 'enumerated string value' in the class javadoc reads like it could be user data.

  2. Downgrade asymmetry for freshly created DBs. A new DB is written as v1 with page size 65536. As long as it stays within page 0 it remains readable by older ArcadeDB (the old reader had no version guard and page 0 is layout-identical), and only becomes unreadable once it rolls over - consistent with the PR's downgrade note. Just flagging that new single-page v1 files are NOT refused by old versions (desired, but easy to misread from the note).

  3. Redundant checkNameFitsAPage in the updateName loop (every name re-checked, including newName already validated up front). The author already documents this as an intentional corrupt-page guard costing two arithmetic ops - agreed, fine to keep.

  4. Test coverage gap (small): there is a test for the follower multi-page path and for updateName rollback-shrink, but not for a purely local transaction that crosses a page boundary and then rolls back - the exact scenario the pageCount vs getTotalPages() distinction in reload() exists to protect. Adding one would pin that reasoning directly; the existing tests cover it indirectly.

Style, TDD, final usage, and the license/@author headers all match repo conventions. No security concerns - this is an internal format change, and the new version guard strictly improves robustness against misreading a future format.

Nice work.

…saction's

Writing the test the review asked for, for a local transaction that grows the
dictionary onto a new page and then rolls back, showed the reasoning it was
meant to pin did not actually hold.

reload() read its pages through TransactionContext.getPage(), which resolves
modifiedPages FIRST. On the rollback path that means it rebuilt the in-RAM
dictionary from exactly the content being discarded: after a rolled back
updateName, the new name still resolved to the renamed id until something else
reloaded. Reading through the page manager instead makes both halves of the
method agree on durable state, matching the pageCount choice already made for
the page count. Every caller wants committed content: load, rollback, and the
two post-commit replication paths.

The defect predates multi-page support, since updateName always modified page 0
inside the caller's transaction. Confirmed the test pins the fix by reverting
the read and watching it fail.

Only updateName can reach it: it runs inside the caller's transaction and both
modifies existing pages and adds new ones, so a dictionary page lands in
modifiedPages, which is what arms the reload on rollback. An append through
getIdByName only ever adds a page and never modifies one, so it never arms it.

Also state in the class javadoc which strings can enter the dictionary. Only
identifiers are ever added, the create=true callers: type names and property
names. A string value is only ever looked up with create=false and stored as a
reference when it already matches an entry, so user data never enters and the
per-name size limit only ever applies to identifiers. The old wording,
"enumerated string value", read as though user data could land here and made the
smaller page size look like a limit on values.
@lvca

lvca commented Jul 29, 2026

Copy link
Copy Markdown
Member Author

Round 2. Point 4 turned out to be the valuable one: writing the test found a real defect, and it is not one this PR introduced.

4. No test for a local transaction crossing a page boundary then rolling back — agreed, and worth spelling out why the path is narrower than it looks. addItemToPage only ever adds a page, never modifies one in the same transaction, and TransactionContext.rollback() only arms the dictionary reload when a dictionary page is in modifiedPages. So an append-driven rollover can never reach it. The single caller that both modifies existing pages and adds one inside the caller's transaction is updateName. That is what the new test drives.

And it failed. reload() read its pages through TransactionContext.getPage(), which resolves modifiedPages first — so on the rollback path it rebuilt the dictionary from precisely the content being discarded. After a rolled-back updateName, the new name still resolved to the renamed id until something else happened to reload. The pageCount.get() half of the method spoke durable state while the page-read half spoke transaction state.

Fixed by reading through the page manager, so both halves agree. Every caller wants committed content: load, rollback, and the two post-commit replication paths. This defect predates multi-page supportupdateName always modified page 0 inside the caller's transaction, so the same rollback would have rebuilt from dirty page 0. I verified the test pins the fix by reverting the read and watching it fail (expected: -1 but was: 1), then restoring it.

1. "enumerated string value" reads like user data — agreed, and I fixed it in the class javadoc rather than only in the PR body. The PR body is read once; the javadoc is where the question comes back. It now says explicitly that only identifiers are ever added (the create=true callers: type names and property names), that a string value is only ever looked up with create=false and stored as a reference when it already matches, and therefore that the per-name limit only applies to identifiers. To restate it here as asked: the smaller max entry size can never apply to user data.

2. New single-page v1 files are not refused by old versions — correct, and intended. A fresh database stays readable by an older ArcadeDB for as long as it fits page 0, and only becomes unreadable once it actually uses the new capability. Downgradability is lost exactly when the database starts depending on the feature, not a moment earlier. No code change; flagging it here so the note is not misread.

3. Redundant checkNameFitsAPage in the loop — keeping it, as agreed.

Local: 624 tests across com.arcadedb.schema, com.arcadedb.serializer, com.arcadedb.database, com.arcadedb.engine. 0 failures, 0 errors. DictionaryMultiPageTest is now 10 tests.

@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review: multi-page schema dictionary (PR #5560)

Reviewed the full diff and traced the surrounding code paths (Dictionary, PaginatedComponent.getTotalPages/pageCount, TransactionContext.rollback, TransactionManager.applyChanges, LocalDatabase.transaction). This is a careful, well-reasoned change, and the PR description and commit messages are exemplary: they call out exactly the three spots that needed the most scrutiny, and each one holds up.

What I verified holds

  • Append-only invariant. Names only ever go to the last page; earlier pages are never backfilled. Since the id is the ordinal in page order and that id lives inside records on disk, this is the correct (and only safe) choice: backfilling a gap would silently renumber. Good.
  • Backward compatibility. Every page (page 0 included) still carries the 4-byte legacy counter, and reload() walks Math.max(1, pageCount.get()) pages, so a pre-existing single-page dictionary reads byte-for-byte. pageZeroKeepsTheLegacySinglePageLayout pins this well.
  • addItemToPage reads before enlisting. Using getPage() to test free space and only calling getPageToModify() on the fit branch avoids bumping the tail page version (and false-conflicting) when a name does not fit. Correct and worth the care.
  • The patch-3 fix (read committed state in reload()). Switching from TransactionContext.getPage() (which resolves modifiedPages first) to PageManager.getImmutablePage() is the right call: on the rollback path the old code would have rebuilt the in-RAM view from the very content being discarded. I confirmed the rollback reload only arms off modifiedPages, that updateName always rewrites page 0 (so it is always enlisted as modified), and that getIdByName commits in its own joinCurrentTx=false transaction and so never depends on the caller rollback. The reasoning in the commit message is accurate.
  • Follower path. applyChanges calls component.updatePageCount(pageNumber+1) before the post-commit reload(), so pageCount reflects the new page and the walk picks it up. aReplicatedNewDictionaryPageIsVisibleAfterApplyChanges exercises this directly.
  • checkNameFitsAPage arithmetic. usable = pageSize - PAGE_HEADER_SIZE - DICTIONARY_HEADER_SIZE exactly equals freeSpaceIn() of a freshly reset page, so the up-front check and the per-write check agree: no off-by-one that would let writeString throw a raw page-boundary error.
  • No remaining callers of the removed getAvailableSpace / getAvailableSpaceInLastPage.

Points worth a look (none blocking)

  1. Max single-identifier length shrinks 5x for new databases. DEF_PAGE_SIZE drops 327,680 -> 65,536, so on a new db a single name now caps at ~65,524 bytes instead of ~327,668. Existing dbs keep their page size, so this only affects new ones, and identifiers that long are unrealistic, but it is a real behavior change and is not called out in the PR text. A one-line note in the class Javadoc (max identifier length on a new dictionary is pageSize - 12 bytes) would help future readers.

  2. HA rolling-upgrade ordering. The documented downgrade hazard applies to replication too: if a new-version leader creates a v1 dictionary and ships a page >= 1 to a follower still running the old code, that follower reads page 0 only and fails with "Dictionary item with id N is not valid". Worth an explicit note in the release/upgrade docs that followers must be upgraded before (or with) the leader, since operators will not infer it from the file-format note alone.

  3. DictionaryLimitsTest.fillingWellPastOnePageKeepsWorking is not tagged. It inserts 20,000 names, each in its own inner transaction. Per the CLAUDE.md guidance, if this runs more than a second or two on CI it may deserve @Tag("slow"). The prior version (100k) was not tagged either, so this is only a suggestion, worth a quick timing check.

  4. Minor consistency nit. reload() reads with getImmutablePage(..., createIfNotExists=true). Given the Math.max(1, pageCount.get()) floor, when pageCount == 0 on a torn/partial file this materializes page 0. That preserves the previous always-read-page-0 behavior, so it is intentional: just flagging that createIfNotExists=true is load-bearing here and a short comment saying so would not hurt.

Test coverage

Strong. Rollover, reopen persistence, legacy page-0 layout, oversized-name rejection leaving the dictionary usable, the serializer round-trip across pages, updateName shrink and grow branches, the newer-format version guard through the real load path, the follower apply path, and the rolled-back-growth case that motivated patch 3. Assertions use the preferred assertThat(...) style. Nicely done.

Overall: I did not find a correctness or data-integrity defect. The design is sound, the append-only guarantee is preserved end-to-end, and the durability/rollback edge cases the change touches are handled and tested. The notes above are documentation/operational polish, not blockers.

Note: I was unable to run the build in this sandbox; this review is from code analysis. The author reports a clean local run (920 engine test classes, 0 failures).

…ade order

Two behaviour changes were only visible by reading the diff.

The smaller page size caps a single identifier at pageSize - 12 bytes, so ~65Kb
on a new dictionary against ~327Kb on one created before. Only new databases see
it, and only identifiers are subject to it since string values are never
inserted, but the class javadoc now says so instead of leaving it to be derived.

More important operationally: dictionary pages replicate as raw pages, so a
leader that has rolled the dictionary over ships page 1 and beyond to its
followers. A follower still running a build without multi-page support writes
the page but reloads only page 0, so every record referencing a name past the
first page fails with "Dictionary item with id N is not valid". Followers have
to be upgraded before, or together with, the leader. Recorded in the class
javadoc and in docs/5560-dictionary-multipage.md, which also collects the
design, the page-size and version decisions, and the single-node and downgrade
notes.

Also note that createIfNotExists=true in the reload() page read is load bearing
rather than a default: paired with the Math.max(1, pageCount) floor it
materialises page 0 of a file shorter than one page, which is what the
single-page reader did.

Not tagging DictionaryLimitsTest.fillingWellPastOnePageKeepsWorking as slow: it
measures 0.3s, and every DictionaryMultiPageTest case is at or under 0.088s, so
tagging would drop real coverage from any run that skips slow tests for no gain.
@lvca

lvca commented Jul 29, 2026

Copy link
Copy Markdown
Member Author

Round 3. All four applied, one of them by measuring rather than doing what was suggested.

1. Identifier cap shrinks 5x on new databases — agreed, and it belongs in the class javadoc rather than only the PR text, which is read once. It now states the cap as pageSize - PAGE_HEADER_SIZE - DICTIONARY_HEADER_SIZE: ~65Kb on a new dictionary against ~327Kb on one created before, existing databases unaffected because they keep their page size. Also noted there that only identifiers are subject to it, since string values are only ever looked up with create=false.

2. HA rolling-upgrade ordering — the most valuable point of this round, and I agree it would not be inferred from the file-format note. Recorded in two places: the class javadoc, next to the code that causes it, and docs/5560-dictionary-multipage.md, following the repo's existing docs/NNNN-*.md convention. The mechanism spelled out: dictionary pages replicate as raw pages through applyChanges, so a rolled-over leader ships page 1+ to followers; an old follower writes the page but reloads only page 0, and every record referencing a name past the first page fails. Followers must be upgraded before, or together with, the leader. The doc also collects the single-node and downgrade notes so the release manager has one place to draw from.

I considered whether this could be prevented rather than documented — a leader refusing to roll over while any follower is on an older build. That needs cluster-wide version negotiation and would couple the engine to ha-raft for a hazard that a correct upgrade order removes entirely. Documenting it is the right level.

3. fillingWellPastOnePageKeepsWorking might deserve @Tag("slow") — you suggested a timing check, so I ran one instead of guessing. It measures 0.3s, and every DictionaryMultiPageTest case is at or under 0.088s. CLAUDE.md reserves the tag for "noticeably long, multi-second" tests, so not tagging it: at 0.3s the tag would drop real coverage from every run that skips slow tests, in exchange for nothing.

4. createIfNotExists=true is load-bearing — correct, and a fair thing to trip over. Commented at the call site: paired with the Math.max(1, pageCount.get()) floor it materialises page 0 of a file shorter than one page (killed mid-write), which is what the single-page reader did.

Local: 19 dictionary tests, 0 failures. Previous round's wider run was 624 tests across com.arcadedb.schema, com.arcadedb.serializer, com.arcadedb.database, com.arcadedb.engine, also clean; this round changed only javadoc, one comment and a new doc file.

@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review: feat(engine): let the schema dictionary grow past a single page

Thorough, well-reasoned change. The PR description and the class Javadoc do a lot of the reviewer work, and the invariants they state hold up against the code. I traced the append path, the updateName rewrite, reload(), and the base-page size accounting, and the design is sound.

What is done well

  • Backward compatibility is real, not asserted. Every page (page 0 included) carries the same 4-byte legacy counter, so a pre-multi-page file is literally a one-page dictionary and needs no special case. pageZeroKeepsTheLegacySinglePageLayout pins this by re-reading page 0 with the old single-page algorithm.
  • The append-only-into-the-last-page invariant is the right call and correctly justified: filling a gap would renumber ids that are physically embedded in records. The size accounting is self-consistent: checkNameFitsAPage uses pageSize - PAGE_HEADER_SIZE - DICTIONARY_HEADER_SIZE, which is exactly the free space of a freshly reset page, so any name that passes the check is guaranteed to fit an empty page.
  • Read-before-modify in addItemToPage (getPage then conditionally getPageToModify) is a genuinely good detail: it avoids bumping the tail page version and false-conflicting concurrent transactions when the name does not fit.
  • reload() walking committed pageCount (floored at 1) rather than getTotalPages() is subtle and correct: a rolling-back transaction still holds its own page counter, and reading it would rebuild from content being discarded. aRolledBackTransactionThatGrewTheDictionaryLeavesItIntact exercises exactly this, and updateName is correctly identified as the only path that reaches it.
  • Test coverage is strong: rollover, reopen persistence, oversized-name rejection leaving the dictionary usable, the serializer round-trip across pages, updateName shrink and grow branches, the version guard via the load path, and the follower/replication applyChanges path. I confirmed applyChanges reloads the dictionary when a dictionary page is involved (TransactionManager.java:689), so that test is meaningful.
  • The CURRENT_VERSION guard refusing a newer-format file instead of silently misreading it is the right defensive posture, and the downgrade behaviour is loud rather than silent.

Minor points (non-blocking)

  1. getAvailableSpaceInLastPage() appears to have no callers (the old getAvailableSpace did not either). If it is not part of an intended public API surface, consider dropping it. Note it reads page getTotalPages() - 1 and assumes an active transaction plus at least one page, so it would want a guard if ever called on the empty-file path.
  2. updateName rewrite over a CopyOnWriteArrayList. The while (indexOf(oldName) != -1) { set(...) } loop copies the whole backing array on every set, and indexOf is O(n). For unique names this is one copy; for a name stored many times (enum values) it is O(n) copies. updateName is rare, so this is acceptable, but a heads-up.
  3. Test timing / tagging. DictionaryLimitsTest.fillingWellPastOnePageKeepsWorking performs 20,000 getIdByName(create=true) calls, each committing its own nested transaction (20k WAL commits). Per CLAUDE.md, functional regression tests that take noticeably long should carry @Tag("slow"). Worth checking the wall-clock on a 2-vCPU CI runner and tagging if it is multi-second.
  4. Concurrency corner (informational, not actionable). In getIdByName, newPos is captured as entries.names().size() before the commit, while a concurrent replication reload() (which does not hold this monitor) could swap the snapshot. The current.names().size() != newPos + 1 guard catches the resulting inconsistency and reloads/throws rather than corrupting, which is the correct fail-safe. Extremely rare and pre-existing in shape; noting it survived the multi-page change intact.

Verification
I could not run the module build in this environment (network-restricted), so I relied on static analysis; the author reports a full local engine run (920 classes, 0 failures). A green CI run on the engine and ha-raft modules is the thing to confirm before merge.

Overall: a careful, well-documented change with matching tests. The minor points above are polish, not blockers.

…t it proves

updateName found each occurrence with indexOf() and rewrote it until none were
left, re-scanning the whole list per occurrence. Scan by index instead: one pass
instead of one per occurrence, and the loop can no longer fail to terminate when
oldName equals newName, which previously depended on the two names differing.
The early return for that case stays, but it is now a fast path rather than the
only thing standing between the loop and spinning forever.

fillingWellPastOnePageKeepsWorking inserted 20,000 names, each committing its
own nested transaction. The point of the test is that the dictionary keeps
working well past one page, and 3,000 padded names cross four of them, so the
extra 17,000 WAL commits proved nothing. 0.3s to 0.068s, which keeps it well
under a second even on a slow CI runner and leaves it out of the slow tag, where
it would have cost real coverage on every run that skips those.
@lvca

lvca commented Jul 29, 2026

Copy link
Copy Markdown
Member Author

Round 4. Two applied, one stale, one agreed as non-actionable.

1. getAvailableSpaceInLastPage() has no callers — already gone, deleted in 249bd99bd back in round 1 for exactly this reason. grep over engine/, ha-raft/ and server/ returns only LocalBucket.getAvailableSpaceInPage, which is unrelated. Nothing to do; flagging that this point was reviewing a stale view of the branch.

2. updateName rewrite over a CopyOnWriteArrayList — you called it acceptable with a heads-up; I think it is worth actually fixing, because the cost is not the interesting part. The old shape was while (indexOf(oldName) != -1) { set(...) }, which re-scans the whole list per occurrence and only terminates because the two names differ — with oldName.equals(newName) every scan finds what the previous set just wrote, which is the infinite loop fixed by guard earlier in this PR. Scanning by index instead is one pass rather than one per occurrence, and makes non-termination impossible by construction. The early return stays, but as a fast path rather than as the only thing preventing a spin. Better code, not just fewer array copies.

3. Test timing on a slow runner — fair push-back on my local 0.3s measurement; my machine is not a 2-vCPU runner. But between the two options I think there is a third that beats both. The test exists to show the dictionary keeps working well past one page, and 3,000 padded names cross four pages just as convincingly as 20,000 do — the other 17,000 bought nothing but WAL commits. Resized: 0.3s to 0.068s, a 4.4x cut with the coverage intact, and I strengthened the assertion from getTotalPages() > 1 to >= 4 so the fixture size is now pinned by what it proves. Even 10x slower on a constrained runner that is ~0.7s, so it stays out of @Tag("slow") — where it would have cost real coverage on every run that skips slow tests.

4. Concurrency corner in getIdByName — agreed on all counts, and noted as informational. newPos is captured before the commit while a replication reload() could swap the snapshot; the current.names().size() != newPos + 1 guard reloads and throws rather than corrupting. Fail-safe, pre-existing in shape, and it survives the multi-page change because the ordinal is still the ordinal. No change.

On verification: agreed that green CI on engine and ha-raft is the gate, since neither of us can run the build in the review sandbox. Local this round: 624 tests across com.arcadedb.schema, com.arcadedb.serializer, com.arcadedb.database, com.arcadedb.engine, 0 failures, 0 errors.

@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review: multi-page schema dictionary

Strong, carefully reasoned change. The append-only invariant ("an id is the ordinal of the name in page order"), the backward-compatible page-0 layout, the pageCount-not-getTotalPages() and page-manager-not-TransactionContext.getPage() choices in reload(), and the read-before-modify in addItemToPage to avoid false page conflicts are all correct and well documented. The commit messages, the class Javadoc, and docs/5560-dictionary-multipage.md do an excellent job of recording why. Test coverage is broad (rollover, reopen, legacy page-0 read, oversized name, serializer round trip, updateName shrink/grow, rolled-back growth, version guard, follower apply path).

A few observations:

1. updateName: two validations still run after the in-RAM view is mutated (robustness)

The PR nicely moved the newName size check to before any mutation. But two remaining validations still fire after entries has already been mutated:

dictionaryMap.remove(oldName);                 // in-RAM mutation
for (int i = 0; i < dictionary.size(); ++i)
  if (oldName.equals(dictionary.get(i))) {
    oldIndexes.add(i);
    dictionary.set(i, newName);                // in-RAM mutation
  }
if (oldIndexes.isEmpty()) throw new IllegalArgumentException(...);   // not caught
for (final DocumentType t : ...) if (oldName.equals(t.getName()))
  throw new IllegalArgumentException("...used as a type name");      // not caught

For the type-name path this leaves the in-RAM dictionary inconsistent with no repair:

  • oldName was present, so the loop already ran dictionary.set(i, newName) and dictionaryMap.remove(oldName).
  • The IllegalArgumentException is not an IOException, so updateName's own catch (which calls reload()) does not fire.
  • No dictionary page was modified before the throw (the page rewrite comes later), so TransactionContext.rollback() - which only reloads the dictionary when modifiedPages contains a dictionary page - does not repair it either.

Result: after a failed rename of a property whose name also matches a type name, the in-RAM map is missing both oldName and newName while the list already shows newName, until some unrelated reload/reopen. This is pre-existing (the ordering is unchanged from before), but since this PR is specifically hardening updateName with validate-before-mutate, it would be natural to finish the job: compute oldIndexes read-only, run the empty/type-name checks, and only then mutate dictionaryMap/dictionary. A small regression test (rename a property whose name equals a type name, then assert both directions still resolve) would pin it.

2. Mixed-version cluster / downgrade (operational, already documented)

The silent-data-loss window on a follower that lacks multi-page support is the highest-impact real-world risk here, and it's handled the right way: the follower fails loudly with "Dictionary item with id N is not valid", and both the class Javadoc and the docs call out "upgrade followers before, or together with, the leader." Worth surfacing in the release notes / upgrade guide as well, since it's easy to miss operationally.

3. Minor

  • DEF_PAGE_SIZE dropping to 65,536 caps a single identifier at ~65Kb on new databases (vs ~327Kb before). Correctly documented, and no realistic type/property name approaches it - noting only for completeness.
  • No concurrency test exercises two threads racing getIdByName across a rollover boundary. The synchronized block plus the immutable Entries snapshot look correct, but a contention test would add confidence given the class explicitly reasons about rollback/replication threads not holding the monitor.

Overall this is a high-quality change: correct core logic, honest about its tradeoffs, and backward compatible by construction. The only actionable item is #1, and it's a robustness cleanup rather than a blocker.

…nything

Moving the newName size check ahead of the mutation left two validations behind
it. The type-name check is the one that matters: by the time it fires, the list
has already been renamed and the map has already lost oldName. It throws
IllegalArgumentException, which the local catch does not handle since that only
catches IOException, and no dictionary page has been written yet, so
TransactionContext.rollback() does not arm its repairing reload either. The
in-RAM view was left renamed in the list and missing from the map until some
unrelated reload came along.

All four preconditions now run first, then a read-only scan, then the mutation.
The type-name check moved above the scan rather than just above the mutation: it
depends only on oldName, so a rename refused for that reason no longer walks the
dictionary at all. After the scan the only way out is an IOException, which is
exactly what the catch repairs.

Tests: renaming a name that is also a type name now asserts both directions of
the mapping still agree afterwards, with no reload to repair them. Verified it
fails before the fix.

Also a contention test, which nothing covered: four threads appending distinct
names across a rollover, asserting no two ids collide, every id round-trips, and
the ids handed out under contention still match the order the names landed in on
the pages after a reload. That last assertion is the one that would catch two
threads computing the tail page at once.
@lvca

lvca commented Jul 29, 2026

Copy link
Copy Markdown
Member Author

Round 5. Point 1 was right, and I took it further than proposed. Point 3's second half was also worth acting on.

1. Two validations still ran after the in-RAM mutation — confirmed exactly as you traced it, including the part that makes it bite: IllegalArgumentException is not an IOException so the local repairing catch does not fire, and no dictionary page has been written yet so rollback() does not arm its reload. Nothing repairs it.

You proposed computing oldIndexes read-only, then running the empty/type-name checks, then mutating. I went one step further: the type-name check moved above the scan, not just above the mutation. It depends only on oldName, so a rename refused for that reason now walks nothing at all. Final order is all four preconditions, then a read-only scan, then the isEmpty check, then the mutation — after which the only way out is an IOException, which is precisely what the catch repairs. That makes the comment "validation is over" literally true at that point rather than approximately.

Regression test added as suggested, and I verified it fails against the old ordering: expected: 131 — the id had vanished from the map while the list already showed the new name.

3, second half: no contention test — agreed, and this was a real gap, since the class's whole safety argument is "appends serialise on the monitor while reload() runs on threads that do not hold it" and nothing exercised it. Added: four threads appending 250 distinct names each across a rollover, asserting no two ids collide, that the ids cover exactly the expected range, that every id round-trips, and — the assertion that actually matters — that after a reload() the ids handed out under contention still match the order the names landed in on the pages. That is what would catch two threads computing the tail page at once, which is the specific way concurrency could renumber entries here.

2. Release notes / upgrade guide — the mechanism and the "upgrade followers before, or together with, the leader" instruction are in docs/5560-dictionary-multipage.md under an explicit Upgrade notes heading, alongside the single-node and downgrade cases, so whoever writes the release notes has one place to draw from. I have deliberately not written a release-notes file itself, as that is not mine to author on this branch.

3, first half: identifier cap — noted for completeness and already documented in the class javadoc. No change.

Local: 626 tests across com.arcadedb.schema, com.arcadedb.serializer, com.arcadedb.database, com.arcadedb.engine. 0 failures, 0 errors. DictionaryMultiPageTest is now 12 tests.

@codacy-production

codacy-production Bot commented Jul 29, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 15 complexity

Metric Results
Complexity 15

View in Codacy

🟢 Coverage 94.74% diff coverage

Metric Results
Coverage variation Report missing for d463a311
Diff coverage 94.74% diff coverage

View coverage diff in Codacy

Coverage variation details
Coverable lines Covered lines Coverage
Common ancestor commit (d463a31) Report Missing Report Missing Report Missing
Head commit (706d86d) 178685 121303 67.89%

Coverage variation is the difference between the coverage for the head and common ancestor commits of the pull request branch: <coverage of head commit> - <coverage of common ancestor commit>

Diff coverage details
Coverable lines Covered lines Diff coverage
Pull request (#5560) 95 90 94.74%

Diff coverage is the percentage of lines that are covered by tests out of the coverable lines that the pull request added or modified: <covered lines added or modified>/<coverable lines added or modified> * 100%

1 Codacy didn't receive coverage data for the commit, or there was an error processing the received data. Check your integration for errors and validate that your coverage setup is correct.

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review: feat(engine): let the schema dictionary grow past a single page

Reviewed the full 6-commit diff and the surrounding engine code (Dictionary, PaginatedComponent, TransactionContext, PageManager, BasePage). This is a high-quality change: the design is coherent, the invariants are stated where they matter, backward compatibility is genuinely preserved, and the test suite is unusually thorough (concurrency, follower/replication apply, rollback repair, version guard, legacy page-0 layout, shrink/grow rename). The commit history reads as a real review loop that surfaced and fixed a preexisting latent defect. Nice work.

Things I verified and agree with

  • id = ordinal in page order holds across a shrink-then-append. After updateName shrinks the content onto fewer pages, the emptied tail pages remain counted. The next addItemToPage appends to the (empty) last page, and its id is entries.names().size(). Because emptied intermediate pages contribute zero names, reload() reading pages in order still yields the same ordinal, so id and page order stay consistent. No corruption there.
  • Space math is exact. checkNameFitsAPage usable (pageSize - PAGE_HEADER_SIZE - DICTIONARY_HEADER_SIZE = pageSize - 12) equals freeSpaceIn() of a fresh page (getMaxContentSize() - contentSize = (pageSize-8) - 4). A name that passes the check is guaranteed to fit an empty page, so the rollover never wedges.
  • The reload() fix (PageManager + pageCount) is the right call. Reading committed durable state on both the count and the page content is what makes the rollback path stop rebuilding from discarded content. The Math.max(1, ...) floor plus createIfNotExists=true correctly reproduces the old single-page read for a file killed mid-write.
  • The read-before-getPageToModify in addItemToPage genuinely avoids the false-conflict / needless version bump described (page is the conflict unit per engine/CLAUDE.md).

Considerations (none blocking)

  1. updateName now rewrites every page in one transaction. Previously it only touched page 0; on a large dictionary a rename now dirties every dictionary page and grows the WAL commit proportionally. Renames are rare and (see below) not internally wired, so this is acceptable, but worth a one-line note in the method Javadoc since the cost is no longer bounded by one page.
  2. updateName is a public API with no production caller (confirmed: the only updateName reference in engine/server main is the declaration itself). Unlike getIdByName's create path, it does not run under synchronized(this). Today that is fine because nothing invokes it concurrently with inserts. If a live property/type rename path is ever wired to it, the page rewrite could race a concurrent getIdByName append (both mutate the shared entries and dictionary pages). A short "not synchronized; assumes an exclusive schema lock" note on the method would save a future reader from discovering this the hard way.
  3. Cluster downgrade hazard is real but only documented. The docs and class Javadoc explain the "upgrade followers first" requirement well. Consider emitting a one-time WARNING log the first time a dictionary rolls over past page 0 (i.e. when addPage(1) first runs), so an operator gets an in-log breadcrumb correlating a later follower "Dictionary item with id N is not valid" with the rollover event. Optional, but cheap operational insurance.
  4. Emptied-not-truncated pages after a shrink are never reclaimed. The commit message explains why truncation is risky against the follower page-count path, and given updateName has no production caller the wasted pages are effectively unreachable. Agreed this is the right tradeoff to defer.

Test coverage

Excellent. The aRolledBackTransactionThatGrewTheDictionaryLeavesItIntact case (which exposed the dirty-page reload defect), the follower applyChanges path, the version guard exercised through the real load path, and the 4-thread contention test that asserts ids match on-page order after reload together cover the paths that actually carry the correctness. One note: I was not able to run mvn in this review environment to independently confirm green, so I am relying on the author's stated local run (920 engine classes, 0 failures).

Overall: LGTM once the two small Javadoc notes (items 1 and 2) are considered. The operational WARNING (item 3) is a nice-to-have.

…page 0

updateName is public, has no production caller, and unlike the getIdByName
create path did not hold this component's monitor. Documenting that would have
left the next reader to discover the consequence the hard way, so serialize it
instead: both methods mutate the same entries snapshot, and an append racing the
rename would read a half-renamed list. That is the part with no other protection.
It deliberately does not make the two atomic on disk, because this method writes
inside the caller's transaction and does not commit, so an append committing in
between is resolved the usual way by the page version check. The javadoc says
exactly that, and that a live rename should also hold a schema-level lock.

Also note in the javadoc that the rewrite now dirties every dictionary page in
the caller's transaction, so the WAL commit grows with the dictionary rather than
being bounded by the single page it used to touch.

Log once when page 1 is created, which is the one moment a database stops being
readable by a build without multi-page support. It gives an operator a breadcrumb
to correlate with a follower later reporting "Dictionary item with id N is not
valid". At INFO rather than WARNING: nothing is wrong at that moment, and
warnings on healthy growth are how logs stop being read.
@lvca

lvca commented Jul 29, 2026

Copy link
Copy Markdown
Member Author

Round 6. Both javadoc items addressed, one of them with code rather than only a note, and the optional item taken with one adjustment.

1. updateName now dirties every page, so the WAL commit is no longer bounded by one page — correct, and now stated in the method javadoc: the rewrite re-lays the dictionary out from page 0 into the caller's transaction, so the commit grows with the dictionary rather than with the single name that changed.

2. updateName is public, unsynchronized, no production caller — you suggested a "not synchronized; assumes an exclusive schema lock" note. I think that is the weaker fix: a note tells the next reader about the hazard, it does not remove it, and you correctly identified that the hazard is a concurrent getIdByName append mutating the same entries snapshot. So updateName is now synchronized, on the same monitor as the create path. One keyword on a method with no production caller and no contention, and the in-RAM race is gone by construction.

I was deliberate about what that does and does not buy, and the javadoc now says so: it serialises the in-RAM edit, which is the part with no other protection — an append would otherwise observe a half-renamed list. It does not make the two atomic on disk, because updateName writes inside the caller's transaction and does not commit, so an append committing in between is resolved the normal way, by the page version check raising ConcurrentModificationException on whichever commits second. That is the correct outcome, not a gap. The note about a live rename also needing a schema-level lock is there too.

I checked for deadlock before adding it: updateName acquires no other lock while holding the monitor, and the caller's commit happens after it returns, so there is no cycle with getIdByName, which takes the monitor and then begins its nested transaction.

3. One-time log when the dictionary rolls over — taking it, with one change: INFO, not WARNING. Nothing is wrong at that moment; rolling over is the healthy behaviour this PR exists to enable, and a warning on normal growth is how logs stop being read. The breadcrumb value you are after is fully preserved at INFO. Triggered on the creation of page 1 specifically, which is once per database lifetime and is exactly the moment downgradability is lost — pages 2 and beyond change nothing about that status, so logging them would be noise. The message names the database and states the consequence and the remedy.

4. Emptied-not-truncated pages — agreed on deferring, for the reasons in the earlier commit message.

Local: 626 tests across com.arcadedb.schema, com.arcadedb.serializer, com.arcadedb.database, com.arcadedb.engine. 0 failures, 0 errors.

@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review: feat(engine): let the schema dictionary grow past a single page

I read through Dictionary.java, the two dictionary tests, and traced the surrounding paths (TransactionContext.rollback(), PaginatedComponent.getTotalPages() vs pageCount, LocalDatabase.transaction(..., joinCurrentTx), and the BinarySerializer callers). Overall this is a carefully reasoned, exceptionally well-documented change. The core invariant - an id is the ordinal of a name in page order, so the layout must stay strictly append-only across pages - is sound, and the code and comments consistently protect it. No correctness bugs found.

WHAT I VERIFIED AND AGREE WITH

  • Backward compatibility. Every page (page 0 included) carries the same 4-byte legacy counter, so a pre-existing single-page dictionary is byte-for-byte a one-page instance of the new format. pageZeroKeepsTheLegacySinglePageLayout pins this with the old reader algorithm. No migration needed.
  • reload() reads committed state. Confirmed getTotalPages() is transaction-aware (returns tx.getPageCounter(fileId) when a tx is live) while pageCount only advances on commit (PaginatedComponent:134-141). Using Math.max(1, pageCount.get()) + getImmutablePage(...) (not tx.getPage()) is exactly right for the rollback caller at TransactionContext:372, which fires while modifiedPages is still populated. aRolledBackTransactionThatGrewTheDictionaryLeavesItIntact genuinely exercises this - nice observation that only updateName (modifies pages) can reach it, whereas the append path (only ever addPage) mutates in-RAM strictly after commit and so never needs the repair.
  • addItemToPage reads the tail page before deciding. Using getPage() first and only escalating to getPageToModify() when the name fits avoids a false version bump / false-conflict on a full tail page. Matches the "the conflict unit is the PAGE" rule in engine/CLAUDE.md.
  • updateName re-lays out and empties stale tail pages. The stale-page sweep is necessary given reload() walks every committed page; without it a shrunk rename would re-add stale names on next load. updateNameGrowingBeyondTheExistingPagesAddsOne + updateNameRewritesEveryPage cover both branches.
  • Validate-before-mutate ordering in updateName is correct: the pre-mutation checks throw IllegalArgumentException/DatabaseMetadataException, which the catch (IOException) does not handle and which leave no dirtied dictionary page, so rollback reload() is not armed for a half-applied rename.
  • Boundary math is consistent. checkNameFitsAPage usable = pageSize - PAGE_HEADER_SIZE - DICTIONARY_HEADER_SIZE, and freeSpaceIn(emptyPage) collapses to the same value, so the "fits an empty page" check and the actual write agree at the exact boundary.
  • No API break. The removed public getAvailableSpace() had no callers outside the test that was updated.
  • Nice side improvements: the SchemaException throws now chain the underlying cause, and the reload() one-shot ArrayList to CopyOnWriteArrayList build avoids the documented O(n^2) COW append cost.

MINOR POINTS (non-blocking)

  1. Format version does not track multi-page usage. CURRENT_VERSION is stamped at creation, so an existing v0 database that later rolls over keeps v0 on disk. The version guard protects against a newer writer but cannot detect "this v0 file now needs multi-page support" - the addPage(1) INFO breadcrumb is the only signal. Intentional and clearly documented (downgrade failure is loud), just worth confirming this is preferred over bumping the on-disk version on first rollover.
  2. concurrentAppendsAcrossARolloverKeepEveryIdUnique does 4x250 appends, each its own nested-tx WAL commit, plus a reload() (~1000 commits). On a slow 2-vCPU CI runner it may run long enough to warrant @tag("slow") per the CLAUDE.md guidance. Low priority - flagging since the sibling DictionaryLimitsTest explicitly right-sized its fixture for CI.
  3. updateName WAL cost scales with dictionary size, not with the one renamed name (re-lays out from page 0, dirtying every page in the caller tx). Well documented; no production caller today, so fine - just a note for whoever eventually wires a live rename that it should also hold a schema-level lock, as the javadoc advises.

TESTS

Coverage is excellent and the test names read as a spec: rollover, id stability across reopen, legacy page-0 read, oversized-name rejection, serializer round-trip across pages, both updateName branches, rolled-back growth, the version guard through the real load path, concurrent appends across a rollover, and the follower applyChanges path. I could not execute mvn in this sandbox (network-restricted), so I relied on the author stated local run (920 engine test classes, 0 failures) plus the static trace above.

Nice work - the invariant-first framing and the "why" comments make this unusually easy to review.

…t rollover

The format version is stamped at creation, so a v0 database that later grows past
page 0 keeps saying v0. That reads like an oversight, so write down that it is
not.

Bumping it on first rollover was considered and rejected. The version is part of
the file name, so changing it at runtime means renaming a live component file,
which PaginatedComponent.rename can only do behind a full flush barrier and which
would race replication shipping pages by file id, leaving follower file names
diverged from the leader's. And it would protect nothing: ComponentFactory passes
the parsed version through without validating it, so a build old enough to be at
risk opens a v1 file exactly as happily as a v0 one. The guard added here only
helps a newer format meeting this reader.

The version therefore records what wrote the file, and the operational signal
that a database has left single-page territory stays the INFO line logged when
page 1 is created.
@lvca

lvca commented Jul 29, 2026

Copy link
Copy Markdown
Member Author

Round 7. No code changes needed: one point was a question, one is settled by measurement, one was already done. I have documented the answer to the first so it does not have to be re-derived.

1. "Confirm not bumping the on-disk version on first rollover is preferred" — confirmed, and I have written down why rather than leaving it as "intentional", because it does read like an oversight. Two reasons, and the second is the decisive one:

  • The version is part of the file name (dictionary.0.65536.v1.dict). Changing it at runtime means renaming a live component file. PaginatedComponent.rename can only do that behind a full flush barrier (waitAllPagesOfDatabaseAreFlushed), and doing it from inside an append transaction would race replication — which ships pages by file id — and leave follower file names diverged from the leader's.
  • It would protect nothing. ComponentFactory passes the parsed version straight through without validating it, so a build old enough to be at risk opens a v1 file exactly as happily as a v0 one. The guard added in this PR only helps a newer format meeting this reader; it cannot help an older reader meeting this format. Bumping would be real risk for zero protection.

Recorded at CURRENT_VERSION and in docs/5560-dictionary-multipage.md.

2. concurrentAppendsAcrossARolloverKeepEveryIdUnique may warrant @Tag("slow") — measured rather than estimated: 0.026s, with the whole 12-test class at 0.198s. That matches the ~29µs-per-nested-transaction rate I measured earlier (1000 x 29µs = 29ms), so the estimate of ~1000 commits was right and the cost of a commit is simply much lower than it suggests. Even a 50x slower runner puts this at 1.3s.

Not resizing it either, and here the contrast with the sibling test is the point rather than an inconsistency: DictionaryLimitsTest was cut from 20,000 names to 3,000 because the extra volume proved nothing beyond crossing a page. In the contention test the volume is the mechanism — fewer operations means fewer chances for two threads to interleave at a rollover boundary, so trimming it would weaken exactly what it exists to catch, for 0.02s.

3. updateName WAL cost and the schema-level lock advice — both already in the method javadoc as of the previous round, along with synchronized to close the in-RAM race rather than only warn about it.

Local: 21 dictionary tests, 0 failures. Previous round's wider run was 626 tests across com.arcadedb.schema, com.arcadedb.serializer, com.arcadedb.database and com.arcadedb.engine, also clean; this round touched only a javadoc and a doc file.

That closes out everything raised across the seven review rounds. Remaining known tradeoffs, all deliberate and documented: emptied-not-truncated pages after an updateName shrink, the identifier cap on new databases, and the follower-upgrade ordering requirement.

@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review: feat(engine): let the schema dictionary grow past a single page (#5560)

Reviewed the full 8-commit series against Dictionary.java, PaginatedComponent, PageManager, and the new/changed tests. This is a genuinely high-quality change: the append-only-across-pages invariant is the right model, the backward-compatibility story (every page carries the legacy counter, so a pre-change file is a one-page dictionary and loads with no special case) is clean, and the commit history shows the reasoning was stress-tested rather than asserted. The rollback-reads-committed-state fix (commit 3) and the validate-before-mutate reordering in updateName (commit 6) are real correctness improvements that predate this feature. Nice work.

I could not find a correctness defect. getImmutablePage(pageId, pageSize, false, true) is the right call (isNew=false, createIfNotExists=true), reload() walking Math.max(1, pageCount.get()) through the PageManager keeps both the page count and the page reads on durable state, and checkNameFitsAPage's usable matches freeSpaceIn(emptyPage) exactly, so there's no rollover infinite-loop or off-by-one at the page boundary. I also confirmed the removed getAvailableSpace()/getAvailableSpaceInLastPage() have no remaining callers (the getAvailableSpaceInPage hits in LocalBucket are an unrelated method).

A few non-blocking observations:

1. CopyOnWriteArrayList append is now unbounded (performance). The create path does current.names().add(name) on a CopyOnWriteArrayList, which copies the whole backing array per append (O(n)). The reload() comment already calls this out and works around it by building the list once, but the incremental append path still pays it. Previously the ~48K-entry ceiling bounded the worst case; this PR is precisely what removes that ceiling. A schemaless workload with millions of distinct field names, which is the scenario this feature enables, would now see O(n^2) total cost growing the dictionary. Worth a follow-up thought on whether the name list wants a different structure (a plain ArrayList swapped wholesale under the same volatile Entries pattern would keep O(1) indexed reads and drop the per-append copy). Out of scope here, but this PR is what makes it matter.

2. The "grew beyond first page" INFO log can fire for growth that never commits. addPage logs when pageNumber == 1, but it runs inside a transaction that may roll back: updateName grows within the caller's uncommitted transaction, and your own aRolledBackTransactionThatGrewTheDictionaryLeavesItIntact test rolls back exactly such a growth, so that path logs "this database can no longer be read by an old build" for a database that did not actually roll over. It also fires on any updateName that happens to cross into page 1. Cosmetic, not correctness, but since the whole point of the line is to give operators a reliable breadcrumb to correlate with a follower failure, a spurious one slightly undermines it. Logging on the commit of the first rollover, or at least noting in the message that it reflects an in-progress transaction, would be tighter.

3. Test coverage: the follower path is synthetic. aReplicatedNewDictionaryPageIsVisibleAfterApplyChanges hand-builds a WALPage and calls applyChanges directly, which is a good unit-level check of the reload-walks-all-pages behavior. But the operationally scary scenario in the docs (a rolled-over leader shipping page 1+ to a follower) has no end-to-end HA test that actually replicates across a real page boundary. Given the downgrade/upgrade-order hazard is the sharpest edge of this change, one ha/ha-raft integration test that grows the dictionary past a page on a leader and asserts a follower resolves the names would be worth it. Similarly, there's no fixture of an actual pre-change on-disk file being opened then rolled over; pageZeroKeepsTheLegacySinglePageLayout verifies the format in the reverse direction only.

4. Couldn't run the suite here. Maven execution is blocked in this review environment, so I'm relying on the static read plus your reported "920 engine test classes, 0 failures." The tests read as correct and the assertions are meaningful (both-directions resolution, id stability across reopen, contention id-uniqueness).

Net: I'd merge this. Items 1-3 are follow-ups, not blockers, and the design and documentation quality here are a good model for engine-format changes.

The in-RAM name list was a CopyOnWriteArrayList, which copies its whole backing
array on every append. Growing to N names therefore cost N^2/2 element copies.
That was survivable only because the single page capped the dictionary at ~48K
entries, and removing that cap is exactly what this branch does, so the append
had to stop being O(n) with it.

Measured, appending N names:

  N        CopyOnWriteArrayList   doubling array
  50,000              232 ms             0 ms
  200,000           1,927 ms             0 ms
  500,000          11,179 ms             2 ms

4x the names costs 8.3x the time and 2.5x costs 5.8x, which is the quadratic
term showing through. A schemaless workload with millions of distinct field
names, the case this feature enables, would have spent minutes there.

Entries now holds a String[] with spare capacity plus the live size, grown by
doubling. An append writes names[size] and then publishes a new Entries with
size + 1: sharing the array with older readers is safe because they hold the
smaller size and never look at that slot, and a reader that sees the new snapshot
sees the slot through the volatile write. Reads stay one volatile read plus an
array index, as they were.

Swapping a plain ArrayList wholesale under the same volatile field would not have
helped: republishing a copy per append is the same O(n) copy the COW list was
already doing. The spare capacity is what removes it.

updateName renames onto a copy instead of in place. An append only writes slots
past the published size so it can share its array; a rename rewrites a slot
readers are already looking at, which needs a happens-before edge they do not
have. One copy per rename is nothing next to the full page rewrite it precedes.

Also fixes the rollover breadcrumb, which fired from inside a transaction that
could still roll back: updateName grows inside the caller's transaction, and the
rolled-back-growth test rolled back exactly such a growth, so the log announced a
rollover that never happened. It now registers an after-commit callback, keyed so
several pages in one transaction still log once, and discarded on rollback. A
breadcrumb that lies is worse than none.

And a test for the compatibility claim in the forward direction, which
pageZeroKeepsTheLegacySinglePageLayout only covered in reverse: a single-page
dictionary labelled with the previous format version is opened, read in full with
no migration, then rolled over past two more pages with every id it arrived with
intact.
@lvca

lvca commented Jul 29, 2026

Copy link
Copy Markdown
Member Author

Round 8. All three items addressed in code, including the two you filed as follow-ups — item 1 because you are right that this PR is what makes it matter, so shipping the ceiling removal without it would be shipping the problem.

1. CopyOnWriteArrayList append is now unbounded — correct, and the sharpest observation in the whole review: the ~48K ceiling was the only thing bounding an O(n^2) append, and this branch removes exactly that ceiling. Fixed rather than deferred.

One correction on the proposed remedy, though. "A plain ArrayList swapped wholesale under the same volatile Entries pattern" would not have helped — republishing a copy per append is precisely the O(n) copy the COW list was already doing, just relocated. What removes it is spare capacity: Entries now holds a String[] plus the live size, grown by doubling. An append writes names[size] and publishes size + 1. Sharing the array with older readers is safe in both directions: a reader on the older snapshot holds the smaller size and never looks at that slot, and a reader that sees the new snapshot sees the slot through the volatile write's happens-before edge. Reads remain one volatile read plus an array index.

Measured, appending N names:

N CopyOnWriteArrayList doubling array
50,000 232 ms 0 ms
200,000 1,927 ms 0 ms
500,000 11,179 ms 2 ms

4x the names costs 8.3x the time and 2.5x costs 5.8x, which is the quadratic term showing through. Your "millions of distinct field names" scenario was heading for minutes of pure array copying.

updateName renames onto a copy rather than in place, deliberately: an append only ever writes slots past the published size, so it can share its array, whereas a rename rewrites a slot readers are already looking at and would need a happens-before edge they do not have. One copy per rename is nothing next to the full page rewrite it precedes.

2. The breadcrumb can fire for growth that never commits — a good catch, and worse than cosmetic in my view: the line's entire value is being trustworthy when correlated against a follower failure, and my own rolled-back-growth test was making it lie. It now registers an after-commit callback via addAfterCommitCallbackIfAbsent, which is discarded on rollback and keyed so several pages added in one transaction still log once. I preferred that over your fallback of "noting in the message that it reflects an in-progress transaction", since a caveat in the text does not make the signal usable.

3b. No forward-direction compatibility fixture — agreed, and a fair gap: pageZeroKeepsTheLegacySinglePageLayout only proved new-writer to old-reader. Added aDictionaryLabelledWithThePreviousFormatVersionLoadsAndThenRollsOver, which relabels a single-page dictionary to the previous format version, opens it with this code, checks it loads in full with no migration, then rolls it over past two more pages with every id it arrived with intact. Relabelling rather than synthesising a fixture is deliberate: the two formats are byte-identical for one page, and the version in the name is the only difference, which is precisely the claim under test.

3a. No end-to-end HA test across a real page boundary — agreed this is the sharpest edge, and I am not claiming it covered. aReplicatedNewDictionaryPageIsVisibleAfterApplyChanges exercises the reload-walks-all-pages behaviour at the unit level through the real applyChanges entry point, which is the part that could be wrong in this diff; what is untested is the full leader-ships-page-1-to-follower path. That belongs in ha-raft as a genuine two-node integration test, and I would rather file it than bolt a flaky cluster test onto this branch. Flagging it explicitly as a known coverage gap rather than quietly leaving it in the noise.

Local: 627 tests across com.arcadedb.schema, com.arcadedb.serializer, com.arcadedb.database, com.arcadedb.engine. 0 failures, 0 errors. DictionaryMultiPageTest is now 13 tests.

@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review: multi-page schema dictionary (#5560)

Reviewed the full 9-commit diff, the final Dictionary.java, and the engine contracts it leans on (PaginatedComponent.getTotalPages()/pageCount, TransactionContext, PageManager.getImmutablePage, engine/CLAUDE.md). Overall this is an excellent, unusually careful piece of work: the invariant ("id = ordinal of the name in page order, append-only across pages") is stated crisply and every derived decision traces back to it. Note: I was not able to run mvn in my environment, so I did not independently reproduce the green test run reported in the description.

What's strong

  • Backward compatibility by construction. Keeping the 4-byte legacy counter on every page so a pre-multi-page file is literally "a dictionary of one page" is the right call: no migration, no format flag on the read path. pageZeroKeepsTheLegacySinglePageLayout and aDictionaryLabelledWithThePreviousFormatVersionLoadsAndThenRollsOver pin both directions.
  • Read-before-modify in addItemToPage. Using getPage() to size the tail page and only calling getPageToModify()/addPage() afterwards correctly avoids bumping the tail page version and false-conflicting with concurrent transactions - consistent with the "page is the conflict unit" rule in engine/CLAUDE.md.
  • reload() reading committed state on both counts (Math.max(1, pageCount.get()) for the count, PageManager.getImmutablePage rather than TransactionContext.getPage) is correct, and the commit message honestly calls out that the dirty-page-on-rollback half was a latent pre-existing defect. getTotalPages() returning the tx-local counter when a tx is active (confirmed in PaginatedComponent) is exactly why pageCount had to be used here instead.
  • Validate-before-mutate in updateName. Moving all four preconditions (incl. the type-name check, which throws IllegalArgumentException that the local catch (IOException) does not repair) ahead of any mutation closes a real half-applied-rename window. aRenameRefusedBecauseTheNameIsATypeLeavesTheDictionaryConsistent is a good regression guard.
  • The O(n)->amortised O(1) append (spare-capacity String[] + volatile Entries snapshot) is well reasoned; writing names[size] before the volatile publish is safe for readers on either snapshot, and the rename-onto-a-copy note is correct (a rename rewrites a slot older readers are already reading).
  • Test coverage is genuinely thorough: rollover, reopen persistence, legacy layout both directions, oversize-name rejection, serializer round-trip across pages, updateName shrink + grow, rolled-back growth, version guard through the real load path, the follower applyChanges path, and a 4-thread contention test that re-checks id/page order after a reload. Nicely targeted.

Points worth a second look (discussion, not blockers)

  1. updateName WAL/heap amplification at scale. The rewrite now dirties every dictionary page into the caller's transaction, so on a large multi-page dictionary a single rename produces a WAL commit and in-memory page set proportional to the whole dictionary, not the one name. The javadoc documents this and there is no production caller today, but if a live rename is ever wired up this is a sharp edge - worth a follow-up cap or a note at the future call site.

  2. reload()'s createIfNotExists=true can mask corruption. Paired with the floor of 1 it correctly materialises page 0 of a file killed mid-write (the intended case). But if pageCount ever says N while fewer than N pages are actually present (partial/corrupt file), the loop silently synthesises empty pages instead of failing loudly. The single-page reader had a narrower version of this, so it's not a regression - just flagging that "empty page" and "missing page" are now indistinguishable here.

  3. Version stays v0 after an in-place rollover. Thoroughly documented and the reasoning (the version lives in the filename; bumping at runtime means renaming a live component and racing replication) is sound. The practical consequence is that the only durable on-disk signal that a database depends on multi-page is the absence of readability by an old build - the INFO breadcrumb is transient. Acceptable given the constraints, but operators relying on it should know the log line is the only proactive signal.

  4. Minor: the getIdByName safety-net path (appended != newPos.get() + 1 -> reload + throw) leaves the name durably committed on the page while surfacing a SchemaException to the caller; the next reload reconciles it. Pre-existing shape, rare, and self-healing - noting only for completeness.

Style / conventions

  • Consistent with the repo (final params, one-statement ifs without braces, assertThat(...) style, @author tag on the new test, Apache header, no new dependencies). No System.out left behind.
  • Comment density is very high. It is high-quality and load-bearing here given the concurrency and on-disk-format subtlety, so I would keep it, but it is well above the surrounding baseline.
  • docs/5560-dictionary-multipage.md is a nice touch and matches the class javadoc.

Nice work - the correctness reasoning and test coverage are exemplary. My comments above are refinements, not objections.

(Automated review; I was unable to run the build/test suite in this environment, so the reported green run was not independently verified.)

…issing

reload() passed createIfNotExists=true for every page. For page 0 that is load
bearing and stays: paired with the Math.max(1, pageCount) floor it materialises
page 0 of a file killed mid-write, which is what the single-page reader did. For
every later page it was wrong, and worse than the masked corruption it looks
like.

An invented page is not empty-and-harmless. It contributes zero names, so every
name stored after it comes back with an id lower by however many the missing page
held, and those ids are embedded in records. Silently renumbering is the one
outcome this class exists to prevent, so a gap has to be loud. Later pages now
pass createIfNotExists=false and a missing one is reported as a truncated
dictionary, naming the page and the count that claimed it.

A partial replication replay is how the count gets ahead of the file: applyChanges
writes a page and calls updatePageCount(pageNumber + 1) without necessarily having
written the pages before it. The new test reproduces that shape directly, in its
own database because it deliberately leaves the count inconsistent, and drops it.

Also record in the docs how to tell after the fact whether a database has rolled
over, since the log line is emitted once and is no help later: the dictionary file
is one page per pageSize bytes and pageSize is in its name, so ls answers it.
@lvca

lvca commented Jul 29, 2026

Copy link
Copy Markdown
Member Author

Round 9. Point 2 turned out to be worth more than "flagging", and it produced a real fix.

2. createIfNotExists=true can mask corruption — you framed this as "empty page and missing page are now indistinguishable, not a regression". Following it through, the consequence is sharper than masking: an invented page silently renumbers ids. It contributes zero names, so every name stored after it comes back with an id lower by however many the missing page held, and those ids are embedded in records. That is the exact failure mode the append-only invariant exists to prevent, arriving through the read path instead of the write path.

So it is fixed rather than noted. createIfNotExists stays true for page 0, where it is load bearing (paired with the floor it materialises page 0 of a file killed mid-write, which is what the single-page reader did, and EmptyDictionaryFileReopenTest / UnflushedDictionaryRecoveryTest still pass). Every later page now passes false, and a missing one is reported as a truncated dictionary naming the page and the count that claimed it.

On reachability, since that governs whether the guard is theoretical: pageCount is derived from file size at load, so it cannot over-report there, and dirty-but-unflushed pages are served from the cache, so that is not it either. The way the count gets ahead of the file is a partial replication replayapplyChanges writes a page and calls updatePageCount(pageNumber + 1) without necessarily having written the pages before it. The new test reproduces that shape directly, in its own database because it deliberately leaves the count inconsistent, and drops it.

3. The rollover breadcrumb is transient, so there is no durable signal — accepted as stated, but there is a durable one worth naming rather than conceding: the dictionary is one page per pageSize bytes and pageSize is in the file name, so ls -l <db>/dictionary.*.dict answers "has this rolled over" at any time. 131072 / 65536 = 2 pages means rolled over and no longer downgradable; a file at or under pageSize is still single-page. Added to the docs as the thing to check before rolling a cluster back.

1. updateName WAL amplification — already in the method javadoc, and I am declining a cap. A cap on a rename is semantically odd (you cannot refuse a rename because the dictionary is large), and it would be complexity on a method with no production caller. For the record, the right optimization when a live rename is wired is not a cap but a narrower rewrite: pages before the first renamed occurrence are byte-identical and need not be touched, and if the new name has the same byte length as the old, only the one page containing it changes. That needs the index-to-page mapping the rewrite currently derives on the fly, which is worth building for a real caller and not before.

4. The getIdByName safety net leaves the name committed while throwing — agreed: pre-existing, rare, self-healing on the next reload. No change.

Comment density — noted, and I take the point that it is above the surrounding baseline. Keeping it, per your read, since it is carrying the concurrency and on-disk-format reasoning.

Local: 628 tests across com.arcadedb.schema, com.arcadedb.serializer, com.arcadedb.database, com.arcadedb.engine, run twice, 0 failures both times.

Unrelated flake observed, for the record: one full-suite run had DatabaseGetSizeTest.getSizeMultipleCalls fail with expected: 131453L but was: 65917L. It is not related to this branch (it reproduces neither on the previous commit of this branch nor on repeated runs of the current one, and passes in isolation). LocalDatabase.getSize() does Files.walk over the database directory and sums file sizes, so asserting three consecutive calls are byte-identical only holds if no background WAL rotation happens between them; the 65,536-byte delta is consistent with a segment being removed. Left alone as out of scope for this PR, but it is a genuine intermittent worth its own issue.

@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review: feat(engine): let the schema dictionary grow past a single page (#5560)

Thorough, careful piece of work. The single-page dictionary ceiling was a real, permanent wall for schemaless / dynamic-field workloads, and the append-only-across-pages invariant is the right way to lift it without touching the on-disk id encoding. The commit history reads like a design review of itself, and the class Javadoc plus docs/5560-dictionary-multipage.md capture the reasoning the diff alone would hide. I verified the load-bearing claims against the surrounding engine code and they hold up. Notes below, mostly confirmations with a few small points.

Correctness (verified)

  • Backward compatibility. Page 0 carries the same 4-byte legacy counter and is read with setBufferPosition(DICTIONARY_HEADER_SIZE) then string-until-content-size, identical to the old single-page reader. A pre-change dictionary is a one-page dictionary and loads with no special case. Space accounting is consistent: checkNameFitsAPage uses pageSize - PAGE_HEADER_SIZE - DICTIONARY_HEADER_SIZE, and freeSpaceIn uses getMaxContentSize() - getContentSize() where getMaxContentSize() is size - PAGE_HEADER_SIZE (BasePage.java:68), so an empty page reports exactly usable free bytes. No off-by-one.
  • Truncated-dictionary guard is precise. Traced getImmutablePage(..., createIfNotExists=false) for a missing page: loadPage returns null (PageManager.java:666), getCachedPage throws IllegalArgumentException "Page id ... does not exist" (PageManager.java:844). That is the only IAE reachable from the wrapped call, and the try wraps only the page load (the setBufferPosition/readString calls are outside it), so the catch cannot misclassify an unrelated IAE as truncation. Turning silent renumbering into a loud failure on a partial replication replay is exactly right.
  • reload() reading committed state via PageManager + Math.max(1, pageCount.get()) rather than TransactionContext.getPage() / getTotalPages() is the correct fix for the rollback path, and the commit message honestly notes it is a pre-existing defect updateName could already hit. The aRolledBackTransactionThatGrewTheDictionaryLeavesItIntact test pins it.
  • Lock-free read model. The Entries(String[], size, ids) snapshot published through one volatile field is a correct safe-publication idiom: an append writes only slot size (past every older reader size) and doubling copies to a fresh array, so no reader observes a torn slot. updateName renaming onto a copy rather than in place is the right call, since a rename rewrites slots readers are already looking at.

Minor points

  1. CopyOnWriteArrayList import is now dead code. After the String[] migration (commit 9) it survives only in a {@link CopyOnWriteArrayList} Javadoc reference and prose (Dictionary.java:37, :97, :462). Javadoc @link resolves it, but most unused-import linters flag it. Worth dropping the import and inlining the reference. This is the one actionable item.

  2. Narrow map-vs-array ordering window in getIdByName (pre-existing, not a regression). Between current.ids().putIfAbsent(name, newPos) (:172) and appendName publishing the bumped size (:208), the id map holds the new id while the published names/size snapshot does not yet. A concurrent reader resolving the id via getIdByName(name, false) then calling getNameById(id) could momentarily hit "id N is not valid". The window is a couple of instructions and the old COW-list code had the identical ordering, so not introduced here; given how carefully the other invariants are documented, a one-line note that the map can lead the array by an instant might be worth it. Low priority.

  3. updateName shrink leaves a permanently-wasted gap. After a shrink, the last used page can be left partially filled while the emptied trailing pages become the append target, so that partial page tail is never reclaimed. Still consistent (id order matches page order after reload) and updateName has no production caller today, so purely cosmetic. Flagging only that the "under 1 percent wasted tail" Javadoc claim is about the append path, not the post-shrink layout.

Performance

The CopyOnWriteArrayList to doubling-array change is the right fix and the measured quadratic numbers (11s for 500k names) justify it directly; without it, removing the cap would have traded a hard wall for a quadratic one. Reusing a shared backing array across snapshots keeps reads at one volatile read plus an index. DEF_PAGE_SIZE dropping to 65,536 for write-amplification reasons is well argued, and keeping existing DBs on their stored page size is the safe default.

Tests

Coverage is excellent and each test pins a specific claim rather than the happy path: rollover, reopen survival, legacy page-0 layout in both directions, the too-big-name refusal, the serializer round trip across pages, updateName shrink and grow, rolled-back growth, the version guard through the real load path (via file rename), the truncated-page case in its own throwaway DB, the follower applyChanges path, and a contention test. Exercising the version guard and legacy format through the real load path rather than the constructor is a nice touch. I did not re-run the full engine suite; trusting the stated 920-class clean run.

Security

No new surface. Only identifiers (create=true callers) enter the dictionary and the per-name size limit is bounded by page size; the Javadoc clarification that user string values never enter is a helpful correction. The name.substring(0, 64) truncation in the error message keeps a pathological identifier from bloating logs.

Nice work. The only actionable item is the dead CopyOnWriteArrayList import; the rest are notes.

Automated review. Read the full diff and cross-checked PageManager/BasePage; did not execute the test suite.

@codecov

codecov Bot commented Jul 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.10891% with 11 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.76%. Comparing base (9c4e194) to head (cd0f669).
⚠️ Report is 14 commits behind head on main.

Files with missing lines Patch % Lines
.../src/main/java/com/arcadedb/engine/Dictionary.java 89.10% 5 Missing and 6 partials ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main    #5560      +/-   ##
============================================
- Coverage     66.46%   65.76%   -0.71%     
- Complexity        0     1113    +1113     
============================================
  Files          1702     1748      +46     
  Lines        139704   147059    +7355     
  Branches      30203    31428    +1225     
============================================
+ Hits          92854    96712    +3858     
- Misses        34279    37390    +3111     
- Partials      12571    12957     +386     

☔ 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.

The two halves of the in-RAM view cannot be updated atomically together, so one
leads by an instant and a concurrent reader can land in between. The map was
leading, which is the worse order: that reader resolves a name to an id the
published names do not cover yet, and getNameById() rejects it. Leading with the
names instead means the reader finds the name absent and gets -1, which every
create=false caller already handles, BinarySerializer by skipping the value
compression, and which a create=true caller resolves by blocking on this monitor.
Nobody can be holding the new id before the map publishes it, since it only
reaches a record after getIdByName returns. A miss costs nothing and a throw
costs a request.

Pre-existing ordering, not introduced with the array representation, and the
window is a couple of instructions either way. Closing it was cheaper than
documenting it.

Also drop the CopyOnWriteArrayList import, left behind by the array migration and
kept alive only by a Javadoc link, which unused-import linters flag. The prose
reference stays as {@code}: the reference is historical, so linking the JDK class
bought nothing.

And qualify the wasted-tail claim in the class Javadoc. Under 1% is the append
path; updateName is looser, because a rewrite that shrinks leaves the pages it no
longer reaches empty and appends resume on the last of them, so the ones between
stay empty for good. One zero-entry read each on load, and no production caller.
@lvca

lvca commented Jul 30, 2026

Copy link
Copy Markdown
Member Author

Round 10. The actionable item done, and item 2 closed rather than documented.

1. Dead CopyOnWriteArrayList import — dropped. The {@link} became {@code} rather than moving to a fully-qualified link: the reference is historical ("it used to be a COW list"), so linking the JDK class bought nothing and an import that exists only for Javadoc is the smell you flagged. The prose comment in reload() needs no import.

2. Map-vs-array ordering window — you asked for a one-line note. I think the better answer is to make the note unnecessary, because the order was the bug, not the absence of documentation.

The two halves cannot be updated atomically together, so one leads by an instant either way. What matters is which. Leading with the map, as it did, means the reader in between resolves a name to an id the published names do not cover yet, and getNameById() throws. Leading with the names means that reader finds the name absent and gets -1 — which every create=false caller already handles (BinarySerializer just skips the value compression) and which a create=true caller resolves by blocking on this monitor. And nobody can be holding the new id before the map publishes it, since the id only reaches a record after getIdByName returns, so the reverse exposure does not exist.

So: a miss costs nothing, a throw costs a request. Swapped, with the reasoning recorded at the site. You are right it was pre-existing and a couple of instructions wide either way; closing it was simply cheaper than describing it. Ran the 4-thread contention test three more times plus the full selection, all clean.

3. The "under 1% wasted tail" claim covers the append path, not the post-shrink layout — correct, and the imprecision was mine. Qualified in the class Javadoc, and it is slightly worse than you described: after a shrink, appends resume on the last page, which is one of the emptied ones, so every page between the last used one and that tail stays empty for good, not just the one partial tail. They cost one zero-entry read each on load and nothing else, and updateName still has no production caller.

Local: 628 tests across com.arcadedb.schema, com.arcadedb.serializer, com.arcadedb.database, com.arcadedb.engine. 0 failures. Plus DictionaryMultiPageTest three times over, since the ordering change lands in the concurrency path.

Standing item, unchanged from the previous round: there is still no end-to-end ha-raft test of a rolled-over leader shipping page 1+ to a follower. That is the sharpest operational edge of this change and I have deliberately left it as a follow-up rather than attach a two-node cluster test to this branch.

@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review: feat(engine): let the schema dictionary grow past a single page

Thanks for a genuinely exemplary PR. The problem statement, the invariant-driven design (id = ordinal in page order), the commit-by-commit narrative, and the class/field Javadocs make this easy to reason about. The backward-compatibility approach (page 0 keeps the exact legacy layout, no migration, no format flag needed to read) is the right call, and the test coverage is broad and well-targeted (rollover, reopen, legacy page-0 read, oversize refusal, serializer round-trip, updateName shrink+grow, rollback integrity, version guard, follower apply path).

A few observations, mostly for discussion rather than blockers.

Strengths worth calling out

  • The reload() durable-state fix is a real latent bug fix, not just refactoring. Switching from TransactionContext.getPage() (which resolves modifiedPages first) to PageManager.getImmutablePage(...) plus Math.max(1, pageCount.get()) means rollback rebuilds the in-RAM view from committed content instead of the content being discarded. Good catch that this predates multi-page support (updateName always dirtied page 0 in the caller's transaction), and good that a test pins it.
  • Read-before-modify in addItemToPage (getPage to size the write, getPageToModify only when it fits) correctly avoids bumping the tail page version and false-conflicting - consistent with the "page is the conflict unit" rule in engine/CLAUDE.md. Nice.
  • The gap-detection in reload() (a page pageCount claims but that is missing throws loudly rather than silently renumbering) is exactly the right failure posture for a structure whose ids are embedded in records.

Points to consider

  1. Public API removal. getAvailableSpace() was public on main; it is removed here (renamed to getAvailableSpaceInLastPage(), then deleted). No in-repo callers remain, so this is clean internally, but Dictionary is a public engine class and this is a source-incompatible change for any downstream code. Probably fine given it is engine-internal, but worth a conscious note in the changelog if the project tracks API compatibility.

  2. Mixed-cluster failure mode is data-access failure, surfaced only via a one-time INFO log. The downgrade/upgrade-order hazard is documented thoroughly (docs + class Javadoc + the addPage breadcrumb), which I appreciate. My only concern: once a leader rolls over, an un-upgraded follower fails every record referencing ids beyond page 0 with Dictionary item with id N is not valid, and the operator's only proactive signal is a single INFO line emitted at rollover time (easily missed after the fact). The "check the file size" recipe in the docs is a good after-the-fact answer. Consider whether the rollover event also deserves a persisted marker or a startup-time compatibility check somewhere, rather than relying on log retention. Not a blocker - the tradeoff is reasonable and clearly argued.

  3. updateName leaks empty pages permanently. When a rewrite shrinks the content, the pages it no longer reaches are emptied but never truncated, and appends resume on the last one, so the in-between pages stay empty for good. The Javadoc and commit message justify this well (no production caller; truncating a live file id risks a follower replaying a WAL entry for a vanished page). Agreed for now; if a live rename path is ever wired up, this is the spot to revisit.

  4. Per-name cap drops from ~327KB to ~65KB for new databases. Documented, and since only identifiers (type/property names) ever enter the dictionary, no realistic name comes close. Fine - just flagging it is a real (if theoretical) behavior change for freshly created databases.

  5. On-disk version cannot distinguish a rolled-over v0 database (version is stamped at creation, never updated on rollover). The reasoning for not bumping it at runtime (file-name coupling, flush-barrier rename racing replication, and the fact that an at-risk old build does not validate the field anyway) is sound and thoroughly documented. No change requested.

Test note

I was unable to run DictionaryMultiPageTest / DictionaryLimitsTest in my review environment (Maven was sandbox-restricted here), so I am relying on reading the tests plus your reported local run (920 engine classes, 0 failures). The tests read as correct and cover the paths the design hinges on; I would just make sure CI runs the full engine suite green on the final commit, particularly SnapshotCompressionRatioTest in ha-raft which now pins 65536 * 5 as a literal decoupled from DEF_PAGE_SIZE.

Overall: strong, careful work. The points above are refinements and operational considerations, not correctness objections.

Automated review; please double-check the operational/downgrade points against your release process.

@codacy-production

codacy-production Bot commented Jul 30, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 17 complexity

Metric Results
Complexity 17

View in Codacy

🟢 Coverage 95.05% diff coverage

Metric Results
Coverage variation Report missing for d463a311
Diff coverage 95.05% diff coverage

View coverage diff in Codacy

Coverage variation details
Coverable lines Covered lines Coverage
Common ancestor commit (d463a31) Report Missing Report Missing Report Missing
Head commit (cd0f669) 179051 121584 67.90%

Coverage variation is the difference between the coverage for the head and common ancestor commits of the pull request branch: <coverage of head commit> - <coverage of common ancestor commit>

Diff coverage details
Coverable lines Covered lines Diff coverage
Pull request (#5560) 101 96 95.05%

Diff coverage is the percentage of lines that are covered by tests out of the coverable lines that the pull request added or modified: <covered lines added or modified>/<coverable lines added or modified> * 100%

1 Codacy didn't receive coverage data for the commit, or there was an error processing the received data. Check your integration for errors and validate that your coverage setup is correct.

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

The rollover is logged once, when it happens, which is no help to someone looking
at a database afterwards or planning a rollback. Log the same fact as current
state at open time instead, when the dictionary already spans more than one page,
naming the page count.

Preferred over the persisted marker the review floated: the page count already is
the durable record, so a separate marker would be a second copy of the same fact
that can drift from it. A startup compatibility check cannot help either, because
the build that needs the warning is the older one, which predates the concept and
cannot be given a check retroactively. Restating the state on every open is what
actually survives log rotation.
@lvca

lvca commented Jul 30, 2026

Copy link
Copy Markdown
Member Author

Round 11. Item 1's premise needs a correction, item 2 is addressed, and I ran the verification you asked for.

1. Public API removal of getAvailableSpace() — the premise is not quite right, and it changes the conclusion. That method was not on main before this work: git log -S puts its first appearance in 621313ce5, my own commit from earlier today, and git tag --contains 621313ce5 is empty, so it is in no release tag. It was added and deleted inside this same body of work, a few hours apart. There is no released version that ever exposed it, so there is no downstream source-compatibility break and nothing to note in a changelog. (Removing it was itself a response to review round 1, which flagged it as dead.)

2. The rollover signal is only a one-time INFO, easily missed after the fact — this is the third round to land on the operational hazard, and the "easily missed after the fact" part is the fair core of it. Addressed: the dictionary now logs its state on every open when it already spans more than one page, naming the page count.

I deliberately did not take either of the two mechanisms you suggested:

  • A persisted marker would be a second copy of a fact the page count already records durably, and two copies of one fact can drift. The file size is the marker.
  • A startup compatibility check cannot help, because the build that needs warning is the older one. It predates the concept and cannot be given a check retroactively. No code in this PR can make an un-upgraded follower detect this.

Restating current state at open is what actually survives log rotation, which was the real complaint. The rollover event line stays as well, so an operator gets it both as it happens and on every subsequent start.

Test note: confirm SnapshotCompressionRatioTest in ha-raft still passes with 65536 * 5 decoupled from DEF_PAGE_SIZE — ran it: 12 tests, 0 failures. The literal is intentional there; that test pins the compression-ratio guard against a sparse page-sized zip entry, and 327,680 remains the largest such entry a snapshot of a pre-change database can carry, so coupling it to DEF_PAGE_SIZE would have weakened it. I updated only its stale comment.

3, 4, 5 — agreed as stated, no changes. The updateName empty-page leak, the identifier cap on new databases, and the version-not-bumped-on-rollover reasoning are all documented and unchanged.

On the red CI checks, since they matter more than the notes

integration-tests is failing, and it is not this branch. The job's own steps show "Run Integration Tests with Coverage" success and "Check no test leaked a databases/ directory" success; the only failed step is IT Tests Reporter, the report-publishing action. The last three runs on main fail identically, with the same single step. Meterian client scan is also failing on main. Both are pre-existing infrastructure failures, not regressions from this PR. unit-tests was still running at the time of writing and is the check worth watching.

Local: 628 tests across com.arcadedb.schema, com.arcadedb.serializer, com.arcadedb.database, com.arcadedb.engine, plus the ha-raft test above. 0 failures.

@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review: multi-page schema dictionary (#5560)

Reviewed Dictionary.java, both test classes, the design doc, and the SnapshotCompressionRatioTest comment tweak. This is a high-quality change: the append-only-across-pages invariant is the right model, the backward-compat story (page 0 byte-identical, no migration) is sound, and the commit history already worked through the subtle cases (dirty-page reload on rollback, validate-before-mutate in updateName, the O(n)->amortised-O(1) append, publish-names-before-map ordering, loud failure on a claimed-but-missing page). The docs/5560-dictionary-multipage.md write-up is genuinely useful for operators. Below are observations, not blockers.

Correctness / concurrency

  1. Writer-vs-reload() race is only safe by caller context, not by the class's own synchronization. getIdByName's create path and updateName serialize on this, but reload() deliberately does not hold the monitor. If a reload() on another thread published a fresh Entries between appendName's read of current = entries and its entries = new Entries(...) write, the append would clobber the reload with stale content plus one name. In practice this looks unreachable - reload()'s callers are rollback (same thread, inside the synchronized block) and follower applyChanges (no concurrent local create=true append on a follower) - but that safety rests on where the callers run, not on anything the class enforces. The Entries javadoc argues reader-vs-rebuild safety thoroughly; a sentence stating why reload() can never overlap a monitor-holding writer would close the reasoning, since a future caller that reloads from a third context would silently break it.

  2. reload() maps any IllegalArgumentException from getImmutablePage to "truncated". Confirmed getCachedPage throws exactly that for a missing page with createIfNotExists=false, and the try is narrowly scoped to just the getImmutablePage call, so this is correct today and there is a test pinning it (aDictionaryPageThatIsClaimedButMissingFailsLoudly). The only fragility is that the loud-truncation guarantee is coupled to getCachedPage's current exception type rather than a stated contract - worth a one-line comment tying it to that, so a future refactor of PageManager's not-found signal does not quietly downgrade it to a raw error.

Minor

  1. The initial Entries uses new ConcurrentHashMap<>(1024) and new String[64] for a brand-new (typically tiny) dictionary. Negligible, but the fixed 1024 map capacity is a touch arbitrary versus the 64-slot names array; reload() already sizes both from the real count, so this only affects a never-reloaded fresh DB.

Tests

Coverage is excellent and the intent comments make each case easy to follow: rollover, reopen persistence, legacy page-0 layout in both directions, serializer round-trip across pages, updateName shrink and grow, rolled-back growth, newer-format refusal, type-name rename rejection, concurrent appends across a rollover, claimed-but-missing page, and the follower applyChanges path. Two gaps worth noting (both acceptable): the getIdByName self-heal path (appended != newPos + 1 -> reload() + SchemaException) has no direct test since it is hard to trigger deterministically, and there is no test asserting behavior when updateName runs concurrently with an append (the javadoc explicitly punts this to a schema-level lock, so documenting-only is reasonable).

Nice work - the append-only invariant and the "make failure loud rather than silently renumber" stance are exactly right for on-disk ids embedded in records.

@lvca
lvca merged commit 7c40815 into main Jul 30, 2026
22 of 25 checks passed
@lvca
lvca deleted the dictionary-multipage branch July 30, 2026 01:59
@lvca

lvca commented Jul 30, 2026

Copy link
Copy Markdown
Member Author

Correction to my CI note above.

In my last comment I said integration-tests was red only because of the IT Tests Reporter publishing step, and that the tests themselves passed. That was wrong, and the mistake was mine: I read the step conclusions and stopped there instead of reading the reporter's output.

The reporter is not broken. It is correctly reporting a genuinely failing integration test:

com.arcadedb.postgres.PostgresQueryMetricsIT.postgresQueriesTaggedWithPostgresProtocol
  Expecting actual: 0L to be greater than or equal to: 1L
  at PostgresQueryMetricsIT.java:76
##[error]Failed test were found and 'fail-on-error' option is set to true

What misled me is that the Maven step runs with --fail-never, so "Run Integration Tests with Coverage" reports success even when an IT fails, and the reporter is the only thing that turns the job red. The failure therefore looks like it lives in a publishing step when it does not.

What stands from my earlier note: this is not a regression from this PR. Run 30504641942 was on main at 01:05Z against f2909e34, before this merged at 01:59Z, and the failing test is Postgres query metrics with no relationship to the dictionary. The two claims that mattered for merging this PR - not caused here, and pre-existing on main - are unaffected. But "the integration tests pass" was not true, and anyone reading that comment as a green light on main would have been misled.

I am picking this up separately along with the other follow-ups.

lvca added a commit that referenced this pull request Jul 30, 2026
…nise

The index arithmetic walked record * (PROPERTIES / TYPE_RECORDS) + p, which only
covers 0..PROPERTIES-1 without gaps while the two constants divide evenly. At
400/5 they do, but a later edit to a non-divisible pair would truncate, write
fewer distinct names than the follower loop then looks for, and report missing
names that were never written - a confusing failure in a test whose whole job is
to be trusted about missing names.

Documenting the constraint was the suggestion; making it unrepresentable is
better. PROPERTIES is now derived as TYPE_RECORDS * PROPERTIES_PER_RECORD, so
there is no pair of numbers a future editor can pick that desynchronises them,
and the arithmetic uses the per-record constant directly.

Also stop abbreviating the key in the assertion label: it said "p" + p while the
real key is the padded 500-character name, so grepping the message for the key
found nothing. It now reads as an index, which is what it is.
lvca added a commit that referenced this pull request Jul 30, 2026
#5560 removed the single-page cap on the schema dictionary and merged before the
living release notes file existed, so it is not in them. Add it under New
Features, in the style of the entries already there.

The part that needs to reach an operator rather than a reader: in a cluster,
followers have to be upgraded before or together with the leader. Dictionary pages
replicate as raw pages, so a rolled-over leader ships page 1 and beyond, and an
older follower writes them but reloads only page 0 - leaving it missing every name
past the first page and failing each record that references one. The entry also
gives the ls recipe for telling whether a given database has rolled over, since
that decides whether it can still be opened by an older build.
lvca added a commit that referenced this pull request Jul 30, 2026
The getTotalPages() > 1 guard assumes 400 names padded to 500 bytes overflow page
0, which is an implicit dependency on the dictionary page size that a reader had
to rederive. Say it: DEF_PAGE_SIZE leaves roughly 65Kb usable per page and the
names are about 200Kb, so the boundary is crossed around three times over rather
than only just, and if the default ever grew past the total written the guard
fails loudly rather than passing without a rollover.

Comment only, no behaviour change. Verified by running the test through the same
profile CI uses: it passes, and with reload() pinned back to a single page it
fails on property #130 of a follower - which is where page 0 fills at this name
length, so it still fails for the reason it exists.
lvca added a commit that referenced this pull request Jul 30, 2026
…xture is empty

Two comment nits from review. Neither changes behaviour.

The page-size note said "roughly 65Kb usable per page", which review read as
overstating a DEF_PAGE_SIZE of 65,536 bytes - 64Kb binary, 65.5Kb decimal, and
the ambiguity is the whole problem. Swapping one Kb for another just moves it, so
the figures are now in bytes: 65,536 per page less the headers, against about
200,000 bytes of names. Nothing to misread.

populateDatabase() is overridden empty on purpose and did not say so. The base
fixture builds several types with properties, indexes and records, and every one
of those names reaches the dictionary before the test starts; this test opens by
asserting a single page, so the emptiness is what makes that assertion mean
anything. Said so.

Re-ran the test through the profile CI uses: passes in 21.9s.
lvca added a commit that referenced this pull request Jul 30, 2026
…e of them

The comment over the follower loop said resolving the name is what fails when a
follower stopped at page 0, which implies resolving the id would still work. It
would not: such a follower holds neither mapping for a name past the boundary, so
getIdByName returns -1 and the missing-id assertion trips first. The pinned-page
run says as much - it fails on "property #130 must be in the dictionary", not on
the read-back.

Reworded to say both break together and that the missing-id assertion is simply
the one reached first, while keeping the point the original was reaching for: the
id->name direction is the one a real record read hits, which is why the read-back
further down is the user-visible end of the same failure.

Comment only. Re-ran through the profile CI uses: passes in 21.4s.
lvca added a commit that referenced this pull request Jul 30, 2026
…ary (#5563)

* test(ha-raft) #5560: replicate a dictionary that crosses a page boundary

The multi-page dictionary shipped with the follower path covered only by a unit
test that fed a hand-built WALFile.WALPage to applyChanges. That checks the reload
walks every page, but not the scenario the upgrade-ordering requirement in
docs/5560-dictionary-multipage.md is about: a real leader crossing the boundary
and real followers having to use what arrives.

Three nodes. The leader writes enough distinct property names that ordinary
inserts push the dictionary off page 0, which is how it happens in production
rather than by calling the dictionary directly. Then every server has to report
more than one page, resolve all 400 names in both directions, and read every
record back - the last one being the user-visible symptom, since property names
live inside records as dictionary ids and deserialising them is what turns a
missing page into "Dictionary item with id N is not valid".

Verified it detects the regression: with reload() pinned back to a single page it
fails, and passes with the page walk restored. Tagged slow at 19s.

* test(ha-raft) #5560: derive the property count so it cannot desynchronise

The index arithmetic walked record * (PROPERTIES / TYPE_RECORDS) + p, which only
covers 0..PROPERTIES-1 without gaps while the two constants divide evenly. At
400/5 they do, but a later edit to a non-divisible pair would truncate, write
fewer distinct names than the follower loop then looks for, and report missing
names that were never written - a confusing failure in a test whose whole job is
to be trusted about missing names.

Documenting the constraint was the suggestion; making it unrepresentable is
better. PROPERTIES is now derived as TYPE_RECORDS * PROPERTIES_PER_RECORD, so
there is no pair of numbers a future editor can pick that desynchronises them,
and the arithmetic uses the per-record constant directly.

Also stop abbreviating the key in the assertion label: it said "p" + p while the
real key is the padded 500-character name, so grepping the message for the key
found nothing. It now reads as an index, which is what it is.

* test(ha-raft) #5560: record the page size the fixture is sized against

The getTotalPages() > 1 guard assumes 400 names padded to 500 bytes overflow page
0, which is an implicit dependency on the dictionary page size that a reader had
to rederive. Say it: DEF_PAGE_SIZE leaves roughly 65Kb usable per page and the
names are about 200Kb, so the boundary is crossed around three times over rather
than only just, and if the default ever grew past the total written the guard
fails loudly rather than passing without a rollover.

Comment only, no behaviour change. Verified by running the test through the same
profile CI uses: it passes, and with reload() pinned back to a single page it
fails on property #130 of a follower - which is where page 0 fills at this name
length, so it still fails for the reason it exists.

* test(ha-raft) #5560: say the page arithmetic in bytes, and why the fixture is empty

Two comment nits from review. Neither changes behaviour.

The page-size note said "roughly 65Kb usable per page", which review read as
overstating a DEF_PAGE_SIZE of 65,536 bytes - 64Kb binary, 65.5Kb decimal, and
the ambiguity is the whole problem. Swapping one Kb for another just moves it, so
the figures are now in bytes: 65,536 per page less the headers, against about
200,000 bytes of names. Nothing to misread.

populateDatabase() is overridden empty on purpose and did not say so. The base
fixture builds several types with properties, indexes and records, and every one
of those names reaches the dictionary before the test starts; this test opens by
asserting a single page, so the emptiness is what makes that assertion mean
anything. Said so.

Re-ran the test through the profile CI uses: passes in 21.9s.

* test(ha-raft) #5560: the two lookup directions break together, not one of them

The comment over the follower loop said resolving the name is what fails when a
follower stopped at page 0, which implies resolving the id would still work. It
would not: such a follower holds neither mapping for a name past the boundary, so
getIdByName returns -1 and the missing-id assertion trips first. The pinned-page
run says as much - it fails on "property #130 must be in the dictionary", not on
the read-back.

Reworded to say both break together and that the missing-id assertion is simply
the one reached first, while keeping the point the original was reaching for: the
id->name direction is the one a real record read hits, which is why the read-back
further down is the user-visible end of the same failure.

Comment only. Re-ran through the profile CI uses: passes in 21.4s.
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.

1 participant