◐ Off-By-One · answer catalog

go-bitemporal-table-schema-evolution-time-travel

2 answer(s)gogo1.26gogo1.26

A version answers AS OF VALID TIME v / AS OF SYSTEM TIME t iff validFrom <= v < validTo && txFrom <= t < txTo. Four mistakes break the required semantics:

📦 Source in repository (JSON)

Answer 1

I built and verified a complete solution. The full self-contained document is at ~/bitemporal/SOLUTION.md, alongside the working module. Here is the solution.

Bitemporal Table with Mid-History Schema Evolution and Composite-Index Time Travel

Root-cause analysis

A version answers AS OF VALID TIME v / AS OF SYSTEM TIME t iff validFrom <= v < validTo && txFrom <= t < txTo. Four mistakes break the required semantics:

  1. Single, current schema. Storing one mutable column set makes a column added at T visible at T-1 and cannot distinguish "column existed but NULL" from "column did not exist yet." Fix: schema is transaction-time-versioned metadata; a query at t exposes only columns with AddedAt <= t, and project emits nil for any visible column whose AddedAt > version.txFrom.
  2. Rewriting history on ALTER. Mutating old row versions destroys pre-T snapshots. Old versions stay immutable and are interpreted through the schema current at read time.
  3. Wrong transaction-interval algebra. A correction over part of a valid interval must close the old belief at tx and open left/overlap/right pieces at tx (not at the original txFrom), otherwise old and new versions overlap. Put/Backfill use monotonic tx and close-and-split.
  4. Non-covering index / full-scan fallback. The index must own all versions and key on both axes. Here (id, validFrom, validTo, txFrom) is the sole store, reads only seek it, and a FullScans counter proves 0.

Exact fix

Module bitemporal (go 1.26): bitemporal.go, index.go, ref.go, bitemporal_test.go. Key pieces:

Temporal schema + projection (bitemporal.go):

func (t *Table) ColumnsAt(at int64) map[string]Column {
    out := map[string]Column{}
    for _, sv := range t.schemaHistory {
        if sv.At <= at {
            for k, v := range sv.Cols { out[k] = v }
        }
    }
    return out
}

func (t *Table) project(v *version, tx int64) Row {
    cols := t.ColumnsAt(tx)
    vals := make(map[string]any, len(cols))
    for name, col := range cols {
        if v.txFrom < col.AddedAt {
            vals[name] = nil // pre-ALTER version -> NULL
            continue
        }
        vals[name] = v.values[name]
    }
    return Row{ID: v.id, ValidFrom: v.validFrom, ValidTo: v.validTo,
        TxFrom: v.txFrom, TxTo: v.txTo, Values: vals}
}

Close-and-split correction (bitemporal.go):

func (t *Table) update(tx int64, id string, vf, vt int64,
    transform func(map[string]any) map[string]any, insertMissing bool) {
    cur := t.currentVersions(id)
    covered := make([][2]int64, 0, len(cur))
    for _, v := range cur {
        oS, oE := max64(v.validFrom, vf), min64(v.validTo, vt)
        if oS >= oE { continue }
        covered = append(covered, [2]int64{oS, oE})
        v.txTo = tx // close old belief at current tx
        if v.validFrom < oS { // left remainder, old values
            t.addVersion(&version{id: id, validFrom: v.validFrom, validTo: oS,
                txFrom: tx, txTo: Infinity, values: cloneValues(v.values)})
        }
        if v.validTo > oE { // right remainder, old values
            t.addVersion(&version{id: id, validFrom: oE, validTo: v.validTo,
                txFrom: tx, txTo: Infinity, values: cloneValues(v.values)})
        }
        t.addVersion(&version{id: id, validFrom: oS, validTo: oE,
            txFrom: tx, txTo: Infinity, values: transform(cloneValues(v.values))})
    }
    if insertMissing {
        for _, g := range gaps(vf, vt, covered) {
            t.addVersion(&version{id: id, validFrom: g[0], validTo: g[1],
                txFrom: tx, txTo: Infinity, values: transform(nil)})
        }
    }
}

Put writes full values (reset; fills gaps). Backfill merges one column into existing rows only. Transaction time is monotonic; retroactive corrections use later tx with past valid intervals.

Composite index (index.go): per-id slices sorted by (validFrom, validTo, txFrom), with binary-search seekPoint / seekRange and IndexStats{Seeks, EntriesExamined, FullScans}. No read path iterates the id map.

Verification

go vet ./...
go test ./...
go test -race -count=2 ./...

ref.go is an independent oracle: it replays the write log (Put = reset, Backfill = last-assignment-wins) instead of materializing tx intervals. Results:

ok  bitemporal  2.540s          # go test ./...
ok  bitemporal  20.737s         # go test -race -count=2 ./...

--- PASS: TestSchemaEvolutionNullAndInvisible        # absent at T-1, NULL at T
--- PASS: TestValidAndTxTimeTravel                   # 1 row @tx1 -> 3-way split @tx2
--- PASS: TestIndexNeverFullScans                    # 300 ids, FullScans == 0
--- PASS: TestRetroactiveBackfillKeepsNonOverlappingHistory
--- PASS: TestRandomizedDifferential (25/25 seeds)   # 900 ops + 4000 queries/seed vs oracle
--- PASS: TestRandomizedRangeSampled                 # ordered, disjoint, matches AsOf
--- PASS: TestMultipleOpsSameTransactionTime

Minimal reproduction:

tb := bitemporal.NewTable("accounts")
_ = tb.AddColumn(1, "balance")
_ = tb.Put(1, "acct-1", 0, 10, map[string]any{"balance": 100})
_ = tb.AddColumn(5, "risk_score")        // mid-history ALTER
_, _ = tb.AsOf("acct-1", 3, 4)           // no "risk_score" key at T-1
row, _ := tb.AsOf("acct-1", 3, 5)        // row.Values["risk_score"] == nil
_ = tb.Backfill(6, "acct-1", "risk_score", 7, 0, 10)
row, _ = tb.AsOf("acct-1", 3, 6)         // row.Values["risk_score"] == 7

The full markdown with all source listings is in ~/bitemporal/SOLUTION.md.

Evidence & signatures

# Evidence
- Problem class: go-bitemporal-table-schema-evolution-time-travel
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-14T22:08:06.850Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Implement a bitemporal table that answers AS OF queries on both the valid-time and transaction-time axes while schema evolution happened mid-history: a column added at transaction time T must materialize as NULL for rows inserted before T and must be invisible at T-minus-one. Add a composite index that serves point lookups and valid-time range scans with no full-scan fallback, and verify against a reference model that replays a randomized insert/update/backfill/ALTER schedule including out-of-order retroactive corrections.", "environment": "go1.26", "language": "go", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "go-bitemporal-table-schema-evolution-time-travel", "provider": "openrouter", "solved_at": "2026-09-14T22:08:06.850Z", "version": "1.26"}

Answer 2

I built and verified a complete solution. The full self-contained document is at ~/bitemporal/SOLUTION.md, alongside the working module. Here is the solution.

Bitemporal Table with Mid-History Schema Evolution and Composite-Index Time Travel

Root-cause analysis

A version answers AS OF VALID TIME v / AS OF SYSTEM TIME t iff validFrom <= v < validTo && txFrom <= t < txTo. Four mistakes break the required semantics:

  1. Single, current schema. Storing one mutable column set makes a column added at T visible at T-1 and cannot distinguish "column existed but NULL" from "column did not exist yet." Fix: schema is transaction-time-versioned metadata; a query at t exposes only columns with AddedAt <= t, and project emits nil for any visible column whose AddedAt > version.txFrom.
  2. Rewriting history on ALTER. Mutating old row versions destroys pre-T snapshots. Old versions stay immutable and are interpreted through the schema current at read time.
  3. Wrong transaction-interval algebra. A correction over part of a valid interval must close the old belief at tx and open left/overlap/right pieces at tx (not at the original txFrom), otherwise old and new versions overlap. Put/Backfill use monotonic tx and close-and-split.
  4. Non-covering index / full-scan fallback. The index must own all versions and key on both axes. Here (id, validFrom, validTo, txFrom) is the sole store, reads only seek it, and a FullScans counter proves 0.

Exact fix

Module bitemporal (go 1.26): bitemporal.go, index.go, ref.go, bitemporal_test.go. Key pieces:

Temporal schema + projection (bitemporal.go):

func (t *Table) ColumnsAt(at int64) map[string]Column {
    out := map[string]Column{}
    for _, sv := range t.schemaHistory {
        if sv.At <= at {
            for k, v := range sv.Cols { out[k] = v }
        }
    }
    return out
}

func (t *Table) project(v *version, tx int64) Row {
    cols := t.ColumnsAt(tx)
    vals := make(map[string]any, len(cols))
    for name, col := range cols {
        if v.txFrom < col.AddedAt {
            vals[name] = nil // pre-ALTER version -> NULL
            continue
        }
        vals[name] = v.values[name]
    }
    return Row{ID: v.id, ValidFrom: v.validFrom, ValidTo: v.validTo,
        TxFrom: v.txFrom, TxTo: v.txTo, Values: vals}
}

Close-and-split correction (bitemporal.go):

func (t *Table) update(tx int64, id string, vf, vt int64,
    transform func(map[string]any) map[string]any, insertMissing bool) {
    cur := t.currentVersions(id)
    covered := make([][2]int64, 0, len(cur))
    for _, v := range cur {
        oS, oE := max64(v.validFrom, vf), min64(v.validTo, vt)
        if oS >= oE { continue }
        covered = append(covered, [2]int64{oS, oE})
        v.txTo = tx // close old belief at current tx
        if v.validFrom < oS { // left remainder, old values
            t.addVersion(&version{id: id, validFrom: v.validFrom, validTo: oS,
                txFrom: tx, txTo: Infinity, values: cloneValues(v.values)})
        }
        if v.validTo > oE { // right remainder, old values
            t.addVersion(&version{id: id, validFrom: oE, validTo: v.validTo,
                txFrom: tx, txTo: Infinity, values: cloneValues(v.values)})
        }
        t.addVersion(&version{id: id, validFrom: oS, validTo: oE,
            txFrom: tx, txTo: Infinity, values: transform(cloneValues(v.values))})
    }
    if insertMissing {
        for _, g := range gaps(vf, vt, covered) {
            t.addVersion(&version{id: id, validFrom: g[0], validTo: g[1],
                txFrom: tx, txTo: Infinity, values: transform(nil)})
        }
    }
}

Put writes full values (reset; fills gaps). Backfill merges one column into existing rows only. Transaction time is monotonic; retroactive corrections use later tx with past valid intervals.

Composite index (index.go): per-id slices sorted by (validFrom, validTo, txFrom), with binary-search seekPoint / seekRange and IndexStats{Seeks, EntriesExamined, FullScans}. No read path iterates the id map.

Verification

go vet ./...
go test ./...
go test -race -count=2 ./...

ref.go is an independent oracle: it replays the write log (Put = reset, Backfill = last-assignment-wins) instead of materializing tx intervals. Results:

ok  bitemporal  2.540s          # go test ./...
ok  bitemporal  20.737s         # go test -race -count=2 ./...

--- PASS: TestSchemaEvolutionNullAndInvisible        # absent at T-1, NULL at T
--- PASS: TestValidAndTxTimeTravel                   # 1 row @tx1 -> 3-way split @tx2
--- PASS: TestIndexNeverFullScans                    # 300 ids, FullScans == 0
--- PASS: TestRetroactiveBackfillKeepsNonOverlappingHistory
--- PASS: TestRandomizedDifferential (25/25 seeds)   # 900 ops + 4000 queries/seed vs oracle
--- PASS: TestRandomizedRangeSampled                 # ordered, disjoint, matches AsOf
--- PASS: TestMultipleOpsSameTransactionTime

Minimal reproduction:

tb := bitemporal.NewTable("accounts")
_ = tb.AddColumn(1, "balance")
_ = tb.Put(1, "acct-1", 0, 10, map[string]any{"balance": 100})
_ = tb.AddColumn(5, "risk_score")        // mid-history ALTER
_, _ = tb.AsOf("acct-1", 3, 4)           // no "risk_score" key at T-1
row, _ := tb.AsOf("acct-1", 3, 5)        // row.Values["risk_score"] == nil
_ = tb.Backfill(6, "acct-1", "risk_score", 7, 0, 10)
row, _ = tb.AsOf("acct-1", 3, 6)         // row.Values["risk_score"] == 7

The full markdown with all source listings is in ~/bitemporal/SOLUTION.md.

Evidence & signatures

# Evidence
- Problem class: go-bitemporal-table-schema-evolution-time-travel
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-14T22:08:06.850Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Implement a bitemporal table that answers AS OF queries on both the valid-time and transaction-time axes while schema evolution happened mid-history: a column added at transaction time T must materialize as NULL for rows inserted before T and must be invisible at T-minus-one. Add a composite index that serves point lookups and valid-time range scans with no full-scan fallback, and verify against a reference model that replays a randomized insert/update/backfill/ALTER schedule including out-of-order retroactive corrections.", "environment": "go1.26", "language": "go", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "go-bitemporal-table-schema-evolution-time-travel", "provider": "openrouter", "solved_at": "2026-09-14T22:08:06.850Z", "version": "1.26"}
Generated from the verified corpus · MIT licensedBack to the catalog