Repository navigation
Increase custom datatype limit and make it explicit - #1966
Conversation
aaj3f
left a comment
There was a problem hiding this comment.
@zonotope this is both a nice relaxation of an overly strict characteristic AND a good way of pinning the bound on datatype limits to the actual characteristic that sets that bound. The only headline items I'd mention / suggest attention to in the full review below are (1) the introduced performance cost in check_commit() for a scenario that's unlikely to be true for most users/datasets/commits and (2) a move from a debug_assert() to an assert() in a read path that introduces a possibility of runtime panics rather than a degradation. Full review below:
"a ledger can accept data it can never index" is about the worst failure shape there is, and deriving the new ceiling from OType::MAX_PAYLOAD rather than picking a bigger number means it can't drift out of sync with the format again. datatype_dict_id_limit_matches_customer_payload pinning the two together, collapsing the four ad-hoc u8 guards into one checked_dt_id, and hoisting the fifteen reserved IRIs out of new_datatype_dict()'s hand-numbered get_or_insert list into DatatypeDictId::RESERVED_IRIS are all exactly the right altitude. The test file is genuinely strong — the concurrency case that stages two writes before either commits, and the unseeded-runtime-dictionary case, are the two I'd have worried about and both are covered. The push dictionary-population fix riding along is a real bug in its own right.
I'm requesting changes on three things, none of which touch the core of the change:
🟠 The read path now panics where it used to degrade (o_type.rs:266 → o_type_registry.rs:127). Promoting debug_assert! to assert! is right for the write boundary and, I think, wrong for the read one. OTypeRegistry::builtin_only() builds dt_otypes with exactly RESERVED_COUNT entries, so in decode_value_from_kind every custom datatype takes the else arm — the one whose comment says "dt value beyond what we know — treat as customer datatype" — and that arm is now a panic. Its input is a u16 straight off a decoded row (run_record.rs:418), and DatatypeDictId(pub u16) still has an unchecked from_u16 beside your new try_from_dict_id. A damaged or future-format dt column takes down the query instead of decoding as unknown. I couldn't build a normal-operation input that reaches it, so I don't think it's live — but it's the fallback branch, and I'd rather it stayed a fallback. Suggested shape in the inline comment.
🟠 The test check is red and the body accounts for a different test. grp_index::it_indexing_workflow::file_based_indexing_then_new_connection_loads_and_queries times out at 360 s on this head; "Known limitations" names it_cached_handle_cow_cancel. I chased it rather than just flagging it: that test runs in 0.278/0.406/0.496 s on #1968/#1969/#1971's CI the same week, passes in 2.5 s in isolation on this branch, and the full grp_index binary passes 104/104 locally — so it isn't a deterministic regression. But locally it also inflates to 26.7 s under contention, and your new insert_past_datatype_limit_is_rejected_from_index takes 42.4 s, so I suspect the new file's weight tipped a neighbour over the wall on a smaller runner. A re-run would settle it; if it recurs, moving the 300-datatype round-trips into their own target seems better than leaving them competing with the indexing suite.
🟡 Every commit pays a per-flake scan for a ceiling nothing is near (commit.rs:667). check_commit walks all flakes, and the last == Some(dt) run-skip doesn't fire on ordinary mixed-datatype commits, so most flakes pay a 15-IRI strip_prefix scan plus a hash lookup on the write hot path. Since adding can never exceed flakes.len() + txn_meta.len(), used + flakes.len() + txn_meta.len() <= MAX_NON_RESERVED_DATATYPES is an exact O(1) proof the check can't fail — three lines that make it free for every ledger that isn't already near 16,369, and skip the known_datatypes() rebuild with it.
Two smaller ones inline: the push path now holds a full all_flakes clone across steps 4.1–4.4 when it only needs the datatypes (🔵, question more than suggestion), and the docs cover the upgrade direction but not the downgrade one — past 241, a rollback makes the ledger un-indexable again (🔵).
One false lead I chased and want to save you the trouble on: the u8 datatype widths in class_stats.rs and stats_wire.rs look like something a 16,383 ceiling would overflow, but class_prop_dts is keyed by ValueTypeTag::as_u8() as u16, not a dict id, so they're fine. Same for old-reader-new-writer — payload() has always masked the full 14 bits and there's no other u8 narrowing on a read path, so an index carrying dt ids in [256, 16383] decodes correctly on an older reader.
Adherence to repo commitments
- Patterns / abstractions — ✔. Extends
DatatypeDictId/OType/RuntimeSmallDictsrather than adding a parallel construct;RESERVED_IRIScollapses a duplicated list into the shared one; the new error extends each crate'sthiserrorenum throughTransactError→ApiError→SubmissionError→ServerErrorwith noanyhowor newunwrapon a fallible path. - Performance (speed first, memory second) —
⚠️ . No query-engine regression, and the indexer changes are a comparison for a comparison. Butbuild_commitgains an unconditional per-flake pass with no headroom early-out, andknown_datatypes()can rebuild a wholeRuntimeSmallDictsper commit. Both are fixed by the three-line guard in PERF-1. - Testing —
⚠️ . Coverage is excellent and correctly wired (grp_index.rsdeclares the new file, so it does run; the 11 new tests are all PASS in the CI log). The mark-down is that the suite does not currently pass —testis red on this head, on a neighbouring test, for a reason the body doesn't name. - Conventions — ✔. Ten self-describing lowercase-imperative subjects; the PR body is one of the more thorough I've read, including the per-test mutation checks and an honest "Known limitations". Docs updated across
concepts/datatypes.md,api/errors.md,troubleshooting/common-errors.md,api/endpoints.md,cli/push.md,cli/create.mdanddesign/index-format.md. Clippy and fmt both green.
Happy to talk through any of these — the read-path one especially, since I may be over-weighting a path you've already convinced yourself is unreachable.
| /// would spill into the tag bits and silently become a different type. | ||
| /// Callers bound it through [`crate::ids::DatatypeDictId::MAX`]. | ||
| #[inline] | ||
| pub const fn customer_datatype(payload: u16) -> Self { |
There was a problem hiding this comment.
🟠 CRITICAL-1 — the read path now panics where it used to degrade
(the assert! in customer_datatype)
The debug_assert! → assert! promotion here is the right instinct in the wrong direction, and I think it needs to be split: hard-fail at the write boundary, keep degrading at the read boundary.
The write side is already covered — checked_dt_id in the resolver, check_commit in build_commit, the push and import checks — so nothing legitimate can mint a payload past MAX_PAYLOAD any more. But customer_datatype is also called from the decode path, and there the input is bytes off disk rather than an id the engine just allocated.
Concretely: OTypeRegistry::builtin_only() is new(&[]), so dt_otypes.len() is exactly RESERVED_COUNT (o_type_registry.rs:31-55). That means in binary_index_store.rs:2630 (decode_value_from_kind, the late-materialization route for Binding::EncodedLit) and :2613 (o_type_from_kind), every dt_id >= 15 falls into the else arm at o_type_registry.rs:127:
} else {
// dt value beyond what we know — treat as customer datatype.
OType::customer_datatype(dt.as_u16())
}That comment is the point — that arm is the documented graceful handling for a dt the registry doesn't recognize, and it is now a process panic for any dt_id > 16383. The dt_id it receives is unvalidated: DatatypeDictId is pub struct DatatypeDictId(pub u16) with a plain from_u16, and FactKey::from_decoded_row builds one straight from Region 2 decode output (run_record.rs:418, DatatypeDictId::from_u16(dt_raw as u16)).
Consequence: a truncated leaflet read, a bit flip in the dt column, or an index written by a future format that widens dt past 14 bits takes down the query — where before the PR it produced a wrong-but-contained OType and the query returned. That is the inverse of the fallback contract the engine holds elsewhere (docs/contributing aside, it's the same shape as the PSOT fast-path builders returning Ok(None) rather than propagating when overlay translation fails).
I could not construct a normal-operation input that reaches it — the write-side guards do look complete to me — so I'm not calling this a live bug. But the branch that now panics is precisely the unknown-dt branch, and its input is decoded bytes, so I don't think it should be the one that aborts.
Minimally, degrade in the registry and leave the assert! as the write-side guard it was meant to be:
fn resolve_by_dt(&self, dt: DatatypeDictId) -> OType {
let idx = dt.as_u16() as usize;
if idx < self.dt_otypes.len() {
self.dt_otypes[idx]
} else if dt.as_u16() <= OType::MAX_PAYLOAD {
OType::customer_datatype(dt.as_u16())
} else {
// A dt past the payload width can only come from a damaged or
// future-format row; decode it as unknown rather than aborting.
OType::RESERVED
}
}Or, if you'd rather keep one bound in one place, give DatatypeDictId::from_u16 the same Option treatment try_from_dict_id has and make the decode sites say what they want on overflow. Either way the write path keeps its hard guard.
Commenting here because fluree-db-core/src/o_type_registry.rs:127 and fluree-db-binary-index/src/read/binary_index_store.rs:2630 are not in this diff.
| mod support; | ||
|
|
||
| #[path = "it_custom_datatype_limit.rs"] | ||
| mod it_custom_datatype_limit; |
There was a problem hiding this comment.
🟠 CI-1 — the test check is red, and the PR body accounts for a different test
(the line that wires the new file into this binary)
The test job on 7e3de3b7c fails: grp_index::it_indexing_workflow::file_based_indexing_then_new_connection_loads_and_queries hits nextest's terminal timeout at 360.005 s (run 36263447359). The "Known limitations" section names it_cached_handle_cow_cancel as the intermittent one, so this failure isn't covered by it.
I dug into whether this is a regression and I don't think it is, so I want to give you what I found rather than just "CI is red":
- That test took 0.278 s on feat(cli): fluree server run --memory for a throwaway in-memory server #1968's run, 0.406 s on feat(api): branch from a point in time with at: "time:<ISO>" #1969's, 0.496 s on Accept TriG on insert (API, server, CLI) #1971's — all within a day of this branch.
- On this branch it passes in 2.531 s in isolation locally, and the full
grp_indexbinary passes 104/104. - But in that full local run it took 26.696 s under contention, and the new
it_custom_datatype_limit::insert_past_datatype_limit_is_rejected_from_indextook 42.443 s.
So my read is that the new file lands several genuinely heavy tests into grp_index and the CI runner — which is fighting 13,429 tests at once on far fewer cores than my laptop — tipped a neighbour over the 360 s wall. It's a plausible story but it is still a story, and a 0.4 s test going to >360 s is a big jump to attribute entirely to scheduling.
What I'd want before merge: a re-run. If it comes back green, a line in the PR body saying so is enough. If it recurs, I think the answer is to move the heavy round-trip cases (the 300-datatype full-rebuild and incremental ones) out of grp_index into their own target, so they stop competing with the indexing suite they were filed next to.
There was a problem hiding this comment.
This also happens on main and several other branches as well, so the flakiness predates this pr. I'll dig into it after this is merged.
| }); | ||
| } | ||
|
|
||
| // 4b. Datatype dictionary limit. Not subject to `skip_backpressure`: |
There was a problem hiding this comment.
🟡 PERF-1 — every commit pays a per-flake scan for a ceiling no ledger is near
(the check_commit call at step 4b)
check_commit walks every flake in the commit. For each one it compares the datatype Sid to the previous flake's, and whenever they differ it runs is_reserved_datatype() — a linear scan over the 15 RESERVED_IRIS doing strip_prefix plus a string compare — and then an FxHashMap lookup.
The last == Some(dt) run-skip in new_datatypes only pays off when flakes arrive grouped by datatype. In an ordinary commit they don't — a subject with a string, an int and a date alternates the datatype on essentially every row — so most flakes take the full reserved scan plus the lookup. That's a new linear pass over the commit's flakes on the write hot path, and build_commit is on every transaction, SPARQL update, merge, rebase, revert and Cypher write.
What makes it worth fixing rather than accepting is that the work is provably unnecessary almost always. adding can never exceed the number of flakes plus txn-meta entries, so if the remaining headroom already exceeds that count the check cannot fail. That's an exact O(1) guard, not a heuristic:
pub fn check_commit(base: &LedgerState, flakes: &[Flake], txn_meta: &[TxnMetaEntry]) -> Result<()> {
let known = known_datatypes(base);
// Each flake and each txn-meta entry contributes at most one new datatype,
// so a commit smaller than the remaining headroom cannot cross the limit.
let used = known.non_reserved_datatype_count();
if used + flakes.len() + txn_meta.len() <= MAX_NON_RESERVED_DATATYPES {
return Ok(());
}
let meta_datatypes: Vec<Sid> = txn_metaWith 16,369 non-reserved slots, that short-circuits every commit on every ledger that isn't already deep into custom datatypes — which is all of them today — and the full scan only starts running once a ledger is genuinely close to the ceiling, where you want it.
Worth noting known_datatypes() sits above this too: when persisted_datatype_count() != store.dt_sids().len() it rebuilds an entire RuntimeSmallDicts (cloning every datatype Sid, then re-assigning every runtime datatype through is_reserved_datatype) — per commit. The early-out above skips that as well, since the guard only needs non_reserved_datatype_count(). If the two counts routinely disagree on a freshly-loaded ledger, that rebuild is the more expensive half of the two.
There was a problem hiding this comment.
Fixed in 60949e1. The early exit also skips the dictionary rebuild too, and the reserved check is faster for big commits that the early exit doesn't cover.
| // cannot have come from any legitimate writer, since genuine commit | ||
| // records are derived from the envelope just below and never ride | ||
| // the flake stream. Without this an ingested ledger serves forged | ||
| // commit records until it is indexed (#1846). |
There was a problem hiding this comment.
🔵 OPT-1 — the push path now holds all_flakes across the whole validation section
Moving the forged-flake screening from 4.5 up to 4.0.2 is fine on its own terms — base_state isn't mutated inside the loop and c is an immutable borrow, so the all_flakes clone can't go stale, and I confirmed the drop_forged_commit_flakes / stamp_graph_on_commit_flakes pair still runs on every commit before it reaches novelty, so #1846's defense is intact.
The side effect is that a full clone of each commit's flakes is now live across steps 4.1–4.4 (retraction invariant, policy, staging, SHACL) instead of being created and consumed at 4.5. For a large push that's a longer-lived allocation at a point where the staging work is also holding memory. It's per-iteration so it doesn't accumulate across commits, and I don't think it's a problem — but since the reason for the move is to get all_flakes early for the datatype check, and the check only needs f.dt, I wonder if it's worth collecting just the datatypes at 4.0.2 and leaving the flake clone where it was. More of a question than a suggestion.
| - `graph_iris[]` (dict_index → graph IRI; `g_id = dict_index + 1`) | ||
| - `datatype_iris[]` (dt_id → datatype IRI) | ||
| - `datatype_iris[]` (dt_id → datatype IRI). Positions 0–14 hold the reserved datatypes | ||
| (`DatatypeDictId::RESERVED_IRIS`). A custom datatype's `o_type` carries its `dt_id` as a |
There was a problem hiding this comment.
🔵 OPT-2 — the downgrade direction deserves a sentence in the docs
The docs say a ledger already past 241 datatypes "need no migration and index again once upgraded", which is the right and reassuring half. The other half isn't stated: once a ledger accepts its 242nd custom datatype under this release, rolling the binary back makes it un-indexable again, because the old resolver's u8 guard returns. That's inherent and I don't think it needs anything but a note — but it's the kind of thing an operator wants to read before the rollback, not during it.
This is minor and non-blocking, but if you agree it's right I'd rather see it folded in now than lost in the backlog.
| // `u16::MAX` is past `DatatypeDictId::MAX`, so it never names a | ||
| // real datatype. The import refuses IDs past the maximum before | ||
| // any record reaches an `OType`. | ||
| u16::try_from(id).unwrap_or(u16::MAX) |
There was a problem hiding this comment.
⚪ NIT-1 — u16::MAX saturation is safe by ordering, not by construction
The comment is careful and the reasoning holds — current_custom_datatype_iris errors before sort_remap_and_write_sorted_commit runs, so a saturated id can't reach an OType. I traced it and agree.
The thing that nags slightly is that u16::MAX is now a value that would panic (per CRITICAL-1) if the ordering ever changed, and nothing in the type system says so. If CRITICAL-1 lands as a saturating/Option read path this stops mattering entirely, so it may just fall out.
Since #1966, resolve_by_dt returns OType::RESERVED for a datatype id past the o_type payload width, and OType::customer_datatype asserts on such an id. The LEX_ID arm passed every non-string-keyed result, RESERVED included, to customer_datatype, so a damaged or future-format row panicked on read instead of decoding as unknown.
Fixes #1751
Summary
A ledger with more than 241 distinct custom datatypes could not be indexed. The indexer rejected any datatype ID above
u8::MAX, but the index format stores it in a 14-bitOTypepayload. The limit is now that payload width: 16,369 datatypes beyond 15 reserved ones. Writes that would exceed it are refused before commit, so a ledger can no longer accept data it cannot index.Changes
Indexing
u8guards in the resolver are replaced by one check againstDatatypeDictId::MAX(16,383).OType::customer_datatypeasserts its payload width in release builds, as a guard on writes. A payload past 14 bits would spill into the tag bits and become a different type.OType::RESERVED, which decodes as an unknown value. Such an ID can only come from a damaged or future-format row.Rejecting writes past the limit
build_commitrefuses a commit whose new datatypes would not fit. It runs under the ledger write lock, so writes rebased over each other cannot jointly cross the limit. This covers transactions, SPARQL updates, merge, rebase, revert, and Cypher writes.RESERVED_IRIS.DatatypeDictId::RESERVED_IRIS. The indexer and the new check both use that list.RuntimeSmallDictscounts its reserved entries, so the number of non-reserved datatypes is O(1).Error type
err:db/DatatypeLimitExceeded, returned with HTTP 422 and noRetry-After.ApiError,SubmissionError, andServerError, so it reaches clients on the direct and local consensus paths.Push bug fix
Other
u16, so they cannot wrap onto reserved IDs.is_forged_commit_flakeis public, so the push limit check anddrop_forged_commit_flakesskip the same flakes.Docs
concepts/datatypes.mdhas a new section on custom datatypes and the limit, including upgrading to and downgrading from this release.api/errors.mdandtroubleshooting/common-errors.md.api/endpoints.md,cli/push.md,cli/create.md, anddesign/index-format.mdmention the limit where it applies.Tests
rebase_stagewithout restaging.INSERT DATAandINSERT ... WHEREwithSTRDT.OType::RESERVEDand decodes as null.OTypepayload boundary, and the error mapping at each layer.Known limitations
@type.