Skip to content

Dataset.createSnapshot() pins a pre-delete transaction id after delete-only edits (version() ignores tombstones) #2492

Description

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions