Make per-table record-structure dictionary size observable - #2250
Open
kriszyp wants to merge 7 commits into
Open
Make per-table record-structure dictionary size observable#2250kriszyp wants to merge 7 commits into
kriszyp wants to merge 7 commits into
Conversation
The typed (random-access) structure dictionary is append-only for the life of a table and bounded at 256 by RecordEncoder, but nothing reported its size and nothing signalled saturation — past the bound, novel record shapes permanently fall back to plain encoding with no log line, metric, or error. Expose the counts on the encoder and Table, add them to describe_table, emit a table-structures analytics gauge alongside table-size, and warn once per store when the dictionary saturates. Refs #2220 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rded warning - read counts from the durable shared-structures payload rather than the reporting thread's encoder arrays, which are per-worker and lazily loaded - report typed_structures_enabled: random-access fields default off, so typed 0 / limit 256 is disabled encoding, not spare headroom - fold the counts onto the existing table-size record instead of a new metric, which would have doubled retained per-table analytics rows - never let a failing log sink escape into a save whose structures committed; skip the check entirely when the save was declined - integration coverage that the describe_table fields actually reach the API Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…-shape fallback - remove the analytics hunk entirely: `table-size` is emitted only from the non-RocksDB branch of storeDBSizeMetrics, so on the default engine the fields would never have been written. The metric being LMDB-only is a separate gap. - report the encoder's typed count for the bare-array and cbor-x durable payload shapes, which carry named structures only -- structon keeps the local typed dictionary for those, so reading 0 showed an empty dictionary for a saturated store, inverting exactly the misread this change exists to prevent - correct the "remaining stats are O(1)" claim in describe now that one of them reads storage (~0.03ms on a saturated dictionary, measured) - state the saturation warning's real scope: per encoder, not per durable store - assert nonzero counts on a written table in the API test, not just field types Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…st claim Measured describe_all over 200 tables, 5 rounds each, with and against the structure-count read removed: 11.5-17.6ms vs 14.5-19.6ms. The marginal cost is below run-to-run variance, so no cache is warranted; the "only ones that read storage" claim was also wrong (getSize/getAuditSize read storage too) and is corrected. Kept the local-typed fallback for named-only durable payloads against the opposite reading: those payloads are written by the capped fallback path, so that window is by definition a saturated store and reporting zero would show an empty dictionary exactly when the count matters most. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two reviewers independently flagged that nothing exercised the bound through the real stack. Adds an integration suite that runs with storage.randomAccessFields enabled, writes 400 distinct record shapes, and asserts describe_table reports the dictionary saturated exactly at its limit (measured: typed 256/256, classic 32) and that records written past the bound still read back. Also fall back to "(unnamed)" in the saturation warning rather than emitting "for store undefined" when neither the encoder nor the root store carries a name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…he warning is logged msgpackr only defines getStructures when the option was supplied, so the wrapper's captured super could be undefined -- getStructureCounts() threw a TypeError on such a store, and it is now public API. Guard it and report zeros. The unit tests stub harperLogger, so nothing proved the saturation warning survives a real logger binding. The integration suite now reads it back through read_log; verified red with the checkStructureCapacity() call sites removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The claim that a worker restarted against a full dictionary never warns was wrong: msgpackr also calls saveStructures when the classic dictionary grows, so any save by a worker that has loaded a saturated typed dictionary reports it. Documented behavior I had not verified. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
|
Reviewed; no blockers found. |
Contributor
There was a problem hiding this comment.
Code Review
This pull request introduces tracking and observability for record-structure dictionary counts (both typed and classic) within the RecordEncoder and exposes them via the describe_table operation. It establishes a default maximum limit of 256 typed structures, warns when this limit is reached to alert operators of saturation, and adds comprehensive integration and unit tests to verify correct behavior and round-tripping of records beyond the cap. There are no review comments, so I have no feedback to provide.
kriszyp
marked this pull request as ready for review
August 21, 2026 23:31
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Answers the observability half of #2220 — Per-table msgpackr structure dictionaries grow unbounded and unobservably, with a wire-format cliff at id 63.
The typed (random-access) structure dictionary has been bounded at 256 since v5.1.0 (
c93a72611), andstorage.randomAccessFieldshas defaulted off since the same release (6ebe45474). What was still missing is that nothing reported the dictionary's size, and nothing signalled when it saturates — past the bound, novel record shapes are permanently stored without random-access field encoding, with no log line, metric, or error. The reporter found a 1,297-structure table by counting entries in a decoder dump inside an unrelated error message.describe_tabletyped_structures_enabled,typed_structure_count,typed_structure_limit,classic_structure_countTable.getStructureCounts()/RecordEncoder.getStructureCounts()No wire-format change, no bound change, no behavior change for a table below the bound.
For the human reviewer
Read
resources/RecordEncoder.tsfirst — everything else adapts what it returns.The counts come from the durable shared-structures payload, not from this thread's encoder arrays. That is the one non-obvious decision here, and the reason
getStructureCounts()is not a field read. Each worker owns its own encoder and loads/mints lazily, so a worker that never encoded for a store holds empty arrays for a store whose dictionary is full — reporting those would tell an operator there is full headroom when there is none.countDurableStructures()mirrors structon's ownonLoadedStructurespayload handling; look hardest here. The bare-array and cbor-x{structures}forms carry named structures only and structon keeps the local typed dictionary for them, so those two branches deliberately return the encoder's count rather than zero. Getting that backwards inverts the whole point of the change, and an earlier revision did.The warning deliberately reads the local array instead. It fires only from the post-save path, where this encoder's array is exactly what was just persisted — canonical at that moment, and free. The consequence is that its scope is per encoder, not per durable store: several workers can each warn, and a node restarted against an already-saturated dictionary warns not at all (saturation stops minting, so it stops saving). That asymmetry is intentional — the counts are the reliable signal and the warning is the heads-up — and it is stated in the docs companion rather than left to be discovered.
typed_structures_enabledexists becausetyped 0 / limit 256is ambiguous. Random-access fields default off, so most tables report zero typed structures — that is the feature being disabled, not spare headroom, and an alert oncount / limitwould read 0% either way.describe_tablenow reads storage.getStructureCounts()does onegetBinarySyncplus a decode of the shared-structures payload, which two review rounds flagged as a possibledescribe_allregression. Measured rather than argued —describe_allwithskip_record_count: trueover 200 tables, 5 rounds each, with the read and with it stubbed out:The distributions overlap, so no cache was added. Worst case is bounded: a fully saturated dictionary is ~7 KB and decodes in ~0.03 ms, and a pre-v5.1.0 table with a legacy over-cap dictionary (e.g. the issue's 1,297 entries) is ~5× that. The stale "the remaining stats below are O(1)" comment was wrong before this change too —
getSize()andgetAuditSize()read storage — and is corrected.No analytics metric, deliberately. The first revision folded these counts onto the
table-sizerecord. The review caught thattable-sizehas no RocksDB emitter at all —storeTableSizeMetricsis called only from the non-RocksDB branch ofstoreDBSizeMetrics— so on the default engine the fields would never have been written. Rather than ship a dead path or resurrect the metric as a rider here (it is a real cardinality decision), that hunk is gone and the gap is filed as The table-size analytics metric is never emitted on RocksDB #2249.What this deliberately does not do: no pruning and no renumbering. Structure ids are positional and referenced by every stored record, every transaction-log entry, and every unapplied replication backlog entry, so deletion silently reinterprets records; renumbering is only safe inside a full table re-encode, which is a migration feature rather than a fix. Rationale is written up in the issue reply.
Verification
unitTests/resources/recordEncoder.test.js, thirteen cases: the bound holds; a past-cap record still round-trips; counts come from the durable payload even when this encoder's arrays are empty; named-only payloads do not zero the typed count; zero for a never-saved store; the enabled flag; typed vs classic separation; warn-exactly-once; no warn with headroom; a throwing log sink does not fail the write; a declined save does not warn; zeros rather than a throw for a store with no shared-structures mechanism; and the growth model itself (key order and per-field value width each mint distinct shapes) asserted against the installed dependency behavior.typed: 0return; the saturation-warning unit test and the integrationread_logassertion, both with the twocheckStructureCapacity()call sites removed.integrationTests/apiTests/structure-dictionary.test.mjs(new) runs withstorage.randomAccessFields: true— the only configuration in which a table mints typed structures — writes 400 distinct record shapes, and asserts the dictionary saturates exactly at its limit, that the saturation warning comes back throughread_log, and that past-bound records still read back. Measured live: typed 256/256, classic 32, the latter matching msgpackr's ownmaxSharedStructuresbound. Theread_logleg matters because the unit tests stubharperLogger, so nothing else proves the operator-facing signal survives a real logger binding.integrationTests/apiTests/configuration.test.mjsasserts the four fields on a default-config table that has rows, so a nonzeroclassic_structure_countproves real values rather than plumbed-through defaults (26/26).test:unit:resources1654 passing.test:unit:maincould not complete locally (~/harper/database/system/LOCKheld by another local instance — known shared-lock contention, not a regression); relying on CI.Docs companion: HarperFast/documentation#633 — Document record-structure dictionary counts on describe_table