fix(server): one materialized table, written once and readable by every source that shares it - #1000
Conversation
|
Filed #1001 for the residual this PR's alias binding surfaced: serve bindings rebind by source NAME and are pushed package-wide, so a same-named source in another model can be served from the materialized table when their public columns overlap. Pre-existing in mechanism — the seam has always been name-keyed — and out of scope here. What this PR changed is which names are eligible: alias names (an #1001 has the proposed fix (match on |
199fc5e to
885f7db
Compare
Peer review (pre-merge)Checked against current head Simple frame: Malloy #3029 made “several sources, one physical table” the design ( Important
Nit
Out of scope (agreed)#1001 — name-keyed, package-wide serve bindings. Pre-existing; this PR widens which names bind. Leave it on that issue. Checklist for landing
|
|
Local materialization (Hammer) on
The review comment above is edited in place: rebase item struck; remaining importants are mid-loop |
885f7db to
d3541a7
Compare
|
Thanks — this was a good review. Rebased onto Important1. A pre-pass now claims each physical table before the loop starts, so the refusal lands before any CTAS. The loop keeps only the same-content case (an extra name for one artifact, nothing to build). Both derive the coordinate from one shared helper, so they can't drift on what table an instruction writes. The enforce test now asserts zero 2. Scenario 72's note contradicted its own assertions. Removed, along with Nits
Suite state3324 unit tests pass, typecheck and lint clean. Hammer: 78 pass, and four non-passes — all of which reproduce identically on plain
Flagging rather than fixing, since none is this PR's, but two look worth someone's attention: One other thing found while checking my own work: |
|
Reviewed the build dedupe and the alias routing closely. Both hold up: single write path with no bypass, no name can be bound twice, Worth fixing before merge, none of it blocking1. The collision check counts one collision once per graph that reaches it. For sources This is the same "once per graph that reached it" bug the write dedupe exists to fix, and there's already a test for it on the write side. Every collision test uses a single graph, so nothing catches it. Key the records by target rather than pushing to an array, and add a cross-graph case. Related, same counter: the pre-pass runs ahead of 2. The new key uses Destination 3.
4. The collision throw doesn't set The comment explains Smaller items5. The description argues for a design the code no longer has. It says the refusal "fires mid-loop, after the pair's first table is written" and defends that as bounded. The code refuses in a pre-pass before any write, which the release notes already state correctly. Worth updating so review attention doesn't go to the superseded argument. 6. One question on So: does the orchestrating host always send Nits: the alias lookup indexes a plain One structural question, open to the counter-argument: the dedupe and the read-routing fix are independently revertable, neither fixes the other's symptom, and both came out clean. The collision guard is new feature work and carries all four findings above. Splitting at the file boundary would put review weight where the risk is. The case against is that these are one model change seen from two sides, which is fair — but the diff is large enough that it isn't free. |
Several sources routinely map onto one artifact: `#@ persist` is inherited and `extend` does not change a source's materialization SQL, so a base and its extension compile to the same address and resolve to the same instruction. The build loop iterated per SOURCE, so it wrote the table once per name that reached it — and once per graph that reached it, since a source declared in one model and consumed in another appears in both models' graphs. It also let each source overwrite the manifest entry in turn, so the entry was attributed to whichever built LAST; on the storage tier that decides which name gets a serve binding, so the source that declared the persist could silently lose its own routing. Keyed on the artifact coordinate rather than the content address: the address says what a table contains, the coordinate says which table it is, and only the second answers "have I written this already". Two definitions colliding on one table is the other half of that guard, and follows the existing collision policy — warn by default, refuse under PERSIST_COLLISION_ENFORCE — rather than inventing a second one, since it catches the pair the load-time gate cannot see: physical names a host assigns. Signed-off-by: James Estes <james.estes@credibledata.com>
An extension of a persisted source must not get a table of its own — it adds query-time fields and changes no materialization SQL — but it must be able to READ the base's. On the storage tier it could not: bindings are derived one per manifest ENTRY keyed on `entry.sourceName`, an entry names only the source that built the table, and a base and its extension share one address and therefore one entry. So exactly one of the two names was bound and the other silently served live. The colocated tier was never exposed to this, because it substitutes through the same-connection manifest, which is keyed by address. `namesByAddress` supplies the rest of the names, read off the package's own build plan, which already carries the address per source — so nothing new travels on the wire or is stored per entry. Every name binds to the same virtual handle, which is what the handle is for: it is identity-scoped, so several sources resolving to one virtual table is the design rather than a collision. Each name still faces the eligibility gate on its own merits, and the physical table was already narrowed to the base's public columns at build time, so an alias cannot reach past what the base exposed. Signed-off-by: James Estes <james.estes@credibledata.com>
Both asserted exactly ONE build-plan row — that an extension of a persisted source must not appear as a plan source at all — and were known-red pending malloy PR 3012, which keyed `persist` on a source's own annotation. That PR was closed unmerged; its replacement, malloydata/malloy#3029, deliberately leaves `Model.getBuildPlan()` unchanged and documents one table with several sources as the ordinary case. So the expectation was not waiting on an upstream fix, it was the opposite of the design — and meeting it would break serving, because an extension's read binding is found by looking its address up in the build plan, so an extension absent from the plan cannot be routed. They now assert the invariant that does hold and that a regression would violate: both sources present, sharing ONE content address, so one table. Signed-off-by: James Estes <james.estes@credibledata.com>
`persist-without-flag-served-live` shows a `#@ persist` in a model missing `##! experimental.persistence` is inert, because the flagless model is skipped before `getBuildPlan()` can throw on it. That holds only while nothing else in the package carries the flag: an entry model that declares nothing but the flag and an import is enough for the walk to reach the imported definitions and materialize both of them. The flag is a property of the model being walked, not of the model that declared the annotation, so whether a `#@ persist` does anything is decided by a header in another file — and neither state warns. Mutating the warehouse makes the distinction visible, which the sibling scenario cannot do. Signed-off-by: James Estes <james.estes@credibledata.com>
The composite key used a raw NUL as its separator, which made a 3057-line file binary to every content search tool — `grep -rn` over the tree silently skipped it, while `git grep` and the rendered diff both showed an innocuous space, because their binary heuristic reads only the first 8000 bytes and the NUL sat at 71870. It was the only such file in the repo. Encoded as JSON instead, which keeps the delimiter unambiguous without a control byte: a physical name can contain a space, so a plain separator could genuinely make two coordinates read as one. Also records what the key does NOT catch — `Foo` / `foo` / `"foo"` land in different slots here but in one table on a case-folding engine, the same blind spot the load-time gate documents — and why the enforce refusal happening mid-loop is bounded rather than merely tolerated. Signed-off-by: James Estes <james.estes@credibledata.com>
…oute The alias group was looked up by `entry.sourceEntityId`. An instructed build stamps the CALLER's id on its entry — `executeInstructedBuild` treats an instruction's `sourceEntityId` as opaque precisely so a host may derive it however it likes — while the group is computed from the publisher's content addresses and `entries` is keyed by those. The two coincide only for a host that hashes exactly as the publisher does, so the fix worked for auto-run and silently degraded to one-alias routing for any other scheme, which is the bug it exists to fix. A control plane round-tripping the publisher's id verbatim keeps it latent; nothing in the contract holds it there. Grouped by address, keyed by NAME: the grouping has to use the publisher's addresses because that is what decides which sources share a table, and the key has to be a name because that is the one identifier an entry carries that means the same thing whoever built it. The virtual handle still comes from the entry, so it keeps agreeing with what the build wrote into the virtual map. The parameter is now required. The chained-upstream caller passes none and says why: it resolves the tables a downstream build reads, and a second name for a table already named there would declare one source twice in the rebind model. Signed-off-by: James Estes <james.estes@credibledata.com>
…host One counter carried two unrelated conditions, and its help text described only the benign one. A host minting a table per source where several sources share an artifact is wasteful but correct; two definitions with different content addresses materializing into ONE table answers a query from another source's data. Only the second is worth paging on, and it was indistinguishable from the first. `publisher_materialization_table_collision_total` is that condition, described as the serve-time-wrong-data case. It also makes a `PERSIST_COLLISION_ENFORCE` rollout measurable: the rate is what flipping the flag would start refusing. Signed-off-by: James Estes <james.estes@credibledata.com>
…ts own Two rules the re-key by name needed and did not have. Source names are not unique in a package — the wire plan is keyed by sourceID for that reason — so keying an alias group by name introduced two ways for one name to be bound twice. A name declared at more than one address is now dropped from aliasing: it cannot be resolved to a table by name at all, and map order was picking one. And an alias never claims a name some entry OWNS, since the owner is the source whose SQL produced that table. Either one put two `source: <name>` declarations in a single serve shape. That fails to compile and takes the storage tier out for EVERY model in the package, because bindings are pushed package-wide — and silently, because base-only is the tier the ladder returns without a probe, so the failure surfaces per query instead of at shape build where the tier-drop metric would have seen it. Fail-safe as to data, but a whole-package tier loss with nothing pointing at it. The grouping moves out of Package to `groupAliasesByName`, where it is a pure function over plan sources and can be tested directly — including that its output no longer depends on the order sources arrive in. Signed-off-by: James Estes <james.estes@credibledata.com>
The comment claimed the rebind model would declare one source twice and fail to compile. It would not: two aliases are two DISTINCT names on one handle, which is what the serve path emits and compiles. The real consequence runs the other way — a chained downstream that reads the extension's name finds it absent from the rebind model and recomputes its upstream from raw. Correct but slower, and a scope boundary rather than a constraint. Signed-off-by: James Estes <james.estes@credibledata.com>
The note called it merged-and-unreleased. It shipped in malloy 0.0.429 and is in 0.0.430; this repo is pinned at 0.0.427. The claim came from reading a `Version 0.0.431-dev` bump commit sitting above the merge and checking the npm `latest` version rather than what that version contains — 0.0.429 published 33 minutes after the merge landed. Nothing else in the note changes: the route still resolves here, and the scenario is still the canary for a bump. The bump is simply available now. Signed-off-by: James Estes <james.estes@credibledata.com>
Two definitions materializing into one table was refused mid-loop, when the build reached the second one — by which point the first table had already been replaced, and nothing puts the overwritten rows back: the failure path reclaims storage tables only, and a reclaim cannot restore data. The publish gate refuses this before any CTAS runs, and a rebuild now matches it. The reasoning that left it mid-loop was wrong. It held that a model-declared collision could never reach a build under this flag, because the publish gate rejects it — but collisions are ALWAYS warn-only at load, whatever PERSIST_COLLISION_ENFORCE says, so a package published before the flag went on stays loaded and arrives here with its collision intact. A pre-pass claims each physical table first, so the refusal lands before the first write, and one shared helper derives the coordinate for both it and the loop. Also says what an unattributable instruction set actually does, rather than claiming every table is built: `sourceID` is optional on the wire, and without it the address index is the only route back to an instruction and holds one per address — so the last instruction wins and the earlier names are never built. A test pins that, since its sibling stamps a `sourceID` and proves the opposite. Signed-off-by: James Estes <james.estes@credibledata.com>
… nothing `71-` and `72-` are taken on main (`chained-write-only` and its orchestrated twin), so these move to `76-`/`77-`. The front-matter `id` is the runner key, so only the numbering was ever cosmetic — and confusing. `extend-routes-to-the-base-table` still carried a note describing the bug it now disproves: that of a base and its extension only one name binds and the other serves live. The scenario asserts both stay stale after a mutate, so the note contradicted its own assertions; it goes, and `needs-attention` with it. The colocated twin's note was rewrite history with no open question, and the storage twin's kept only the residual that is still true. Signed-off-by: James Estes <james.estes@credibledata.com>
…ters Names `publisher_materialization_table_collision_total` as the one to alert on — it is serve-time wrong data, where the other two are volume and waste — and notes that its rate is what enabling PERSIST_COLLISION_ENFORCE would begin refusing, so a rollout can be measured before it is turned on. Signed-off-by: James Estes <james.estes@credibledata.com>
The physical target key answers "which table is this", and the table a write lands in is the one buildOneSource writes through -- the connection resolved from graph.connectionName. getBuildPlan groups only ROOT nodes by connection, so a nested dependsOn source is yielded under a root of another connection; keying on the source's would name a table in a connection the write never touches, and collapse two writes that land in different warehouses into one. The collision pre-pass keys the same way, so it cannot report a pair the guard does not dedupe. Signed-off-by: James Estes <james.estes@credibledata.com>
7b13549 to
744088d
Compare
Context
malloydata/malloy#3029 settled a question this repo had been working around. An extension of a
persisted source inheriting
#@ persist— and so appearing alongside its base as another name forone table — is by design, not a bug:
#@ persistis an annotation,extendnever changes asource's materialization SQL, and the docs it added state plainly that one table with several sources
is "the normal case, not an edge case". The alternative, malloydata/malloy#3012, would have made an
inherited
persiststop producing a build target; it was closed in favour of #3029, whichdeliberately leaves
Model.getBuildPlan()unchanged and puts the grouping in a newRuntime.getBuildTargets.To be clear about which follow-up this is: #3029's list includes "the publisher's builder moves
to
getBuildTargets". This is not that. This PR stays ongetBuildPlan()and fixes defects thepublisher has today — they do not depend on the migration, and two of them are live on the current
pin. The migration is still open, and is the last item below.
So: a materialized table stops being the thing one source owns and becomes an artifact that
several source names route to. A base and its extension compile to the same SQL, hash to the same
content address, and must share one physical table. That inheritance is load-bearing — without it an
extension of a persisted source could never be served from a table at all — and
#@ -persistis thedocumented opt-out.
The build and serve paths both assumed one source per table. Two defects came out of that, in
opposite directions.
1. One table was written more than once
executeInstructedBuilditerated per SOURCE, so it wrote a table once per name that reached it —and once per graph that reached it, since a source declared in one model and consumed in another
appears in both models' graphs. Each source also overwrote the manifest entry in turn, so the entry
was attributed to whichever built LAST.
Each physical table is now written once, and the key is the point:
Deduplicating on the address would have been wrong in one direction and deduplicating on nothing was
wrong in the other. The coordinate is the identity a write has.
Two definitions on one physical name is the same guard read backwards: different SQL, one table,
so each build overwrites the other's rows while both addresses resolve to it at serve time — a query
answered from another source's data. It follows the existing collision policy rather than minting a
second one: warn by default, refuse under
PERSIST_COLLISION_ENFORCE. It earns its place next tothe load-time gate (
Package.persistenceCollisionWarnings) by catching the pair that gate cannotsee — physical names an orchestrating host assigns, which are not in the package until the
instructions arrive.
The refusal fires mid-loop, after the pair's first table is written. That is bounded rather than
merely tolerated: under the same flag a package whose model declares the colliding names cannot be
published at all, so what reaches the in-loop throw is a collision between host-assigned names,
which a host minting generational names does not produce. The code says what would make it want
hoisting to a pre-pass.
2. An extension served LIVE instead of reading its base's table
The consequence of that last-writer attribution, and the half users would notice.
Serve bindings are derived one per manifest ENTRY, keyed on
entry.sourceName. An entry names onlythe source that built the table, and a base and its extension share one entry — so exactly one of
the two names was bound and the other silently served live, chosen by build order. Nothing in
the model text hinted at which.
deriveServeBindingsnow binds every source that shares the table, all on onevirtualHandle. Thatis what the handle is for: it is identity-scoped, and its own contract already describes several
sources deduping onto one virtual table.
Three rules make that safe, and each closes a way for one name to be bound twice — which would put
two
source: <name>declarations in a single serve shape, fail to compile, and take the storage tierout for every model in the package (bindings are pushed package-wide), silently, since base-only
is the tier the ladder returns without a probe:
because that is what decides which sources really share a table. The key must be a name, because
that is the only identifier a manifest entry carries that means the same thing whoever built it —
an instructed build stamps the caller's
sourceEntityIdon its entry, and the publisher treatsthat as opaque by contract.
package — the wire plan is keyed by
sourceIDfor that reason — so such a name cannot be resolvedto a table by name at all, and map order was picking one.
table.
The grouping lives in
groupAliasesByName, a pure function over plan sources, so it is directlytestable — including that its output does not depend on the order sources arrive in.
The colocated tier was never exposed to any of this: it substitutes through the same-connection
manifest, which is keyed by content address, so every alias already resolved.
What is preserved
BuildPlan,ManifestEntryand the instruction shape are identical.The alias group is read off the package's own build plan, which already carries a content address
per source, so nothing new travels or is stored.
table was already narrowed to the base's public columns at build time (
projectToPublicColumns),so an alias cannot reach past what the base exposed.
shape-bounds-physical-columns, bothvisibility leak guards,
host-binding-of-unplanned-sourceandhost-binding-honors-row-level-accessall stay green.#@ -persist. The sharpest thing this could have broken. The alias group is built frombuildPlan.sources, and an opted-out source is not persistent, so it never appears there — itjoins no group and gets no binding. Had it, it would share the base's address and begin serving the
frozen snapshot, inverting the documented opt-out.
opt-out-persist-recomputesstaying green isthe proof: it asserts the opted-out source recomputes live.
Metrics
Three counters, and the distinction between the last two is the point — they were one, and only one
of them is worth paging on:
publisher_materialization_duplicate_target_skipped_total— a source whose table this run alreadywrote. Ordinary for a package that extends a persisted source; a volume signal, not a fault.
publisher_materialization_shared_address_instructions_total— one content address instructed tobuild more than one table. Wasteful, not wrong: the publisher builds each and records one.
publisher_materialization_table_collision_total— two definitions materializing into one table.Serve-time wrong data. Its rate is also what flipping
PERSIST_COLLISION_ENFORCEwould startrefusing, so a rollout can be measured before it is enabled.
Scenarios
extend-routes-to-the-base-table(new). The design intent, asserted: no second table for theextension, but a routed read for it. It mutates the warehouse, so a routed read is distinguishable
from a live recompute — which is exactly what the existing extend scenarios could not do, and why the
lost routing had gone unnoticed.
extended-source-inherits-persistand its colocated twin — corrected, and no longer known-red.Both asserted exactly ONE build-plan row: that an extension must not appear as a plan source at all,
pending malloydata/malloy#3012. That PR was closed unmerged, and its replacement,
malloydata/malloy#3029, deliberately leaves
Model.getBuildPlan()unchanged and documents one tablewith several sources as the ordinary case. So the old expectation was not waiting on an upstream fix
— it asserted the opposite of the design, and satisfying it would break serving, because an
extension's read binding is found by looking its address up in the build plan. They now assert the
invariant that does hold and that a regression would violate: both sources present, sharing one
content address, so one table.
imported-persist-from-flagless-model(new). Found while building the above.persist-without-flag-served-liveshows that a#@ persistin a model missing##! experimental.persistenceis inert. That holds only while nothing else in the package carriesthe flag: an entry model declaring nothing but the flag and an
importis enough for the walk toreach the imported definitions and materialize them. The flag is a property of the model being
walked, not of the model that declared the annotation — so whether a
#@ persistdoes anything isdecided by a header in another file, and neither state warns. Pinned rather than changed; it pairs
with the open question in scenario 49's note.
Verification
tsc --noEmitclean.quoted-persist-name-colocated, an unrelated colocated quoting bug its own note scopes out.CREATE TABLEs where they should emit one; the collision test resolves successfully;extend-routes-to-the-base-tablereturned live values after a mutation; and each alias rule wasre-checked with that rule removed.
Follow-ups, deliberately not here
#1001 — serve bindings rebind by source name and are pushed package-wide, so a same-named source
in another model can be served from the materialized table when their public columns overlap.
Pre-existing in mechanism; this PR widens which names are eligible, and its alias rules cannot reach
it because they read
buildPlan.sourcesand a shadowed non-persist source is not in there. The issuecarries the proposed fix and the constraint on it.
Grouping on the host side. The wire build plan reports per-source rows, so a host that mints a
physical name per source has to group by
sourceEntityIdfirst or it will ask for two tables for oneaddress. The publisher cannot resolve that on the host's behalf — declining one of the two
instructions would leave the host holding an anchor for a table nothing ever wrote — so it meters the
condition and leaves the grouping to whoever mints the names.
Runtime.getBuildTargets— the open item on #3029's own list. Available since malloy 0.0.429(this repo is pinned at 0.0.427), so it is actionable now rather than pending a release. It does the
grouping in the compiler and returns one target carrying its
buildIdplus every source that maps onto it,deprecating
Model.getBuildPlan(), which this PR still uses. Encouragingly, the two already agree onidentity:
target.buildIdis computed exactly ascomputeSourceEntityIdis, so migrating preservesevery content address — no table is re-addressed, no ledger key moves. The reason it is not folded in
here is that it changes what the builder consumes, whereas everything above is wrong on the current
pin and worth fixing whether or not the migration happens. Doing it separately also keeps this
diff's blast radius to the build loop and the serve seam.