Skip to content

structrually remove deadlock potential - #1058

Open
kans wants to merge 2 commits into
mainfrom
kans/lock-safety
Open

structrually remove deadlock potential#1058
kans wants to merge 2 commits into
mainfrom
kans/lock-safety

Conversation

@kans

@kans kans commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add open → closing → closed mutation admission for Pebble stores.
  • Make Close wait for admitted mutations before deciding whether to save.
  • Preserve dirty state and reopen admission after save failures.
  • Guard writer, metadata, file, grant, session, index-repair, bulk-import, and compactor mutations.
  • Add deterministic race, persistence, retry, partial-error, liveness, and mutation-inventory tests.

Performance

Admission adds two brief mutex operations per synchronous mutation. Mutations remain concurrent and no lock is held while the underlying write executes.

Verification

  • go test -race ./pkg/synccompactor/...
  • go test -tags=baton_lambda_support ./pkg/dotc1z/... ./pkg/synccompactor/...
  • Focused Pebble admission tests under -race
  • golangci-lint run --timeout=3m ./pkg/dotc1z/... ./pkg/synccompactor/...
  • go test -race ./pkg/dotc1z/... — Pebble packages passed; the command failed on the existing SQLite TestC1ZConcurrentClose race and later timed out.

Remaining risks

  • External callers can still bypass store admission by directly mutating an engine obtained through AsEngine; production mutation paths now use guarded callbacks.
  • Racing CloseEngineOnly against full Close retains its existing unsupported lifecycle semantics.
  • Source-cache mutation APIs are not present on the target origin/main, so this change does not introduce or depend on them.

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

General PR Review: structrually remove deadlock potential

Blocking Issues: 0 | Suggestions: 7 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base c15e57fb1528.
Review mode: incremental since b787168
View review run

Review Summary

The full PR diff (84 files) was re-scanned for security and correctness; no security issues and no confident correctness bugs were found. The single new commit (88becea "Drop the verb-prefix mutator guess") deletes mutatingEnginePrefixes / isMutatingEngineMethod / TestPebbleStoreDoesNotPromoteEngineMutators from pebble_store_promotion_test.go and rewrites the surrounding comments; no production code changed, so all six prior findings stand as previously reported. That deletion removes the only automated check on engine mutators becoming reachable on the store — the one new suggestion below.

Risk triage (docs/BUG_CATCHING.md §2): silence — yes, an unadmitted or undirtied write is a silently absent record, not a crash. Durability — yes, the dirty/save decision determines whether a sync reaches the .c1z at all. Uncontrolled dimensions — yes, Close/mutation interleaving and goroutine schedule. Consumer distance — the c1 platform and future SDK versions read the artifact. Verdict: HIGH, remediation rung 2 (re-sync), rung 3 if fold_dead_bytes accounting drifts. The PR does carry real instruments (deterministic admission/liveness/partial-error tests, the AST wrapper inventory, the capability-assertion file, TestPebbleStoreCloseDoesNotInheritAnotherTeardownsFailure) — but this commit spends one of them, and the PR-CI sampling reductions (checkpoint-cut sweep halved, WAL race 100 to 20) land in the same change that restructures teardown while make test-full is wired into no workflow. Recommend the full pass-set review per §6 before merge rather than treating this CI pass as coverage.

Review-state marker omitted: this run's shell rejected the review-state HTML comment (its JSON tripped an "expansion obfuscation" filter on brace-adjacent quotes) and file writes were denied, so there was no way to emit it. Reviewed head SHA 88becea3c7e0a795c71f919bb0ecf41d04bc7c14, base SHA c15e57fb15282d3c091899ae4a66e251505d1d9e. The next run will likely fall back to a full review unless the marker is restored by hand.

Security Issues

None found.

Correctness Issues

None found. The prior high-confidence exported-API break is repeated under Suggestions since it is a compatibility, not a runtime, defect.

Suggestions

  • pkg/dotc1z/pebble_store_promotion_test.go:10-19 — deleting TestPebbleStoreDoesNotPromoteEngineMutators leaves nothing that fails when an engine mutator becomes reachable on pebbleStore: the inventory test skips names absent from guardedMutationWrappers, and the not-embedded test only rules out promotion. A hand-written passthrough to a still-exported engine mutator (PutSyncRunRecord, PutGrantRecord, SetCurrentSync, Flush, CompactAllRanges, PersistSyncStats, …) now passes CI unguarded. A reflect method-set snapshot required to equal the guarded list plus a pinned list of read forwarders replaces the verb heuristic without guessing from names. (new, medium confidence)
  • pkg/dotc1z/engine/pebble/engine.go:661 and across pkg/dotc1z/engine/pebble — ~47 exported *pebble.Engine methods are unexported and several deleted (Save, DBDir, IsSealed, IsFreshSync, CurrentSyncID, ResetForNewSync, GetAssetRecord, DeleteAssetRecord, CleanupCandidates, DropAllGrantDigests, UnsafePutUniqueGrantRecords, the Paginate* and Put*RecordsIfNewer families, the synthesized-grant-layer family), plus pebble.AddFoldDeadBytes / pebble.MarkStoreDirty. pebbleStore also un-embeds the engine, so the store from dotc1z.NewStore loses every method not re-declared in pebble_store_reads.go. pebble.AsEngine stays exported, so out-of-tree callers exist by design; pkg/sdk/version.go is untouched, there are no // Deprecated: shims, and the PR body has no migration note — external callers break at compile time with no signal. In-repo callers are compile-checked or covered by the new capabilities test, so the break is external-only. (carried over, unaddressed, high confidence)
  • pkg/dotc1z/pebble_store.go:1030-1032warnWhileBlocked starts before beginClose and stops immediately after it returns, so the other multi-minute phase of Close (save, i.e. CheckpointTo + envelope encode, which this PR's own ReuseMissingPath signal shows can degrade to a full re-encode) has no periodic progress signal. (carried over, unaddressed, medium confidence)
  • Makefile:57-59 / pkg/sync/expand/full_suite_test.gotest-full is the only setter of BATON_FULL_TESTS and no workflow invokes it. Re-verified: nightly.yaml runs only interrupt-check and scheduler-soak, and the Makefile now sets BATON_FULL_TESTS=1 on scheduler-soak and BATON_CUT_SWEEP=full on checkpoint-cut-check, so those keep nightly coverage. Running nowhere automatically: the full topological resume/interrupt matrices (topological_merge_resume_test.go:39,190, topological_merge_layer_interrupt_test.go:99), the long expand differential seed sweep (topological_merge_differential_test.go:482), and the 100-attempt WAL race (race_test.go:38, 20 in CI). Wire test-full into nightly.yaml or drop the gates. (carried over, partially addressed, medium confidence)
  • pkg/dotc1z/pebble_store.go:398-410admitMutation marks dirty on admission for the non-fold paths, wider than the markDirty calls it replaced: StartOrResumeSync used to dirty only when started was true, and BeginExpandedGrantLayer / AddExpandedGrantLayerContributions / AbortExpandedGrantLayer did not dirty at all; the new ResumeSync and SetCurrentSync wrappers add two more. A pure resume, or an abandoned expanded-grant layer, now forces a full CheckpointTo + envelope re-encode at Close. The fold path is exempt via admitMutation(false, ...); the same treatment fits these. (carried over, partially addressed, medium confidence)
  • pkg/dotc1z/pebble_store.go:587-601CloseEngineOnly takes the same uncancellable beginClose drain as Close but installs no warnWhileBlocked watchdog, so the k-way compactor's per-chunk source teardown (pkg/synccompactor/pebble/kway.go) blocks silently. It also now drains fully before discovering it must refuse a dirty writable store, where it previously refused immediately. (carried over, unaddressed, low confidence)
  • pkg/sync/checkpoint_cut_test.go:512-522 — the sampled checkpoint-cut sweep is halved (16 to 8, 16 to 8, 12 to 6). enumerateCutPoints ignores the limit only under BATON_CUT_SWEEP=full, which nightly checkpoint-cut-check sets, so nightly stays exhaustive — but ordinary PR CI now exercises half as many checkpoint/response/expiry resume points, in the same PR that restructures store teardown and the save/dirty decision. (carried over, medium confidence)
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In pkg/dotc1z/pebble_store_promotion_test.go around lines 10-19: nothing now fails when an
engine mutator becomes reachable on pebbleStore. Replace the deleted verb heuristic with a
method-set snapshot: enumerate the reflect method set of the pebbleStore pointer type and
require it to equal guardedMutationWrappers plus a pinned list of the read forwarders in
pebble_store_reads.go, failing with a message that tells the author to classify any new
method as a guarded wrapper or a read forwarder.

In pkg/dotc1z/engine/pebble/engine.go around line 661 and across the package: ~47 exported
Engine methods were unexported or deleted, plus AddFoldDeadBytes and MarkStoreDirty, and
pebbleStore no longer embeds the engine. Bump the minor version in pkg/sdk/version.go and
add a PR migration note mapping each removed surface to its replacement
(WithEngineMutation / WithEngineFoldMutation for MarkStoreDirty / AddFoldDeadBytes,
CheckpointTo for Save, the pebble_store_reads.go forwarders for promoted methods).

In pkg/dotc1z/pebble_store.go around lines 1030-1032: keep warnWhileBlocked running across
the save phase, not just beginClose, and name the phase it is waiting in.

In pkg/dotc1z/pebble_store.go around lines 398-410: admitMutation dirties on admission for
every non-fold path, wider than the markDirty calls it replaced, so a pure resume or an
abandoned expanded-grant layer forces a full CheckpointTo plus envelope re-encode at Close.
Route ResumeSync, SetCurrentSync, StartOrResumeSync and the layer-session wrappers through
admitMutation with dirtyOnAdmission false and set dirty inside the callback only when
durable state actually changed.

In pkg/dotc1z/pebble_store.go around lines 587-601: wrap CloseEngineOnly's beginClose in the
same warnWhileBlocked watchdog, and consider checking isDirty before draining so a dirty
writable store is refused immediately as it was before.

In Makefile around lines 57-59: test-full is the only setter of BATON_FULL_TESTS and no
workflow invokes it, so the full resume/interrupt matrices, the differential seed sweep and
the 100-attempt WAL race run nowhere. Add a test-full job to nightly.yaml or drop the gates.

In pkg/sync/checkpoint_cut_test.go around lines 512-522: restore the 16/16/12 cut sampling
for PR CI, or document why 8/8/6 suffices given this PR's teardown rework.

@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.

@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.

@kans
kans requested a review from mindymo as a code owner August 5, 2026 22:24
Comment thread pkg/dotc1z/pebble_store.go Outdated
Comment on lines 910 to 918
if s.admission == pebbleStoreClosing {
attempt := s.closeAttempt
for s.admission == pebbleStoreClosing && s.closeAttempt == attempt {
s.cond().Wait()
}
err := s.lastCloseErr
s.closeMu.Unlock()
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 (medium confidence): a waiter here inherits lastCloseErr from whatever operation held Closing, even when that operation failed and reset admission back to Open — so the waiter returns an error without ever performing its own close. Because Close and CloseEngineOnly share lastCloseErr, a Close that waits behind a failed CloseEngineOnly returns "pebble CloseEngineOnly: refusing to discard dirty writable store" and leaves the store open and unsaved, even though a real save would have succeeded. Consider having waiters re-loop and re-attempt their own close when admission returns to pebbleStoreOpen, rather than adopting a different operation's verdict.

Comment thread pkg/dotc1z/engine/pebble/merge_accessor.go Outdated
Comment on lines +158 to +160
if os.Getenv("BATON_FULL_TESTS") == "" {
t.Skip("scheduler soak runs in make scheduler-soak and make test-full")
}

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: BATON_FULL_TESTS is set only by the test-full, race-check, and scheduler-soak Makefile targets, and none of those run in .github/workflows/ci.yaml (CI invokes go test ... ./... directly plus make chaos-check). So this 6-seed randomized scheduler soak now runs in no automated workflow, where it previously ran on every PR. That's a notable coverage loss to take in the same PR that restructures store close/mutation concurrency. Consider keeping 1–2 seeds unconditional, or wiring a race-check/test-full job into CI.

Comment thread pkg/dotc1z/pebble_store.go Outdated

@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.

// build failure anywhere — it just silently stops the capability from being
// discovered. See pebble_store_capabilities_test.go, which asserts each one.

func (s *pebbleStore) CurrentDBSizeBytes() (int64, error) {

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.

🟠 Bug: GrantsForEntitlementPrincipalSorted() (engine/pebble/adapter_reader.go:183) was promoted by the old embed and is not forwarded here — exactly the silent-optional-capability failure this file's header warns about. pkg/sync/syncer.go:427-432 discovers it by inline assertion on a.store (the *pebbleStore), so it now returns false for every Pebble sync: Expander.RunSingleStep (expand/expander.go:186) stops selecting RunTopologicalMergeProjection and falls through to the legacy source-batched expander, and newPrincipalGroupStream/buildProjectionDB drop to the buffer-and-sort path. Nothing fails — pebble_expansion_fastpath_test.go calls RunTopologicalMergeProjection directly, bypassing the gate. Add a forwarder here and an assertion in pebble_store_capabilities_test.go.

Suggested change
func (s *pebbleStore) CurrentDBSizeBytes() (int64, error) {
func (s *pebbleStore) GrantsForEntitlementPrincipalSorted() bool {
return s.Engine.GrantsForEntitlementPrincipalSorted()
}
func (s *pebbleStore) CurrentDBSizeBytes() (int64, error) {

Comment thread pkg/dotc1z/pebble_store_capabilities_test.go Outdated
Comment on lines +42 to +46
var mutatingEnginePrefixes = []string{
"Abort", "Add", "Begin", "Checkpoint", "Clear", "Delete", "Drop", "End",
"Ensure", "Finish", "Ingest", "Mark", "New", "Persist", "Put", "Replace",
"Reset", "Resume", "Set", "Stash", "Start", "Store", "Unsafe", "Wipe",
}

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: the verb list misses several mutating prefixes the engine actually uses, so those methods are invisible to this guard if a hand-written passthrough ever appears: Flush, CompactAllRanges, Save, InitCurrentSync, BuildGrantDigests / BuildDeferredGrantIndexes, InvalidateGrantDigestPartitions, RepairMissingGrantDigests. TestPebbleStoreEngineIsNotEmbedded covers re-embedding, but a single forwarder for e.g. Flush escapes both tests.

Suggested change
var mutatingEnginePrefixes = []string{
"Abort", "Add", "Begin", "Checkpoint", "Clear", "Delete", "Drop", "End",
"Ensure", "Finish", "Ingest", "Mark", "New", "Persist", "Put", "Replace",
"Reset", "Resume", "Set", "Stash", "Start", "Store", "Unsafe", "Wipe",
}
var mutatingEnginePrefixes = []string{
"Abort", "Add", "Begin", "Build", "Checkpoint", "Clear", "Compact", "Delete",
"Drop", "End", "Ensure", "Finish", "Flush", "Ingest", "Init", "Invalidate",
"Mark", "New", "Persist", "Put", "Replace", "Repair", "Reset", "Resume",
"Save", "Set", "Stash", "Start", "Store", "Unsafe", "Wipe",
}

@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.

Blocking issues found — see review comments.

@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.

Blocking issues found — see review comments.

@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.

Blocking issues found — see review comments.

// WithEngineMutation runs a direct engine mutation under the owning store's
// admission guard. A bare *Engine has no envelope lifecycle to coordinate, so
// it executes the callback directly and relies on the Engine's own write guard.
func WithEngineMutation(ctx context.Context, target any, fn func(context.Context, *Engine) error) error {

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.

🔴 Security/Compat — 🟠 Bug (high confidence): this replaces two exported functions that shipped in the released SDK. pebble.MarkStoreDirty and pebble.AddFoldDeadBytes both exist at tag v0.21.0 (merge_accessor.go:171,186) and are deleted here with no deprecated shim, while pkg/sdk/version.go still reads v0.21.0 and the PR description doesn't mention the removal. Any out-of-tree caller of github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble fails to compile on upgrade with no migration signal.

Per the repo's SDK criteria (deprecate-before-remove; breaking changes must be reflected in pkg/sdk/version.go and described in the PR), either keep thin // Deprecated: wrappers that delegate to WithEngineMutation / WithEngineFoldMutation for one release, or bump the 0.x minor in pkg/sdk/version.go and call the removal out explicitly in the PR body. Note the new TestPebbleStoreMutationWrapperInventory AST guard forbids the identifier MarkStoreDirty anywhere under pkg/, so a shim would need that guard narrowed to the store's own call sites.

if ts := rec.GetEndedAt(); ts != nil && ts.AsTime().After(maxEnded) {
maxEnded = ts.AsTime()
var newSyncID string
err = enginepkg.WithEngineFoldMutation(ctx, c.compactedC1z, func(ctx context.Context, destEng *enginepkg.Engine) (int64, error) {

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): wrapping the whole fold in WithEngineFoldMutation moves the dirty flip from after success to before any work. withMutation sets s.dirty = true at admission (pkg/dotc1z/pebble_store.go:276-278), whereas the old code only called MarkStoreDirty on the last line after the provenance write. So every fold failure path now leaves the dest dirty: merge error, digest invalidate/repair error, PutSyncRunRecord error, and — the common one in production — ctx.Err() from the run-duration deadline at compactor_pebble.go:517.

Compact's deferred c.compactedC1z.Close(ctx) (compactor.go:362-370) then runs the full save(): CheckpointTo plus a whole-envelope encode over the entire inherited base keyspace, for an artifact that is never published (cpFile is skipped on the error return). At whale scale — the only scale fold is selected for — that is an O(base) checkpoint+encode charged to every aborted fold, and on the deadline path it runs after the budget already expired, which is exactly what runDuration exists to bound. RunPebbleFoldMutation also accumulates foldDeadBytes when the callback returns an error (pebble_store.go:523-530), so that discarded envelope carries an inflated cumulative counter too.

Suggest restoring the success-only semantics: have the fold callback report dirtiness explicitly (e.g. only bump dirty/foldDeadBytes when mutationErr == nil), or open the fold with an admission mode that does not pre-mark dirty and mark it once the provenance write lands.


interruptCases := append(parityCases(), cyclicCases()...)
if testing.Short() {
if testing.Short() || !fullTestSuite() {

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): this is a coverage reduction on Linux CI, not just a Windows-gating rename. The old condition was testing.Short(), and .github/workflows/ci.yaml:39 runs the Linux job without -short, so Linux PR CI previously swept the full parityCases() + cyclicCases() matrix here (and at :190, and at topological_merge_layer_interrupt_test.go:99). With || !fullTestSuite() it drops to two representative cases everywhere, because no workflow sets BATON_FULL_TESTS — ci.yaml only runs bare go test ./... plus make chaos-check.

Same pattern in three other places in this PR, all previously full on Linux CI:

  • topological_merge_differential_test.go:479-484fuzzSeedRange now returns shortCount by default instead of longCount.
  • pkg/sync/checkpoint_cut_test.go:509,512,519 — cut points halved 16→8, 16→8, 12→6 unconditionally (no env escape; BATON_CUT_SWEEP=full still uncaps, but only in the manual tier).
  • pkg/dotc1z/race_test.go:36-40 — WAL checkpoint race 100 → 20 iterations.

That is a real reduction in the interruption/resume permutation coverage this repo's own bug-catching guidance names as the instrument for checkpoint/schedule-dependent risk, taken in the same PR that restructures store close/mutation concurrency. If the runtime cost is the motivation, consider adding a scheduled (nightly/weekly) workflow job that runs make test-full / make race-check so the full tier actually executes somewhere, rather than existing only as a Makefile target.

@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.

Blocking issues found — see review comments.

Comment thread pkg/dotc1z/engine/pebble/engine.go Outdated
Comment on lines 661 to 668
func (e *Engine) saveToC1z(ctx context.Context, dest string) error {
return errors.New("pebble engine: Save requires the dotc1z.Save shim (envelope write); use CheckpointTo for direct directory access")
}

// DBDir returns the on-disk path the engine writes to. Exported so
// databaseDir returns the on-disk path the engine writes to. Exported so
// the Adapter can implement OutputFilepath / CurrentDBSizeBytes.
func (e *Engine) DBDir() string {
func (e *Engine) databaseDir() string {
return e.dbDir

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.

🟠 Bug: This commit unexports ~47 methods on *pebble.Engine (plus deletes 4 and renames SavesaveToC1z, DBDirdatabaseDir) in pkg/dotc1z/engine/pebble, a public package already published at v0.21.0. pkg/sdk/version.go is unchanged, there are no // Deprecated: shims, and the PR description does not mention the removal — so any out-of-tree caller that reached the engine (via pebble.AsEngine) breaks at compile time with no signal. Per .claude/skills/ci-review.md, breaking SDK changes need a pkg/sdk/version.go minor bump and a migration note; deprecate-then-remove is the expected shape for symbols that shipped.

Also, databaseDir's comment still reads "Exported so the Adapter can implement…", which is no longer true, and saveToC1z now has no caller outside engine_test.go — worth deleting alongside the other callerless functions this commit removed.

Comment thread .raceout/run_1.txt Outdated

@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.

Blocking issues found — see review comments.

@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.

Blocking issues found — see review comments.

Comment thread pkg/dotc1z/pebble_store.go Outdated
Comment on lines 478 to 485
if !s.readOnly && s.dirty {
err := errors.New("pebble CloseEngineOnly: refusing to discard dirty writable store")
s.admission = pebbleStoreOpen
s.lastCloseErr = err
s.cond().Broadcast()
s.closeMu.Unlock()
return errors.New("pebble CloseEngineOnly: refusing to discard dirty writable store")
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: a failed CloseEngineOnly publishes its refusal into the shared lastCloseErr and reopens admission, so a Close(ctx) already parked in the waiter at :915-922 wakes, sees admission != Closing, and returns "pebble CloseEngineOnly: refusing to discard dirty writable store"without ever running save. On base the two serialized on closeMu, so the pending Close acquired the lock afterwards and saved normally; here the dirty store is silently left unsaved in tmpDir. Consider giving CloseEngineOnly its own error slot (or not touching lastCloseErr on the refusal path, and having waiters re-run the close instead of inheriting a foreign result). No in-tree caller hits this today — the compactor only calls CloseEngineOnly on read-only sources — hence suggestion rather than blocking.

Comment thread pkg/dotc1z/pebble_store.go Outdated
Comment thread pkg/dotc1z/pebble_store.go Outdated
Comment thread pkg/dotc1z/to_pebble.go
Comment on lines 203 to 233
@@ -226,7 +227,9 @@ func (c *C1File) ToPebble(ctx context.Context, outPath string, syncID string, op
if err = c.convertGrants(ctx, bi, syncID, cfg, &stats.Grants); err != nil {
return nil, fmt.Errorf("to-pebble: grants: %w", err)
}
if err = bi.Finish(ctx); err != nil {
if err = pebble.WithEngineMutation(ctx, dest, func(ctx context.Context, _ *pebble.Engine) error {
return bi.Finish(ctx)
}); err != nil {
return nil, fmt.Errorf("to-pebble: ingest: %w", 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: only StartBulkSyncImport and bi.Finish are admitted here — every actual record write (convertResourceTypes / convertResources / convertGrants through bi, lines 213-228) runs outside any admission window, and TestPebbleStoreAbandonedSessionsDoNotBlockClose explicitly asserts Close does not wait for an open bulk import. So a concurrent Close can close the engine mid-import while the code reads as guarded. ToPebble owns dest and closes it itself, so nothing races today; consider either holding one admission across the whole import or noting in a comment that the guard here only covers the lifecycle calls, not the writes.

@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.

Blocking issues found — see review comments.

Comment thread pkg/dotc1z/engine/pebble/engine.go Outdated
Comment on lines +202 to +215
if e, ok := target.(*Engine); ok && e != nil {
n, err := fn(ctx, e)
if err != nil {
return err
}
if n > 0 {
// fold_dead_bytes lives in the envelope manifest, which only the
// store writes. Accepting the count here would drop it, understating
// accumulated waste and deferring the rebuild that reclaims it, so
// a fold that shadows bytes has to target a store. A fold that
// shadowed nothing has nothing to record and is allowed through.
return errors.New("pebble WithEngineFoldMutation: bare engine cannot record fold dead bytes; fold into a store")
}
return 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: (new, medium confidence) The bare-engine guard rejects after fn has already applied the fold to the engine, so the caller gets an error describing a mutation that did happen and cannot be undone — the engine now carries the merged records while the return value says the operation failed. n is only knowable post-hoc, so the check can never be a real pre-flight. Since the whole point is that a bare engine has no envelope to record fold_dead_bytes into, consider rejecting bare-engine targets up front (before invoking fn) rather than after; if the n == 0 pass-through is needed for the test-only callers, gate it on an explicit opt-in instead of on the outcome.

Comment thread Makefile
Comment on lines +57 to +59
.PHONY: test-full
test-full: ## Run complete matrices and timing-sensitive soak iterations.
BATON_FULL_TESTS=1 BATON_CUT_SWEEP=full go test -tags=baton_lambda_support -count=1 -timeout=30m ./...

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: (carried over, unaddressed, medium confidence) test-full is the only thing that sets BATON_FULL_TESTS, and no workflow invokes it. Verified at 13226dc: .github/workflows/ci.yaml:39 runs go test ./... on Linux with no -short and no BATON_FULL_TESTS, and neither ci.yaml nor main.yaml calls make test-full / race-check / scheduler-soak. So the cases this PR moved behind the gate now run nowhere in CI: pkg/sync/expand/topological_merge_resume_test.go:39,190, topological_merge_layer_interrupt_test.go:99, topological_merge_differential_test.go:483, pkg/sync/scheduler_soak_test.go:158 (skips outright), and pkg/dotc1z/race_test.go:37 (100→20 WAL-race attempts). Given this PR is entirely about a concurrency lifecycle, losing the race/soak tier is the coverage you most want.

Fix: add a scheduled (or nightly) workflow that runs make test-full and ideally make race-check, otherwise the full tier is dead configuration.

Comment thread pkg/dotc1z/engine/pebble/engine.go Outdated
Comment on lines +291 to +293
for s.activeWrites > 0 {
s.cond().Wait()
}

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: (residual, low confidence) The rationale added above for the non-cancellable drain is sound — abandoning it would leave admitted writes running against an engine about to close. What is left is observability: compactPebble (pkg/synccompactor/compactor_pebble.go:1000) and compactPebbleFold (:490) each hold a single admission for an entire compaction, so a wedged merge or a Lambda freeze/thaw turns Close(ctx) into a silent unbounded block with no log line and no ctx.Err(). Consider a periodic warn from a watchdog goroutine naming activeWrites and elapsed time, so the wedge is diagnosable from logs rather than only from a stack dump.

@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.

Blocking issues found — see review comments.

@kans
kans force-pushed the kans/lock-safety branch from 13226dc to 4f1de11 Compare August 10, 2026 20:41

@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.

Blocking issues found — see review comments.

@kans
kans force-pushed the kans/lock-safety branch from 4f1de11 to 82c5884 Compare August 10, 2026 21:25
Comment thread pkg/dotc1z/c1file.go
var err error
defer func() { uotel.EndSpanWithError(span, err) }()
if c.rawDb == nil {
if c.rawDb == nil || !c.dbClosed.CompareAndSwap(false, true) {

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: closeRawDB no longer nils c.rawDb, which silently breaks the closed-handle guard in finalize at line 737 (if c.rawDb != nil) and its comment at 733–736 ("callers ... manually close c.rawDb ... that path skips both operations here"). That skip is now unreachable via closeRawDB: a C1File whose handle was released this way and then reaches finalize will run PRAGMA wal_checkpoint(TRUNCATE) on a closed *sql.DB, get sql.ErrConnDone, and take the cleanupDbDir(c.dbFilePath, finalizeErr) branch — deleting the working database instead of proceeding to saveC1z. No live caller hits this today (every closeRawDB site either returns immediately or sets c.closed), so this is latent, but the guard should be c.rawDb != nil && !c.dbClosed.Load() and the comment updated. (medium confidence)

Comment thread pkg/dotc1z/format/v3/indexed.go Outdated
Comment on lines 211 to 220
if err == nil {
srcFile = f
defer srcFile.Close()
} else if !errors.Is(err, os.ErrNotExist) {
return stats, fmt.Errorf("c1z v3: open splice source: %w", err)
}
srcFile = f
defer srcFile.Close()
// Reuse is an optimization, not a durability dependency. The
// extracted payload is complete, so if its source envelope was
// removed while the store was open, encode every frame afresh.
}

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: the ErrNotExist fallback is silent — it emits no log and no stat distinguishable from "nothing was reusable". A wrong or stale reuse.srcPath (path bug, source rotated under the store, caller passing a PayloadReuse from a different envelope) now converts an O(changed-frames) splice save into a full O(payload) re-encode of a whale-scale file with no signal anywhere; the caller in pebbleStore.save discards SpliceStats, so nothing else notices either. Log at warn with reuse.srcPath before falling through so the regression is diagnosable in the field. (high confidence that no signal is emitted; low severity)

}
var id string
var started bool
err := s.withMutation(func(e *pebble.Engine) error {

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 (new, medium confidence): withMutation sets dirty on admission, which widens the dirty condition versus the targeted markDirty calls it replaced. StartOrResumeSync used to flip dirty only when err == nil && started — a pure resume left the store clean. It now marks dirty unconditionally, so an open that resumes a sync and then does nothing (or fails immediately) pays a full CheckpointTo + envelope re-encode at Close, which is the dominant cost on a whale-scale c1z. Same widening applies to BeginExpandedGrantLayer / AddExpandedGrantLayerContributions / AbortExpandedGrantLayer (lines 917-958), which previously left dirty alone and relied on FinishExpandedGrantLayer — an abandoned, never-finished layer's already-ingested segments now reach the saved envelope. If admission-time dirty is deliberate for the ordinary write paths, consider keeping the started-conditional flip here (and skipping it on Abort) so a no-op resume does not force a save.

s.closeMu.Lock()
if s.closed {
s.closeMu.Unlock()
if !s.beginClose() {

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 (new, low confidence): CloseEngineOnly enters the same uncancellable beginClose drain as Close, but has no warnWhileBlocked watchdog. This is the teardown the k-way compactor uses for every source chunk (pkg/synccompactor/pebble/kway.go:324), so a wedged admission here blocks with no ctx.Err(), no log line, and — unlike Close — nothing naming activeWrites. Wrapping this call the same way (it would need a ctx, which CloseEngineOnly does not currently take) would make both blocking teardowns diagnosable from logs rather than only from a stack dump.

@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.

Blocking issues found — see review comments.

@kans
kans force-pushed the kans/lock-safety branch from d024fb3 to 55e0f3c Compare August 11, 2026 22:31
Comment on lines +1030 to 1038
stopWarning := s.warnWhileBlocked(ctx)
owned := s.beginClose()
stopWarning()
if !owned {
return nil
}

if !s.readOnly && s.dirty {
if !s.readOnly && s.isDirty() {
if err := s.save(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: the drain watchdog is stopped at line 1032, before save() runs, so only one of Close's two long phases is instrumented. save() is CheckpointTo + a full envelope encode, and this PR's own new SpliceStats.ReuseMissingPath signal exists precisely because that encode can silently degrade from O(changed frames) to O(payload) on a whale-scale file. An operator looking at logs for a Close that has been running for ten minutes gets periodic "still waiting to tear down" lines if it is stuck in the drain, and complete silence if it is stuck in the save. Consider deferring stopWarning() until after save() (with the message distinguishing drain from save), or emitting the save phase at Info rather than Debug. (medium confidence, non-blocking)

@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.

Blocking issues found — see review comments.

…gine surface

A Close could race the writes it was supposed to be waiting for, in both
storage paths, and the Pebble store's engine was reachable in ways that
skipped whatever guard was in place.

The Pebble store now admits every mutation through an explicit
open/closing/closed gate, so a teardown drains the writes it admitted
before it touches the engine, and a second Close reports success rather
than replaying the first one's terminal error or inheriting a foreign
teardown's failure. The drain is deliberately uncancellable, so a
watchdog logs what it is waiting on while it blocks.

C1File tracks closed-ness with an atomic flag instead of nil-ing the
handle out from under concurrent readers, which was a live data race.
The save path keys off whether the handle is open rather than whether
the pointer is non-nil, so releasing it out of band can no longer make
finalize discard the database.

pebbleStore no longer embeds *pebble.Engine. It forwards what it needs,
so no engine mutator reaches a caller without passing the gate; a test
walks the AST to keep that true, and compile-time assertions pin the
optional capability interfaces that callers discover by type assertion,
which an un-embedding would otherwise drop silently into a slow path.
The engine's exported surface goes from 179 methods to 128, four of
which had no callers at all.

Folds mark the store dirty and account dead bytes only when they
succeed, and a fold aimed at a bare engine is refused before it runs,
since there is nowhere to record the count afterwards.

Co-authored-by: Cursor <cursoragent@cursor.com>
@kans
kans force-pushed the kans/lock-safety branch from 55e0f3c to b787168 Compare August 12, 2026 21:52
Comment on lines +512 to +522
for _, n := range enumerateCutPoints(baseline.checkpoints, 8) {
cuts = append(cuts, cut{name: fmt.Sprintf("checkpoint-%02d", n), checkpoint: n, cause: errInjectedCut})
}
for _, m := range enumerateCutPoints(baseline.responses, 16) {
for _, m := range enumerateCutPoints(baseline.responses, 8) {
cuts = append(cuts, cut{name: fmt.Sprintf("response-%02d", m), response: m, cause: errInjectedCut})
}
// Expiry cuts take the run-duration deadline path, which force-writes
// a checkpoint of the MID-BATCH stack (spawned cursors in flight)
// before exiting — the token shape hard cuts never persist, and the
// one resume bugs have historically hidden in.
for _, m := range enumerateCutPoints(baseline.responses, 12) {
for _, m := range enumerateCutPoints(baseline.responses, 6) {

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: the sampled cut sweep is halved here (16→8, 16→8, 12→6) in the same PR that moves the heavyweight matrices behind BATON_FULL_TESTS. enumerateCutPoints only ignores the limit when BATON_CUT_SWEEP=full, which is set by make checkpoint-cut-check (nightly) — so nightly stays exhaustive, but ordinary PR CI now exercises half as many checkpoint/response/expiry resume points. Given this PR restructures store teardown and the save/dirty decision, resume coverage is exactly the axis worth keeping wide on the PR path; consider leaving the sampled limits at 16/16/12 or stating why halving is acceptable.

Confidence: medium.

@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.

The list asked a question names cannot answer: whether a method writes.
It classified by prefix, so it missed any mutator whose name starts with
a verb nobody thought of, saw nothing on the facade types, and reported
false confidence in between — while reading like the thing standing
between the store and an unguarded write.

What stays is the part that is structural rather than inferential: the
engine sits in a named field, so the store's method set is exactly what
this package declares, and TestPebbleStoreEngineIsNotEmbedded fails if
that changes. The AST inventory still proves every wrapper we declare
takes admission. The residual gap — a hand-written passthrough that
writes through the engine without admission — is not covered by naming
either way, and closing it belongs with the gate unification rather than
with a longer list of verbs.

Co-authored-by: Cursor <cursoragent@cursor.com>
Comment on lines +10 to +19
// guardedMutationWrappers is the set of pebbleStore methods that mutate.
// TestPebbleStoreMutationWrapperInventory proves, via the AST, that every
// declaration bearing one of these names calls withMutation, so a wrapper
// that stops taking admission fails there rather than at runtime.
//
// What this list is not: proof that the store exposes no other writes. It
// covers the wrappers we know about. The store's method set being closed —
// the engine is a named field, not embedded, so nothing is promoted — is what
// keeps the unknown set small enough to review; see
// TestPebbleStoreEngineIsNotEmbedded.

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: dropping TestPebbleStoreDoesNotPromoteEngineMutators leaves no automated check on the reverse direction, and the two surviving guards do not cover it. TestPebbleStoreMutationWrapperInventory skips any decl whose name is not already in guardedMutationWrappers (if _, isTracked := tracked[fn.Name.Name]; !isTracked { continue }), and TestPebbleStoreEngineIsNotEmbedded only proves nothing is promoted. A hand-written passthrough such as func (s *pebbleStore) PutSyncRunRecord(ctx, r) error { return s.Engine.PutSyncRunRecord(ctx, r) } added to pebble_store_reads.go now compiles and passes every test in this package with no admission and no dirty bit — the exact escape the deleted test existed to catch (still reachable via PutSyncRunRecord, PutGrantRecord, SetCurrentSync, Flush, CompactAllRanges, PersistSyncStats, DropAllGrantDigestState, … which stayed exported on the engine).

The verb heuristic was indeed the weak half, but a non-heuristic replacement is available: snapshot the store's full method set with reflect and require it to equal guardedMutationWrappers ∪ a pinned list of the read forwarders in pebble_store_reads.go. That fails for any new store method — mutating verb or not — and forces the author to classify it, rather than guessing from the name. (medium confidence)

@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