Summary
Dataset.createSnapshot({ name }) stamps the snapshot with await this.version(). version() is the maximum _xact_id over the rows that fetch() returns, and fetch() returns only live rows. A delete writes a tombstone and leaves no live row, so after delete-only edits version() does not advance (it can even go backwards when the deleted row was the newest one). A snapshot created at that point pins a transaction id from before the deletes, and reading the snapshot returns the deleted rows.
Reads themselves are correct. A read pinned to a transaction id after the tombstones excludes the deleted rows. Only the default transaction id that createSnapshot() picks is stale.
Environment
braintrust 3.26.0 (JavaScript SDK), Node 26.5
- Hosted Braintrust (
api.braintrust.dev), Brainstore-backed org
- Code references at
main (32cc6ff8): js/src/logger.ts ObjectFetcher.version() around L7576, Dataset.version() override around L8823, Dataset.createSnapshot() passing xact_id: currentVersion around L9037
Reproduction
import { initDataset } from "braintrust";
const P = { project: "<project>", dataset: `tombstone-probe-${Date.now()}` };
const ds = initDataset(P);
const ids = async (o: object = {}) =>
(await Array.fromAsync(initDataset({ ...P, ...o }))).map((r) => r.id).sort().join(",");
for (const id of ["a", "b", "c"]) ds.insert({ id, input: { k: id }, expected: { v: id } });
await ds.flush();
const x1 = await ds.version();
console.log("X1", x1, "head:", await ids(), "pinned X1:", await ids({ version: x1 }));
ds.delete("c");
await ds.flush();
const x2 = await ds.version();
console.log("X2", x2, "head:", await ids(), "pinned X2:", await ids({ version: x2 }));
const snap = await ds.createSnapshot({ name: "after-delete-c" });
console.log("snapshot xact", snap.xact_id, "snapshot read:", await ids({ snapshotName: "after-delete-c" }));
// Any content-preserving write moves the version past the tombstone.
const head = await Array.fromAsync(initDataset(P));
const a = head.find((r) => r.id === "a")!;
ds.insert({ id: "a", input: a.input, expected: a.expected });
await ds.flush();
const x3 = await ds.version();
console.log("X3", x3, "head:", await ids(), "pinned X3:", await ids({ version: x3 }));
Observed output (transaction ids from one run):
X1 1000197874935204592 head: a,b,c pinned X1: a,b,c
X2 1000197874935204592 head: a,b pinned X2: a,b,c <- version did not move after the delete
snapshot xact 1000197874935204592 snapshot read: a,b,c <- snapshot contains the deleted row
X3 1000197874946873171 head: a,b pinned X3: a,b <- one no-op upsert fixes it
In the same run, deleting the newest row (d, inserted after c) made version() go backwards from ...466488 to ...204592, and a snapshot created then also contained a row deleted earlier.
Expected
After ds.delete("c"); await ds.flush(), createSnapshot() should pin a transaction id at or after the tombstone, so the snapshot reads back as a,b. version() should reflect the dataset's latest transaction, including deletes, or createSnapshot() should obtain the head transaction id from the server instead of deriving it from live rows.
Impact
Any workflow that curates a dataset by deleting rows and then snapshots it (for example, a CI pipeline that pins a named snapshot) silently ships the pre-delete rows. There is no error. We only noticed because a pull-and-verify step read the snapshot back and compared row ids.
Workaround
Make one content-preserving upsert of an existing row (same id and fields) after the deletes and before createSnapshot(), then read the snapshot back and confirm the deleted ids are absent.
Summary
Dataset.createSnapshot({ name })stamps the snapshot withawait this.version().version()is the maximum_xact_idover the rows thatfetch()returns, andfetch()returns only live rows. A delete writes a tombstone and leaves no live row, so after delete-only editsversion()does not advance (it can even go backwards when the deleted row was the newest one). A snapshot created at that point pins a transaction id from before the deletes, and reading the snapshot returns the deleted rows.Reads themselves are correct. A read pinned to a transaction id after the tombstones excludes the deleted rows. Only the default transaction id that
createSnapshot()picks is stale.Environment
braintrust3.26.0 (JavaScript SDK), Node 26.5api.braintrust.dev), Brainstore-backed orgmain(32cc6ff8):js/src/logger.tsObjectFetcher.version()around L7576,Dataset.version()override around L8823,Dataset.createSnapshot()passingxact_id: currentVersionaround L9037Reproduction
Observed output (transaction ids from one run):
In the same run, deleting the newest row (
d, inserted afterc) madeversion()go backwards from...466488to...204592, and a snapshot created then also contained a row deleted earlier.Expected
After
ds.delete("c"); await ds.flush(),createSnapshot()should pin a transaction id at or after the tombstone, so the snapshot reads back asa,b.version()should reflect the dataset's latest transaction, including deletes, orcreateSnapshot()should obtain the head transaction id from the server instead of deriving it from live rows.Impact
Any workflow that curates a dataset by deleting rows and then snapshots it (for example, a CI pipeline that pins a named snapshot) silently ships the pre-delete rows. There is no error. We only noticed because a pull-and-verify step read the snapshot back and compared row ids.
Workaround
Make one content-preserving upsert of an existing row (same id and fields) after the deletes and before
createSnapshot(), then read the snapshot back and confirm the deleted ids are absent.