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

Open
lvca wants to merge 3 commits into
mainfrom
issue-6340
Open

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 wants to merge 3 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

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.

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