Skip to content

fix(#6340): a component takes its file's mode too, the version joins the guards it belongs with, and CHECK DATABASE can finally read a TimeSeries file - #6347

Merged
lvca merged 4 commits into
mainfrom
issue-6340
Aug 18, 2026
Merged

fix(#6340): a component takes its file's mode too, the version joins the guards it belongs with, and CHECK DATABASE can finally read a TimeSeries file#6347
lvca merged 4 commits into
mainfrom
issue-6340

Conversation

@lvca

@lvca lvca commented Aug 18, 2026

Copy link
Copy Markdown
Member

Closes #6340.

Four items. The first three are the last members of a set issues #6283 and #6314 have been closing one at a
time - a component and the file it holds must agree, and the agreement belongs to the API rather than to
whoever remembered to check. The fourth is the one that left a real defect undetectable.

Item 1 - ComponentFile.getMode()

Added, and used where it was missing: TimeSeriesTagDictionary's build-on-an-existing-file constructor took
the id, the page size and the version off the file and then hard-coded MODE.READ_WRITE in the middle of
them, because there was nothing to read the mode back out of. All four now come from the file.

Item 2 - the by-name getOrCreateFile and the caller's mode

The issue asked for a decision, so here it is stated rather than implied: the mode is a request the caller
is entitled to
, and a hit that cannot satisfy it now throws instead of handing back something else. The
direction that decided it is the quiet one - a caller asking for READ_ONLY and being given a READ_WRITE
file gets a weaker guarantee than it asked for, and mode is the one file property whose whole purpose is to
be a guarantee.

Reopening the file to satisfy the request was considered and rejected: a registered file is shared by every
component addressing it, so upgrading the channel would silently widen it under a reader that had asked for
the narrower one - the same shape being removed, with a worse failure mode.

IllegalStateException, not the SchemaException its by-id sibling raises: a file name mismatch is a
file-id space that diverged from the leader's and an HA follower classifies it as quarantine-and-resync; a
mode mismatch is a programming error, on the same footing as the file-id and page-size guards in
PaginatedComponent, which is this overload's only caller and which throws exactly this.

Nothing reaches it today, and that is checked rather than assumed - the one caller that legitimately hits a
registered file is the tag dictionary constructor above, which now asks for the file's own mode.

Item 3 - the version guard

PaginatedComponent asserted the file id (#6283) and the page size (#6314) against the file it ends up
holding; the version, the third fact baked into name.fileId.pageSize.vVersion.ext, was not. It decides how
the pages are interpreted where the other two decide where they are - a LocalBucket version selects the
record-header layout, a TimeSeriesBucket version selects whether a TAG column is a 4-byte dictionary id or
an inline string - so a disagreement is a misread of real bytes, never an exception.

A tripwire, not a compatibility gate, and worth repeating because #6314 had to work through the same
point twice: every load constructor passes the parsed version straight through and every creation path bakes
the version into the name it generates, so a component and its file agree by construction whatever build
wrote the file. aComponentBuiltOnTheVersionItsFileNameCarriesOpensNormally pins that.

Item 4 - CHECK DATABASE can now read a TimeSeries file

DatabaseChecker had zero references to TimeSeries. It walks record buckets and indexes; a TimeSeries type
has neither - its shards are registered with the schema as files, and its compacted data never goes through
the paginated layer - so the type fell into the document arm, found no bucket to scan, and all three of the
formats TimeSeries owns were outside the reach of the only tool whose job is to find damage in them.

Each format now validates itself, in the shape IndexInternal.checkIntegrity() already uses:

  • TimeSeriesBucket - page 0's magic, format version against the file name, column count against the
    schema, and then every counter page 0 declares reconciled against the data pages themselves: the sample
    count, the min and the max timestamp, and that the data pages it announces are actually in the file. That
    last set is what makes the residue of Follow-ups from #6283: TimeSeries components discard their file's page size, the by-id getOrCreateFile is unguarded, and a vector pool test asserts against its own contract #6314 visible: a session that wrote at the wrong stride put real rows
    at offsets nothing will address again and counted them in a header that still does. Page headers only, no
    row decoding.
  • TimeSeriesTagDictionary - page 0's magic and version, and the entries walked the way load() walks
    them, so the declared entry count and the bytes have to agree. A truncated dictionary does not fail a query;
    it makes every tag written since the damage read back as null, on every row.
  • TimeSeriesSealedStore - header, block directory, offsets against the file length, a trailing region
    that belongs to no block, and the per-block CRC32. That last one is the expensive part and it is the
    point: the CRC is verified lazily on first read, so a block nothing queries is a block nothing verifies.
    It is recomputed from the file rather than delegated to validateBlockCRC(), so a second CHECK DATABASE
    in the same process cannot answer "clean" without having read a byte.

Rows are deliberately not decompressed and there is no FIX arm. A record bucket can be repaired
because its records are self-describing and its indexes derive from them; a sealed store is append-only
columnar data whose blocks are the only copy, so "repair" means deciding which samples to discard - the
design question the issue itself flagged, which wants an answer before code. This change makes the state
visible, which is the part that was missing entirely.

Reported as corruptedTimeSeries plus warnings, with totalTimeSeriesTypes/Shards/Samples/SealedBlocks
seeded on every run so "was this looked at?" is answerable from a clean result rather than from silence.
Scoped by TYPE like every other per-type pass. One progress step for all TimeSeries types and only when the
database has one, so a database without them keeps the step plan every existing expectation was written
against.

Two things found on the way, neither fixed here

  • BlockEntry.blockStartOffset and storedCRC are populated by loadDirectory() alone. A block appended by
    the running process carries both on disk and zero in the fields, with crcValidated pre-set to true.
    Nothing reads them for such a block today, so it is latent rather than a defect - but it is why the check
    reads both sides from the file instead of trusting the directory.
  • The sealed-store CRC pass reads the whole sealed file. That is the same cost class as the record scan
    checkBuckets already runs over every bucket, but on a very large TimeSeries type it is the dominant cost
    of a CHECK DATABASE, and whether it should become opt-in is worth deciding once someone has one.

Verification

…the guards it belongs with, and CHECK DATABASE can finally read a TimeSeries file

Items 1 to 3 are the last members of the set #6283 and #6314 have been closing one at a time: a component and
the file it holds must agree, and the agreement belongs to the API rather than to whoever remembered to check.

1. ComponentFile.getMode() - the mode was the one file property with no accessor, so TimeSeriesTagDictionary's
   build-on-an-existing-file constructor read the id, the page size and the version off the file and then
   hard-coded READ_WRITE in the middle of them. All four now come from the file.

2. The by-name getOrCreateFile consulted the caller's mode only on the miss path. Decided, and stated rather
   than implied: the mode is a request the caller is entitled to, so a hit that cannot satisfy it throws.
   The direction that settles it is the quiet one - asking for READ_ONLY and being handed a READ_WRITE file is
   a weaker guarantee than the caller asked for. Reopening the file to satisfy the request is deliberately not
   the alternative: a registered file is shared, so upgrading the channel would widen it under a reader that
   had asked for the narrower one.

3. PaginatedComponent now asserts the version alongside the file id and the page size. It decides how pages
   are interpreted where the other two decide where they are, so a disagreement is a misread of real bytes
   rather than an exception. A tripwire and not a compatibility gate: every load path takes the version from
   the file, so component and file agree by construction whatever build wrote it.

4. CHECK DATABASE had no TimeSeries coverage at all - DatabaseChecker held zero references to it. The checker
   walks record buckets and indexes; a TimeSeries type has neither, so its three on-disk formats were the only
   storage in the engine an integrity check could not see, and the pages #6314's bug wrote at the wrong stride
   were undetectable. Each format now validates itself in the shape IndexInternal.checkIntegrity() uses:
   - TimeSeriesBucket: page 0's magic, version and column count, and every counter it declares reconciled
     against the data pages - the sample count, the min and max timestamp, and that the pages it announces are
     in the file. Page headers only, no row decoding.
   - TimeSeriesTagDictionary: page 0's magic and version, and the entries walked the way load() walks them.
   - TimeSeriesSealedStore: header, block directory, offsets against the file length, a tail belonging to no
     block, and the per-block CRC32 - recomputed from the file, since it is verified lazily on first read and
     a block nothing queries is a block nothing verifies.
   Report-only in both modes, and rows are not decompressed: what a repair means for an append-only sealed
   store is the design question the issue flagged, and it wants an answer before code. Reported as
   corruptedTimeSeries plus warnings, with totalTimeSeriesTypes/Shards/Samples/SealedBlocks seeded on every
   run. One progress step, and only when the database has a TimeSeries type.
@lvca lvca self-assigned this Aug 18, 2026
@mergify

mergify Bot commented Aug 18, 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 Aug 18, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 31 complexity

Metric Results
Complexity 31

View in Codacy

🟢 Coverage 80.85% diff coverage · -6.88% coverage variation

Metric Results
Coverage variation -6.88% coverage variation
Diff coverage 80.85% diff coverage

View coverage diff in Codacy

Coverage variation details
Coverable lines Covered lines Coverage
Common ancestor commit (7f748a2) 160508 127979 79.73%
Head commit (6f067b0) 193079 (+32571) 140658 (+12679) 72.85% (-6.88%)

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 (#6347) 282 228 80.85%

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%

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 Aug 18, 2026

Copy link
Copy Markdown

Review

Solid, well-scoped change. The three PaginatedComponent/FileManager guards (mode, version) close out the id/page-size/version/mode consistency family cleanly and follow the exact pattern already established by #6283/#6314, and the new TimeSeries checkIntegrity() passes fill a real, previously-total blind spot in CHECK DATABASE (confirmed: before this PR DatabaseChecker had zero references to TimeSeries/tstb/tstd).

Went through the diff in detail (not just the description) - a few notes, nothing blocking:

Correctness checks that passed:

  • PaginatedComponent's new version guard sits after the id/page-size guards and uses the constructor parameter version, which every load path parses off the file name and every create path bakes into the name it generates - so the tripwire can't fire on a legitimate path, matching the "same footing as the other two guards" framing in the comment.
  • FileManager.getOrCreateFile(name, ...)'s double-checked-locking addition (checkModeMatches on both the fast path and inside the synchronized block) is structurally sound - no new race introduced.
  • TimeSeriesSealedStore.checkIntegrity() correctly takes directoryLock.readLock(), which serializes it against appendBlock's writeLock(), so the raw-channel CRC re-read can't observe a concurrent append mid-write. TimeSeriesShard.checkIntegrity() layers appendLock + compactionLock.readLock() in the same order appendSamples takes them, so no lock-ordering inversion there either.
  • TimeSeriesTagDictionary.checkIntegrityUnderLock()'s non-transactional getImmutablePage read matches the existing convention in readStoredHeader()/load() in the same class (deliberately not tx-scoped, per that method's own doc comment) - not an inconsistency, just following the file's established pattern.
  • The offset arithmetic in the new tag-dictionary entry walk (DATA_ENTRIES_OFFSET + offset, starting offset = 0) is equivalent to the existing walker in load() (which starts offset = DATA_ENTRIES_OFFSET and doesn't re-add it) - just refactored to a relative offset. Same semantics.
  • The columns.isEmpty() guard before calling getMaxSamplesPerPage() in both TimeSeriesBucket.checkIntegrity() and the constructor-stub case correctly avoids a division-by-zero (rowSize is 0 when there are no columns).
  • Step-plan math (totalSteps in DatabaseChecker.check()) correctly nets out: TimeSeries types are now excluded from documentTypes (removing N wasted no-op steps) and replaced with + (timeSeriesTypes.isEmpty() ? 0 : 1), and stepBegin/stepTick/stepComplete usage in checkTimeSeries matches the convention used by every other pass in the file.

Minor observations (not blockers, mostly already called out by the PR description itself):

  1. DatabaseChecker.checkTimeSeries walks each engine's shards three separate times per type (getShardCount()/getBlockCount() loop for totalSealedBlocks, countSamples(), then checkIntegrity()). Each is a separate scan/lock acquisition. Not a correctness issue, just a bit of redundant work per CHECK DATABASE run - probably not worth restructuring given the other costs already dominate (as the PR itself notes for the sealed-store CRC scan).
  2. The two "found but not fixed" items called out in the description (BlockEntry.blockStartOffset/storedCRC populated only by loadDirectory(), and the sealed-store CRC pass being O(file size)) are real latent items but explicitly deferred with clear reasoning - agree with not scope-creeping them into this PR.
  3. checkModeMatches/version guard failures leave an orphaned file-id slot and a registered-but-unused ComponentFile behind (same as the existing id/page-size guards) - already documented as deliberate in both the code comments and the regression test's finally block, so not a new concern, just flagging that a caller triggering this in production (which per the PR analysis has no legitimate path today) would leak a slot until process restart.

Test coverage: the 11 new cases look genuinely adversarial rather than happy-path-only - especially aSealedBlockWhoseBytesChangedFailsItsCRC's second-run assertion (pins that a cached crcValidated flag doesn't let a repeat CHECK DATABASE report false-clean) and the residue-shaped aMutableBucketWhoseHeaderCountsSamplesItsPagesDoNotHoldIsReported test that directly reproduces the #6314 defect shape. Good regression coverage.

No security or license concerns - no new dependencies, and the new code is read-only (no FIX arm for TimeSeries, deliberately, per the PR's own reasoning about append-only sealed stores).

…th the totals measured where the verdict is

Code review on #6347 noted that checkTimeSeries visited each type's shards three times - once for the sealed
block count, once for countSamples(), once for the check itself - each with its own lock acquisitions.

Folded into one pass, and the reason it is worth doing is not only the two walks it saves: the totals used to
be sampled OUTSIDE the shard's append and compaction locks, so on a live database the sample count reported
alongside a verdict described a different instant than the walk that produced the verdict, and a compaction
landing in between moves rows from the mutable half to the sealed one. Each shard now measures itself inside
the same lock window it checks itself in, and returns both together as an IntegrityReport.
@claude

claude Bot commented Aug 18, 2026

Copy link
Copy Markdown

Review

This is a well-scoped, well-tested PR that closes out the file/component agreement work from #6283/#6314 and adds real (if intentionally limited) integrity checking for TimeSeries storage. I read the full diff plus the two commits (including the follow-up that folded the three-walks-per-type check into one under a single lock window - nice catch on that already). Overall this looks solid; a few things worth a look before merge.

Possible concern: CHECK DATABASE blocks live writers/compactors for the whole sealed-store CRC scan

TimeSeriesShard.checkIntegrity() takes appendLock first and then compactionLock.readLock(), and holds both for the entire duration of mutableBucket.checkIntegrity() and sealedStore.checkIntegrity() combined. The sealed-store check reads and CRC32s the whole .ts.sealed file sequentially - the PR description itself calls this "the dominant cost of a CHECK DATABASE" on a large TimeSeries type.

Holding appendLock for that entire scan means appendSamples() on that shard is blocked for the whole CRC pass, and holding compactionLock.readLock() blocks a concurrent compaction's writeLock() too. On a large, actively-written shard this could stall ingestion for a non-trivial amount of time during a routine CHECK DATABASE run. The PR description flags the CRC pass as expensive and floats making it opt-in "worth deciding once someone has one" - it might be worth calling out explicitly (in a comment or issue) that the cost isn't just CPU/IO, it's writer stall time, since that changes the urgency of the opt-in decision.

Minor: short reads not checked in TimeSeriesSealedStore.checkIntegrity()

final ByteBuffer headerBuf = ByteBuffer.allocate(HEADER_SIZE);
indexChannel.read(headerBuf, 0);
headerBuf.flip();

and the per-block stored-CRC read further down don't check the return value of FileChannel.read. FileChannel can legally return fewer bytes than requested (more plausible on network filesystems than local disk). Not a big deal in practice given the file-length checks that already gate these reads, but since this is specifically an integrity-checking code path, a short read silently zero-filling part of the buffer would produce a misleading CRC/header mismatch message rather than a clear "couldn't read" one. Might be worth a while loop or a bytes-read assertion if this needs to be robust against non-local filesystems.

Cosmetic: duplicate warnings on a missing page

In both TimeSeriesBucket.checkIntegrity() and TimeSeriesTagDictionary.checkIntegrityUnderLock(), when a declared data page is missing, the loop both adds a "page N is not in the file" problem and breaks with a short count, which then trips the trailing "declares X but pages hold Y" check too. So a single missing page produces two warnings for the same root cause. Harmless, but slightly noisy for an operator reading the report.

Design choices that look right but are worth double-checking against intent

  • FileManager.getOrCreateFile(name, path, mode) now throws IllegalStateException on a mode mismatch on a hit. You verified (and I independently grepped) that PaginatedComponent's constructor is the only caller, so this is safe today - just flagging that this is a behavior change with no fallback, so any future second caller needs to be mode-aware from day one rather than silently reusing a wrong-mode handle.
  • The new version guard in PaginatedComponent is a hard tripwire (IllegalStateException, no recovery). Given every load path derives version from the file name and every creation path bakes it in, this should never fire on legitimate code - agreed with the framing in the PR description. Worth keeping an eye on this in HA scenarios if a follower ever legitimately needs to open an older-version file during a rolling upgrade, though nothing in this diff suggests that's a real scenario today.
  • checkTimeSeries is intentionally not narrowed by BUCKET scope (consistent with how checkIndexes behaves), which is reasonable given a TimeSeries shard isn't a bucket - just noting it so it's a documented decision rather than a surprise if someone runs CHECK DATABASE BUCKET X expecting TimeSeries types to be skipped entirely.

Test coverage

Good coverage of the interesting edge cases: healthy walk with counters, the #6314 residue shape (header overcounting samples), missing magic in both the mutable bucket and the tag dictionary, a flipped CRC byte checked twice in the same process (pins the "not cached/short-circuited" requirement), TYPE scope isolation, and a database with no TimeSeries type at all. The Issue6340ComponentFileAgreementTest cases for the mode/version guards look correctly targeted (both the refusal and the legitimate "opens normally" case).

Nit

Some of the javadocs here run quite long (multi-paragraph, with embedded rationale and issue-number references) - I see this matches the established convention elsewhere in this codebase/PR series, so not asking for a change, just noting it stands out a bit against the CLAUDE.md guidance to keep comments to the non-obvious "why" only.

Nothing above blocks merge in my view; the lock-hold-duration point is the one I'd want a maintainer opinion on before this ships against a production-sized TimeSeries dataset.

…t reads stop reading as corruption, and one missing page reports once

Three points from the second code review on #6347.

1. The expensive half no longer stalls writers. TimeSeriesShard.checkIntegrity() held appendLock and the
   compaction read lock across BOTH halves, so a CHECK DATABASE blocked ingestion on the shard for as long as
   it took to CRC32 the whole .ts.sealed file. It only ever needed them for the mutable half, whose counters
   are raised by every append: the sealed store's own directoryLock read lock already excludes every path that
   mutates that file, all six of which take its write lock. The short window now covers the mutable check and
   both totals - so the numbers still describe one instant - and the sequential read runs outside it.

2. The two raw indexChannel.read() calls in the sealed-store check go through readBytes(), which loops. A
   FileChannel may return fewer bytes than asked for and leave the rest of the buffer zeroed; anywhere else
   that costs a retry, but in an integrity check it turns a short read into a header or CRC that does not
   match - a false accusation of corruption from the code whose job is to tell the two apart.

3. A single missing or unwalkable data page produced two findings: the page itself, and then the aggregate
   "declares X but the pages hold Y" that the short count tripped. The aggregates are statements about every
   page, so they are now skipped when the walk did not reach the end - the same rule the sealed-store walk
   already followed for its own totals.
@claude

claude Bot commented Aug 18, 2026

Copy link
Copy Markdown

Review of #6347

Read through all three commits (initial change + two review-driven follow-ups) end to end. This is a well-scoped, well-documented change, and it's clear the two rounds of self-review already caught the substantive issues (lock scope creep in TimeSeriesShard.checkIntegrity(), short-read false positives via bare indexChannel.read(), and double-reported findings on a truncated walk). A few notes from a fresh pass:

Code quality / correctness

  • Verified the version guard is safe against the "in-place format upgrade" scenario. PaginatedComponent's new file.getVersion() != version check (PaginatedComponent.java:122-125) could in principle trip on a component that intentionally reopens an old file and claims a newer format. I checked all constructors that reach PaginatedComponent (Dictionary, LocalBucket, TimeSeriesBucket) and every one either bakes the version into the generated file name on creation or passes the version parsed off the file name straight through on load - there's no in-place migration path in the engine today that would be broken by this. Good.
  • Confirmed FileManager.getOrCreateFile(String, ...)'s new IllegalStateException really has only one caller (PaginatedComponent's constructor), matching the PR description's claim. The only registered-file hit today is TimeSeriesTagDictionary's build-on-existing-file constructor, which now correctly sources the mode from ComponentFile.getMode() rather than hard-coding READ_WRITE.
  • ComponentFile.mode is final (ComponentFile.java:37), so getMode() is safe to read without additional synchronization - no visibility bug there.
  • checkTimeSeries in DatabaseChecker follows the exact same shape as the existing index/document passes: bounded warnings via addWarning/CollectionUtils.addBounded, stepBegin/stepTick/stepComplete accounting that matches the tick count, and a broad catch (Exception e) around the per-type check that mirrors the index arm's "a failed check is itself a finding" convention. Consistent with the rest of the class.
  • Nice catch (in the second review round) that TimeSeriesShard.checkIntegrity() no longer holds appendLock/compactionLock across the sealed-store CRC scan - that scan is O(file size) and would otherwise stall ingestion on the shard for the duration of a full sequential read. The tradeoff is documented explicitly (a compaction between the two lock windows can leave the totals and the sealed-store verdict describing slightly different instants), which is the right call for a report-only check.

Minor observations (non-blocking)

  • TimeSeriesSealedStore.checkIntegrity() still holds directoryLock.readLock() for the entire block-CRC scan. Since it's invoked once per CHECK DATABASE run rather than on a hot path, this is fine as-is, but worth keeping in mind if CHECK DATABASE is ever run concurrently with heavy compaction on very large sealed stores - a long-held read lock could delay a waiting writer for the length of the scan. Not something this PR needs to solve (the PR body already flags the cost itself as an open question for whether the CRC pass should become opt-in), just flagging it as the natural next friction point.
  • Nothing else stood out - no missing null checks, no obvious off-by-one in the page/entry walks, and the "stop cleanly on the first unwalkable page and skip the aggregate checks" pattern (added in the second review commit) is applied consistently across TimeSeriesBucket, TimeSeriesTagDictionary, and TimeSeriesSealedStore.

Test coverage

Both new test classes are solid:

  • Issue6340ComponentFileAgreementTest covers all three guards (mode accessor, by-name mode refusal + idempotent hit, version mismatch at construction) plus the legitimate non-CURRENT_VERSION case, which is the important negative-space test proving this is a tripwire and not a compatibility gate.
  • Issue6340TimeSeriesCheckDatabaseTest is thorough: healthy walk with counter assertions, the actual Follow-ups from #6283: TimeSeries components discard their file's page size, the by-id getOrCreateFile is unguarded, and a vector pool test asserts against its own contract #6314 residue shape (header over-counting samples), missing header magics for both bucket and dictionary, a sealed-block CRC flip checked twice in the same process (which specifically pins the "recompute from file, don't trust the cached crcValidated flag" behavior), TYPE scoping, and the no-TimeSeries-type zero-state case. Good use of isCheckingDatabaseIntegrity() == false override since several tests deliberately leave a damaged file behind.

I wasn't able to run mvn verify in this environment to independently confirm the "12,474 tests green" claim in the PR description, but the code read didn't surface anything that would contradict it.

No blocking issues found. Nice work tightening the locking and read-correctness during self-review - that's exactly the kind of thing that's easy to miss on a first pass with this much new I/O code.

@codecov

codecov Bot commented Aug 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 67.73050% with 91 lines in your changes missing coverage. Please review.
✅ Project coverage is 70.71%. Comparing base (7f748a2) to head (6f067b0).
⚠️ Report is 8 commits behind head on main.

Files with missing lines Patch % Lines
...adedb/engine/timeseries/TimeSeriesSealedStore.java 50.00% 19 Missing and 16 partials ⚠️
...edb/engine/timeseries/TimeSeriesTagDictionary.java 54.38% 16 Missing and 10 partials ⚠️
...m/arcadedb/engine/timeseries/TimeSeriesBucket.java 71.42% 11 Missing and 9 partials ⚠️
...main/java/com/arcadedb/engine/DatabaseChecker.java 80.43% 7 Missing and 2 partials ⚠️
...src/main/java/com/arcadedb/engine/FileManager.java 85.71% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6347      +/-   ##
==========================================
- Coverage   70.92%   70.71%   -0.22%     
==========================================
  Files        1849     1850       +1     
  Lines      160508   160714     +206     
  Branches    33762    33787      +25     
==========================================
- Hits       113845   113650     -195     
- Misses      32570    33084     +514     
+ Partials    14093    13980     -113     

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

…nothing exercised

Codecov flagged 62.41% of the diff hit against a 70.92% target, and on an integrity checker that number means
something specific: most of the uncovered lines are the branches that only ever run when a file IS damaged, so
each one was a message that had never been proven to fire. Seven more shapes are now pinned - a header format
version disagreeing with the file name, a header column count disagreeing with the schema, a header announcing
data pages the file does not hold, a file that is not a whole number of its own pages, a tag dictionary
claiming more entries than its pages hold, a sealed header miscounting its blocks, and bytes belonging to no
block after the last one.

The data-page one also regression-tests the round-2 fix that a single missing page reports ONCE: it asserts
exactly one mutable-bucket warning, where before the aggregate sample-count check would have added a second
finding for the same root cause.

One shape is deliberately NOT tested, and it turned up a separate defect while trying: corrupting the sealed
store's own header magic produces no finding at all, because TimeSeriesSealedStore's constructor throws on a
bad magic, initEngine() then fails during schema load, and the TimeSeries type disappears from the schema
entirely - the database reopens cleanly with the type simply gone (probed: totalTimeSeriesTypes=0,
existsType=false). A check cannot report a type that is no longer there. That silent disappearance is in the
schema load path rather than in this pass, and it is recorded as a comment where the test would have been
rather than pinned, because a test asserting the current behaviour would enshrine it.
@lvca

lvca commented Aug 18, 2026

Copy link
Copy Markdown
Member Author

Follow-up filed from this work: #6356 - corrupting a .ts.sealed header magic makes the whole TimeSeries type disappear from the schema on reopen, with the database opening cleanly and reporting nothing. It is in the schema load path (LocalSchema.readConfiguration never reaches types.put when initEngine() throws, and the ConfigurationException is then swallowed), so this PR's checker cannot reach it - a type that is not in the schema is not walkable. Left as a comment where the test would have gone rather than pinned, since asserting the current behaviour would enshrine it.

@claude

claude Bot commented Aug 18, 2026

Copy link
Copy Markdown

Review

Went through the full diff (engine changes + both new test classes) with the PR description and commit history as context. This is a careful piece of work, and it shows: the three self-review commits already fixed the lock-contention issue (holding appendLock across the sealed-store CRC scan), the short-read false-positive risk in TimeSeriesSealedStore.checkIntegrity(), and the duplicate-finding issue when a single missing page tripped both a page-level and an aggregate warning. I verified those fixes are actually in the current diff, not just described.

Things I specifically checked and found sound:

  • The new PaginatedComponent version guard is safe for existing databases. This was my main worry going in - a check like this could break opening older on-disk files if any component re-derives CURRENT_VERSION instead of using the file-parsed version. I traced every PaginatedComponentFactoryHandler.createOnLoad registered in LocalSchema (LocalBucket, HashIndex, LSMTreeIndex/LSMTreeIndexCompacted, LSMTreeIndexBloomFilter, Dictionary, TimeSeriesBucket, TimeSeriesTagDictionary, LSMVectorIndex, SparseSegmentComponent) and confirmed ComponentFactory.createComponent passes the filename-parsed version uniformly to all of them, and each load constructor forwards it straight through. So the guard is a true tripwire, not a compatibility risk, as claimed.
  • checkModeMatches/checkFileNameMatches in FileManager are correctly placed both outside and inside the synchronized block for the double-checked-locking pattern, mirroring the existing by-id sibling.
  • Test offsets in Issue6340TimeSeriesCheckDatabaseTest (4, 5, 7, 40 for format version/column count/sample count/data page count) line up exactly with TimeSeriesBucket's private HEADER_*_OFFSET constants, so the corruption injection is hitting the fields it claims to.
  • readBytes() in TimeSeriesSealedStore does loop on short reads as the commit message claims, so the described false-positive fix is real.
  • TYPE scope filtering is applied once at classification time in check() (before types are bucketed into timeSeriesTypes), so checkTimeSeries doesn't need its own scope logic - consistent with how edge/vertex/document types are already handled.

Minor observations, not blockers:

  • checkTimeSeries's catch (final Exception e) swallowing is consistent with the existing index-check arm, but as with that arm, an interrupted thread (InterruptedException wrapped, or similar) would get silently absorbed as a "corrupted" finding rather than propagated. Pre-existing pattern, not something this PR should have to fix.
  • The TimeSeriesTagDictionary.checkIntegrity() holds internLock (a plain mutual-exclusion lock, not read-write) for the whole walk. The PR doc rightly notes this is fine because interning is only contended during warm-up, but it's worth keeping in mind if CHECK DATABASE is ever run against a dictionary under heavy concurrent tag-interning load in production - the check would fully serialize against writers for the walk's duration. Given the documented tradeoff and the intern-lock's typical low contention, this seems like an acceptable and deliberate design choice rather than an oversight.
  • The deliberately-not-tested case (sealed-store header magic corruption causing the whole TimeSeries type to silently vanish from the schema on reopen) is a real latent issue, but it's called out explicitly in a code comment as a separate follow-up rather than something this PR should fix, which seems like the right call given the scope.

Test coverage: the two new test classes (11 cases total) are thorough - they cover both the refusal and legitimate paths for each of the three PaginatedComponent guards, plus healthy/corrupted/empty-database shapes for the TimeSeries check, the TYPE scope, and the "one finding per root cause" dedup rule. The double-CRC-check test (asserting a second CHECK DATABASE run still reports the corruption) is a nice touch that specifically targets the lazy-CRC-caching pitfall called out in the design doc.

Overall: no correctness, security, or performance issues found beyond what's already been self-identified and either fixed or explicitly deferred with justification. Nice work.

@lvca lvca added this to the 26.9.1 milestone Aug 18, 2026
@lvca
lvca merged commit 1c219a7 into main Aug 18, 2026
26 of 28 checks passed
@lvca
lvca deleted the issue-6340 branch August 18, 2026 04:36
lvca added a commit that referenced this pull request Aug 26, 2026
…6398, #6394, #6356)

#6398: HttpAuthSessionManagerTest's idle/absolute timeout assertions raced Thread.sleep against
short timeouts, leaving as little as 40ms of headroom against a JVM stop-the-world pause. Both
HttpAuthSession and HttpAuthSessionManager now accept an injectable clock (defaulting to the wall
clock in production); the whole test class drives a fake clock instead of sleeping, so it cannot
flake and runs in a fraction of the time.

#6394: PageManager.openSnapshotInternal's t0 barrier throws PageSnapshotException from two call
sites with opposite operational meaning (a transient suspend-timeout under load vs. a fatal
in-flight-flush timeout), distinguishable before this change only by parsing getMessage(). A test
for the fatal case inferred which one happened from elapsed time instead, which flakes whenever
trySuspendUntil loses its own race first under a full-suite run. PageSnapshotException now carries
a Reason enum; the test asserts the reason directly and drops the timing-based proxy.

#6356: a TimeSeries type whose .ts.sealed store failed to load (e.g. one bit-flipped byte) used to
vanish from the schema entirely - LocalSchema.readConfiguration wrapped the failure in a
ConfigurationException that aborted the whole type's registration, and the outer catch-and-log in
the same method swallowed it, so the database reopened cleanly with the type simply gone and no
warning. DatabaseChecker#checkTimeSeries already had an unreachable branch for exactly this case
("the storage engine is not initialised"), from #6340/#6347, which is the strongest signal for the
intended design: register the type with its engine unavailable rather than refusing the whole
database open (the sealed store is HA-derived and rebuildable by recompaction) or silently
discarding it. LocalTimeSeriesType now tracks why its engine is unavailable and exposes
requireEngine() for read/write call sites to fail loudly and by name instead of NPEing or silently
falling through to generic document handling.

Co-authored-by: Luca Garulli <l.garulli@arcadedata.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant