Skip to content

Fix the C1File close-vs-write data race - #1086

Open
kans wants to merge 2 commits into
mainfrom
kans/c1file-close-race
Open

Fix the C1File close-vs-write data race#1086
kans wants to merge 2 commits into
mainfrom
kans/c1file-close-race

Conversation

@kans

@kans kans commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Closing a C1File while a write is in flight raced on the database handle: closeRawDB nil'd c.db while validateDb read it on another goroutine, so a write could slip past the open-check onto a handle being torn down. TestC1ZConcurrentClose reproduces this on main today — it fails there and passes here.

Closed-ness is now an atomic flag rather than a nil'd pointer: closeRawDB CAS-flips dbClosed before closing rawDb, validateDb loads it, and the pointers stop being written outside construction. The rawDBOpen predicate replaces the bare rawDb != nil checks in Close, finalize, closeWithoutSave, and copyIsolateSync, which had quietly changed meaning once a closed store could still hold a non-nil handle — finalize consulting the stale check could conclude the handle was open and delete the database file out from under a save.

Extracted from the store-teardown branch so the live bug fix is not queued behind that review; the four files here are byte-identical to their versions there, and that branch rebases over this cleanly.

Closing a C1File while a write is in flight raced on the database
handle: closeRawDB nil'd c.db while validateDb read it on another
goroutine, so a write could slip past the open-check onto a handle
being torn down. TestC1ZConcurrentClose reproduces this on main today —
it fails there and passes here.

Closed-ness is now an atomic flag rather than a nil'd pointer:
closeRawDB CAS-flips dbClosed before closing rawDb, validateDb loads
it, and the pointers stop being written outside construction. The
rawDBOpen predicate replaces the bare rawDb != nil checks in Close,
finalize, closeWithoutSave, and copyIsolateSync, which had quietly
changed meaning once a closed store could still hold a non-nil handle —
finalize consulting the stale check could conclude the handle was open
and delete the database file out from under a save.

Extracted from the store-teardown branch so the live bug fix is not
queued behind that review; the four files here are byte-identical to
their versions there, and that branch rebases over this cleanly.

Co-authored-by: Cursor <cursoragent@cursor.com>
Comment thread pkg/dotc1z/c1file.go
Comment on lines +825 to +826
func (c *C1File) rawDBOpen() bool {
return c.rawDb != nil && !c.dbClosed.Load()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: one rawDb != nil open-check was not converted — cloneCopy at pkg/dotc1z/clone_sync.go:241 still does if c.rawDb == nil { return ErrDbNotOpen }, and its doc comment on line 236 explicitly calls it "defensive against a future caller" that does not reject a closed handle. Since a closed C1File now keeps a non-nil rawDb, that guard no longer detects closed-ness — the exact stale-meaning problem this PR fixes in Close, finalize, closeWithoutSave, and CopyIsolateSync. It's masked today (SnapshotTo calls validateDb, CloneSync reaches getSync/LatestSyncID which do), so this is consistency rather than a live bug: switch it to c.rawDBOpen() (or validateDb) and drop the now-false claim in the comment.

Separately, this doc says "Two idioms release the handle and both must read as closed here", but the package's most common idiom — f.rawDb.Close() with no nil-out (snapshot_test.go:182,210,249,264, copy_isolate_sync_test.go:63,99,151,172, clone_sync_test.go:177, bulkload_test.go:189) — still reads as open. The matching new comment in finalize ("recognize a released handle however it was released", line 739) overclaims for the same reason: a caller using that idiom before Close still checkpoints a closed handle and takes the cleanupDbDir branch that deletes the working database. Worth narrowing both comments to the two idioms actually covered (flag-flip, and close-plus-nil).

Comment thread pkg/dotc1z/c1file.go
// validateDb ensures that the database has been opened and not yet closed.
func (c *C1File) validateDb(ctx context.Context) error {
if c.db == nil {
if c.db == nil || c.dbClosed.Load() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: this removes the data race but not the check-then-use window. A writer can load dbClosed == false here, then closeRawDB CAS-flips the flag and calls rawDb.Close() before the writer acquires the pooled connection — database/sql then returns sql: database is closed (not ErrDbNotOpen) for that one iteration. TestC1ZConcurrentClose asserts require.ErrorIs(t, err, ErrDbNotOpen) on the terminating write (c1file_concurrent_test.go:140) and on the follow-up EndSync (line 147), so landing in that window fails the test rather than degrading gracefully. The window is narrow and pre-existed in the same shape (c.db was nil'd only after rawDb.Close() returned), and publishing the flag before the close narrows it further — but since this test is the PR's oracle, consider mapping the driver's closed error onto ErrDbNotOpen in the write path so the contract holds for any interleaving.

@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

General PR Review: Fix the C1File close-vs-write data race

Blocking Issues: 0 | Suggestions: 4 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base 4bd2b38695ad.
Review mode: incremental since f58edd92
View review run: https://github.com/ConductorOne/baton-sdk/actions/runs/31728091134

Review Summary

The full PR diff was scanned for security and correctness; the new commit was additionally reviewed at suggestion level. All three code findings from the previous review are addressed: the guard in cloneCopy is now rawDBOpen() (clone_sync.go:243) and the hand-rolled rawDb.Close() plus nil-out is now closeRawDB (clone_sync.go:285); the validateDb-to-exec window is now mapped onto ErrDbNotOpen by the new dbNotOpenOnClosed helper on the chunked-insert funnel and on endSyncRun; and the misleading "however it was released" comments now state plainly which release idioms rawDBOpen can and cannot see. I also verified that no c.rawDb == nil or c.db == nil guard remains anywhere in non-test code, so the invariant this design rests on — rawDb and db are never reassigned on a live C1File — now holds literally. No blocking issues; the four suggestions below are residual-risk and hardening items, three of them on the new commit.

Risk triage (per docs/BUG_CATCHING.md section 2) — HIGH. Silence: yes, the residual failure mode is a valid-looking c1z missing its last transactions. Durability: yes, c1z contents. Uncontrolled dimensions: yes, goroutine schedule and checkpoint timing. Consumer distance: the c1 platform and future SDK versions. Consequence: rung 2-3, re-sync. Review-blind class: schedule plus absence. The PR does carry a real instrument for the class it targets (TestC1ZConcurrentClose plus the new TestC1FileFinalizeSavesWhenHandleReleasedOutOfBand), but CI still has no -race gate — raised in the prior review and left as-is — and there is no deterministic, non-probabilistic test for the new error mapping. Recommend the full pass-set review per section 6 before merge for the write-fence question in the first suggestion.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • pkg/dotc1z/c1file.go:844-853 — the doc on closeRawDB claims a racing caller fails closed, but sql.DB.Close() does not wait for the in-use connection: a writer already holding the single pooled conn can commit WAL frames after truncateWAL and have them dropped by saveC1z, silently. Residual and pre-existing; wants a write fence, or an honest doc.
  • pkg/dotc1z/c1file.go:1529 — the ErrDbNotOpen mapping depends on the unexported "sql: database is closed" text from database/sql, with only probabilistic coverage; a deterministic unit test would pin it across Go bumps.
  • pkg/dotc1z/sync_runs.go:869endSyncRun is wrapped but the sync-token write in CheckpointSync (sync_runs.go:622) is not, so the close-vs-write error contract is path-dependent.
  • pkg/dotc1z/clone_sync.go:285 — unchanged code, adjacent: the unnamed error return on cloneCopy makes the temp-dir cleanup defer at line 259 a no-op, silently dropping the cleanup error that its own comment promises to return.
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In `pkg/dotc1z/c1file.go`:
- Around lines 837-853: the doc comment on closeRawDB claims that "the flag is
  published before the Close so a racing caller fails closed rather than
  entering a database/sql call that is about to be torn down". That guarantee
  only covers callers that have not yet passed validateDb. sql.DB.Close() does
  not wait for the connection that is currently in use, and the pool is
  SetMaxOpenConns(1), so this interleaving stays open: truncateWAL in finalize
  releases the single connection, a writer parked in db.conn() is handed it
  immediately by database/sql, closeRawDB flips dbClosed and returns without
  waiting, and the writer then commits WAL frames after the checkpoint. The
  os.Stat(walPath) check at c1file.go:785 can still see a zero-size WAL at that
  instant, so saveC1z proceeds and reads only the main database file - the
  just-committed rows are lost even though PutGrants returned nil to the caller.
  Fix by fencing writes against teardown rather than only publishing a flag: add
  a sync.RWMutex held for read across the executeChunkedInsert transaction and
  the other write paths and taken for write by closeRawDB, or an
  in-flight-write counter that closeRawDB drains before calling rawDb.Close().
  If a fence is deliberately out of scope for this PR, at minimum rewrite the
  doc comment so it states that writers already past the guard are not fenced
  and that their commits can be dropped by saveC1z.
- Around line 1529: the mapping onto ErrDbNotOpen depends on
  strings.Contains(err.Error(), "sql: database is closed"), which is the
  unexported errDBClosed text from database/sql. If a Go release changes that
  string, the branch silently stops matching and the only symptom is rarer,
  harder-to-diagnose flakes in TestC1ZConcurrentClose. Add a deterministic unit
  test in pkg/dotc1z that opens a *sql.DB, closes it, performs a BeginTx to
  obtain the real error, sets dbClosed on a C1File, and asserts that
  dbNotOpenOnClosed returns an error satisfying errors.Is(err, ErrDbNotOpen).
  That makes a stdlib message change fail loudly at the next Go bump instead of
  degrading into flake.

In `pkg/dotc1z/sync_runs.go`:
- Around line 869: endSyncRun now wraps its ExecContext error with
  dbNotOpenOnClosed, but the sync-token write in CheckpointSync at
  sync_runs.go:622 does not, so a Close racing a checkpoint still surfaces the
  raw driver sentinel. Either wrap that ExecContext with c.dbNotOpenOnClosed
  too, or narrow the contract wording in the dbNotOpenOnClosed doc comment at
  c1file.go:1521-1524 so it names exactly the paths that honor it and does not
  read as a general C1File guarantee.

In `pkg/dotc1z/clone_sync.go`:
- Around lines 242-264, unchanged code adjacent to the hunks in this PR:
  cloneCopy is declared with an unnamed error return, so the deferred temp-dir
  cleanup at line 259 assigns to a dead local - the errors.Join result never
  reaches the caller, despite the comment at line 258 saying "Always clean up
  the temp dir and return an error if that fails". Change the signature to use
  a named error return, matching CopyIsolateSync at copy_isolate_sync.go:96, so
  the cleanup error actually propagates on every path.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

Review findings on the close-vs-write fix, verified and addressed:

- validateDb passes, then closeRawDB flips dbClosed and closes the
  handle before the query acquires a connection: database/sql returns
  its unexported "sql: database is closed" sentinel instead of the
  ErrDbNotOpen the concurrent-close test pins. Add dbNotOpenOnClosed,
  which translates the driver's closed-handle errors to ErrDbNotOpen
  only when dbClosed is set, and apply it at the chunked-insert funnel
  (all record Puts) and the sync-run stamp (EndSync).
- cloneCopy still guarded with a bare nil check, which cannot see
  closeRawDB's release now that the pointer stays set; switch it to
  rawDBOpen and fix the comment that called the old check defensive.
- cloneCopy's initFile teardown wrote rawDb/db post-construction,
  contradicting the never-reassigned invariant; route it through
  closeRawDB instead.
- Narrow the rawDBOpen and finalize comments: a direct rawDb.Close()
  that leaves the field set (a common test idiom) still reads as open,
  so those docs no longer claim every release is recognized.

Declined: adding -race to PR CI. The nightly workflow already runs the
dotc1z shard under the race detector, which covers the concurrent-close
test; PR CI deliberately stays race-free for wall-clock (docs/TESTING.md).

Co-authored-by: Cursor <cursoragent@cursor.com>
Comment thread pkg/dotc1z/c1file.go
Comment on lines 844 to 853
func (c *C1File) closeRawDB(ctx context.Context) error {
_, span := tracer.Start(ctx, "C1File.closeRawDB")
var err error
defer func() { uotel.EndSpanWithError(span, err) }()
if c.rawDb == nil {
if c.rawDb == nil || !c.dbClosed.CompareAndSwap(false, true) {
return nil
}
// Copy the rawDb to a local variable to avoid race conditions.
rawDb := c.rawDb
c.rawDb = nil
err = rawDb.Close()
c.db = nil
err = c.rawDb.Close()
return err
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion (high confidence in the mechanism, residual/pre-existing): the doc says a racing caller "fails closed rather than entering a database/sql call that is about to be torn down", but the flag only fences callers that have not yet passed validateDb. sql.DB.Close() does not wait for the in-use connection, so a writer that already holds the single pooled conn keeps committing. In finalize, truncateWAL releases that conn and a hot-looping writer parked in db.conn() is handed it immediately, so the write commits WAL frames after the checkpoint while closeRawDB returns — then the os.Stat(walPath) check at line 785 sees a still-truncated WAL and saveC1z reads only the main db file, silently dropping rows the writer got nil for. This PR fixes the error contract, not this durability window. Consider fencing writes (RWMutex read-held across the tx / in-flight counter drained by closeRawDB) or, at minimum, correcting the claim here so the residual window is documented.

Comment thread pkg/dotc1z/c1file.go
if err == nil || !c.dbClosed.Load() {
return err
}
if errors.Is(err, sql.ErrConnDone) || strings.Contains(err.Error(), "sql: database is closed") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: this mapping hinges on database/sql's unexported errDBClosed text ("sql: database is closed"). If a Go release reworks that string, the branch stops matching and nothing fails loudly — the only coverage is TestC1ZConcurrentClose, which would just get rarer, harder-to-diagnose CI flakes on the require.ErrorIs(err, ErrDbNotOpen) assertions. A deterministic unit test that opens a *sql.DB, closes it, does a BeginTx, and asserts dbNotOpenOnClosed maps the result would pin the string at every Go bump.

Comment thread pkg/dotc1z/sync_runs.go
_, err = c.db.ExecContext(ctx, query, args...)
if err != nil {
return err
return c.dbNotOpenOnClosed(err)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: endSyncRun is wrapped but its sibling durable write CheckpointSync (sync_runs.go:622, the sync-token stamp) is not, so a Close racing a checkpoint still surfaces the raw driver sentinel. The doc at c1file.go:1521 acknowledges the partial application, but sync-token persistence is the one write whose failure classification callers are most likely to grow a dependency on. Consider wrapping CheckpointSync's ExecContext too, or scoping the documented contract to "record Puts and EndSync" so nobody assumes it holds fleet-wide.

Comment thread pkg/dotc1z/clone_sync.go
return err
}
if err = initFile.rawDb.Close(); err != nil {
if err = initFile.closeRawDB(ctx); err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion (unchanged code, adjacent): the defer at line 259 says it will "return an error if that fails", but cloneCopy has an unnamed error return, so err = errors.Join(err, ...) inside the closure writes to a dead local and the temp-dir cleanup error is silently dropped — including the Windows/ATTACH case the comment at line 292 is worried about. CopyIsolateSync gets this right with (err error) at copy_isolate_sync.go:96; the same named return here would make the defer effective. Same applies to the early return err at line 286, which currently bypasses nothing but would compose correctly once named.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant