Skip to content

Commit bb45595

Browse files
steveyeggeclaude
andcommitted
fix: Commit() excludes config table to prevent issue_prefix corruption (GH#2455)
DOLT_COMMIT('-Am') stages ALL dirty tables, sweeping up stale config changes from concurrent operations. Replace with selective staging that queries dolt_status and skips the config table. Add CommitWithConfig() for callers that intentionally modify config (bd dolt commit). Also fix RunMigrations(), FK migration, and merge conflict resolution to use explicit DOLT_ADD instead of -Am. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0827ccd commit bb45595

File tree

3 files changed

+175
-30
lines changed

3 files changed

+175
-30
lines changed

internal/storage/dolt/migrations.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,20 @@ func RunMigrations(db *sql.DB) error {
4040
}
4141
}
4242

43-
// Commit schema changes via Dolt (idempotent - no-ops if nothing changed)
44-
_, err := db.Exec("CALL DOLT_COMMIT('-Am', 'schema: auto-migrate')")
43+
// GH#2455: Stage only schema tables (not config) to avoid sweeping up
44+
// stale issue_prefix changes from concurrent operations. The old '-Am'
45+
// approach staged ALL dirty tables including config.
46+
migrationTables := []string{
47+
"issues", "wisps", "events", "wisp_events", "dependencies",
48+
"wisp_dependencies", "labels", "wisp_labels", "comments",
49+
"wisp_comments", "metadata", "child_counters", "issue_counter",
50+
"issue_snapshots", "compaction_snapshots", "federation_peers",
51+
"dolt_ignore",
52+
}
53+
for _, table := range migrationTables {
54+
_, _ = db.Exec("CALL DOLT_ADD(?)", table)
55+
}
56+
_, err := db.Exec("CALL DOLT_COMMIT('-m', 'schema: auto-migrate')")
4557
if err != nil {
4658
// "nothing to commit" is expected when migrations were already applied
4759
if !strings.Contains(strings.ToLower(err.Error()), "nothing to commit") {

internal/storage/dolt/store.go

Lines changed: 74 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import (
1818
"errors"
1919
"fmt"
2020
"hash/fnv"
21-
"log"
2221
"net"
2322
"os"
2423
"os/exec"
@@ -1017,7 +1016,9 @@ func initSchemaOnDB(ctx context.Context, db *sql.DB) error {
10171016
_, err = db.ExecContext(ctx, "ALTER TABLE dependencies DROP FOREIGN KEY fk_dep_depends_on")
10181017
if err == nil {
10191018
// DDL change succeeded - commit it so it persists (required for Dolt server mode)
1020-
_, _ = db.ExecContext(ctx, "CALL DOLT_COMMIT('-Am', 'migration: remove fk_dep_depends_on for external references')") // Best effort: migration commit is advisory; schema change already applied
1019+
// GH#2455: Stage only the affected table, not all dirty tables.
1020+
_, _ = db.ExecContext(ctx, "CALL DOLT_ADD('dependencies')")
1021+
_, _ = db.ExecContext(ctx, "CALL DOLT_COMMIT('-m', 'migration: remove fk_dep_depends_on for external references')") // Best effort: migration commit is advisory; schema change already applied
10211022
} else if !strings.Contains(strings.ToLower(err.Error()), "can't drop") &&
10221023
!strings.Contains(strings.ToLower(err.Error()), "doesn't exist") &&
10231024
!strings.Contains(strings.ToLower(err.Error()), "check that it exists") &&
@@ -1205,51 +1206,92 @@ func (s *DoltStore) commitAuthorString() string {
12051206

12061207
// Commit creates a Dolt commit with the given message.
12071208
//
1208-
// Uses DOLT_COMMIT('-Am') to stage all dirty tables and commit. This is the
1209-
// general-purpose commit used by maybeAutoCommit and callers that want to
1210-
// commit all pending working set changes. Defense-in-depth for GH#2455:
1211-
// snapshots issue_prefix before committing and restores it if corrupted
1212-
// by stale working set changes from concurrent operations.
1209+
// GH#2455: Stages all dirty tables EXCEPT config, then commits with '-m'.
1210+
// The old '-Am' approach staged ALL dirty tables including config, which
1211+
// swept up stale issue_prefix changes from concurrent operations. By
1212+
// excluding config from automatic staging, we prevent the corruption.
1213+
//
1214+
// Callers that intentionally modify config (e.g., CommitPending after
1215+
// 'bd config set') must call CommitWithConfig instead.
12131216
func (s *DoltStore) Commit(ctx context.Context, message string) (retErr error) {
12141217
ctx, span := doltTracer.Start(ctx, "dolt.commit",
12151218
trace.WithSpanKind(trace.SpanKindClient),
12161219
trace.WithAttributes(s.doltSpanAttrs()...),
12171220
)
12181221
defer func() { endSpan(span, retErr) }()
12191222

1220-
// Pin a single connection so snapshot, commit, and restore all run
1221-
// on the same Dolt session. (GH#2455)
1223+
// Pin a single connection so all operations run on the same Dolt session.
12221224
conn, err := s.db.Conn(ctx)
12231225
if err != nil {
12241226
return fmt.Errorf("failed to acquire connection: %w", err)
12251227
}
12261228
defer conn.Close()
12271229

1228-
// Snapshot issue_prefix from HEAD before commit to detect corruption.
1229-
var prefixBefore string
1230-
_ = conn.QueryRowContext(ctx, "SELECT value FROM config WHERE `key` = 'issue_prefix'").Scan(&prefixBefore)
1230+
// GH#2455: Stage all dirty tables EXCEPT config. Query dolt_status for
1231+
// dirty tables and stage each one individually, skipping config to avoid
1232+
// sweeping up stale issue_prefix changes from concurrent operations.
1233+
rows, err := conn.QueryContext(ctx, "SELECT table_name FROM dolt_status")
1234+
if err != nil {
1235+
// If dolt_status fails, fall back to nothing (rare edge case).
1236+
return fmt.Errorf("failed to query dolt_status: %w", err)
1237+
}
1238+
var tables []string
1239+
for rows.Next() {
1240+
var table string
1241+
if err := rows.Scan(&table); err != nil {
1242+
_ = rows.Close()
1243+
return fmt.Errorf("failed to scan dolt_status: %w", err)
1244+
}
1245+
if table != "config" {
1246+
tables = append(tables, table)
1247+
}
1248+
}
1249+
_ = rows.Close()
1250+
if err := rows.Err(); err != nil {
1251+
return fmt.Errorf("failed to iterate dolt_status: %w", err)
1252+
}
1253+
1254+
if len(tables) == 0 {
1255+
return nil // Nothing to commit (all changes were config-only or dolt_ignore'd)
1256+
}
1257+
1258+
for _, table := range tables {
1259+
if _, err := conn.ExecContext(ctx, "CALL DOLT_ADD(?)", table); err != nil {
1260+
// Best effort: some tables may be dolt_ignore'd (e.g., wisps).
1261+
// DOLT_ADD fails for ignored tables; skip silently.
1262+
continue
1263+
}
1264+
}
12311265

12321266
// NOTE: In SQL procedure mode, Dolt defaults author to the authenticated SQL user
12331267
// (e.g. root@localhost). Always pass an explicit author for deterministic history.
1234-
if _, err := conn.ExecContext(ctx, "CALL DOLT_COMMIT('-Am', ?, '--author', ?)", message, s.commitAuthorString()); err != nil {
1268+
if _, err := conn.ExecContext(ctx, "CALL DOLT_COMMIT('-m', ?, '--author', ?)", message, s.commitAuthorString()); err != nil {
12351269
if isDoltNothingToCommit(err) {
12361270
return nil
12371271
}
12381272
return fmt.Errorf("failed to commit: %w", err)
12391273
}
12401274

1241-
// Verify issue_prefix wasn't corrupted by sweeping up stale working set changes.
1242-
if prefixBefore != "" {
1243-
var prefixAfter string
1244-
_ = conn.QueryRowContext(ctx, "SELECT value FROM config WHERE `key` = 'issue_prefix'").Scan(&prefixAfter)
1245-
if prefixAfter != prefixBefore {
1246-
// Restore the correct prefix and create a fix-up commit.
1247-
log.Printf("WARNING: issue_prefix corrupted by DOLT_COMMIT (%q -> %q), restoring (GH#2455)", prefixBefore, prefixAfter)
1248-
_, _ = conn.ExecContext(ctx, "UPDATE config SET value = ? WHERE `key` = 'issue_prefix'", prefixBefore)
1249-
_, _ = conn.ExecContext(ctx, "CALL DOLT_COMMIT('-Am', 'fix: restore issue_prefix corrupted by stale working set (GH#2455)', '--author', ?)", s.commitAuthorString())
1250-
}
1275+
return nil
1276+
}
1277+
1278+
// CommitWithConfig creates a Dolt commit that includes the config table.
1279+
// Use this instead of Commit when the caller intentionally modified config
1280+
// (e.g., CommitPending after 'bd config set', 'bd init', or 'bd rename-prefix').
1281+
// GH#2455: Commit() excludes config to prevent sweeping up stale changes.
1282+
func (s *DoltStore) CommitWithConfig(ctx context.Context, message string) error {
1283+
conn, err := s.db.Conn(ctx)
1284+
if err != nil {
1285+
return fmt.Errorf("failed to acquire connection: %w", err)
12511286
}
1287+
defer conn.Close()
12521288

1289+
if _, err := conn.ExecContext(ctx, "CALL DOLT_COMMIT('-Am', ?, '--author', ?)", message, s.commitAuthorString()); err != nil {
1290+
if isDoltNothingToCommit(err) {
1291+
return nil
1292+
}
1293+
return fmt.Errorf("failed to commit: %w", err)
1294+
}
12531295
return nil
12541296
}
12551297

@@ -1301,7 +1343,10 @@ func (s *DoltStore) CommitPending(ctx context.Context, actor string) (bool, erro
13011343
}
13021344

13031345
msg := s.buildBatchCommitMessage(ctx, actor)
1304-
if err := s.Commit(ctx, msg); err != nil {
1346+
// GH#2455: CommitPending is an explicit user action (bd dolt commit) that
1347+
// should include ALL pending changes, including config. Use CommitWithConfig
1348+
// instead of Commit to ensure intentional config changes are committed.
1349+
if err := s.CommitWithConfig(ctx, msg); err != nil {
13051350
// Dolt may report "nothing to commit" even when Status() showed changes
13061351
// (e.g., system tables or schema-only diffs). Treat as no-op.
13071352
errLower := strings.ToLower(err.Error())
@@ -1711,8 +1756,11 @@ func (s *DoltStore) tryAutoResolveMetadataConflicts(ctx context.Context, tx *sql
17111756
return false, fmt.Errorf("failed to resolve metadata conflicts: %w", err)
17121757
}
17131758

1714-
// Commit the merge with resolved conflicts.
1715-
if _, err := tx.ExecContext(ctx, "CALL DOLT_COMMIT('-Am', 'auto-resolve metadata merge conflicts (GH#2466)')"); err != nil {
1759+
// GH#2455: Stage only metadata (the table we resolved), not all dirty tables.
1760+
if _, err := tx.ExecContext(ctx, "CALL DOLT_ADD('metadata')"); err != nil {
1761+
return false, fmt.Errorf("failed to stage metadata: %w", err)
1762+
}
1763+
if _, err := tx.ExecContext(ctx, "CALL DOLT_COMMIT('-m', 'auto-resolve metadata merge conflicts (GH#2466)')"); err != nil {
17161764
return false, fmt.Errorf("failed to commit resolved conflicts: %w", err)
17171765
}
17181766

internal/storage/dolt/wisp_gc_test.go

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -279,11 +279,12 @@ func TestRunInTransaction_DoesNotCorruptConfig(t *testing.T) {
279279

280280
// Set the canonical issue_prefix and create an issue BEFORE corrupting config.
281281
// CreateIssue has its own DOLT_COMMIT, so we must set up the issue first.
282+
// Use CommitWithConfig since we're intentionally modifying config.
282283
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
283284
t.Fatalf("SetConfig: %v", err)
284285
}
285-
if err := store.Commit(ctx, "set prefix"); err != nil {
286-
t.Fatalf("Commit: %v", err)
286+
if err := store.CommitWithConfig(ctx, "set prefix"); err != nil {
287+
t.Fatalf("CommitWithConfig: %v", err)
287288
}
288289

289290
issue := &types.Issue{
@@ -340,6 +341,90 @@ func TestRunInTransaction_DoesNotCorruptConfig(t *testing.T) {
340341
}
341342
}
342343

344+
// TestCommit_ExcludesConfig verifies that s.Commit() does NOT stage the config
345+
// table, preventing stale issue_prefix changes from being swept into commits.
346+
// This is the core fix for GH#2455: Commit() now stages all dirty tables
347+
// EXCEPT config. Config is only committed by CommitWithConfig (used by
348+
// CommitPending for explicit 'bd dolt commit').
349+
func TestCommit_ExcludesConfig(t *testing.T) {
350+
store, cleanup := setupTestStore(t)
351+
defer cleanup()
352+
353+
ctx, cancel := testContext(t)
354+
defer cancel()
355+
356+
// Set and commit a known-good prefix via CommitWithConfig
357+
if err := store.SetConfig(ctx, "issue_prefix", "correct"); err != nil {
358+
t.Fatalf("SetConfig: %v", err)
359+
}
360+
if err := store.CommitWithConfig(ctx, "set correct prefix"); err != nil {
361+
t.Fatalf("CommitWithConfig: %v", err)
362+
}
363+
364+
// Simulate stale corruption: another operation modified issue_prefix in
365+
// the working set without DOLT_COMMIT'ing. The working set now has a
366+
// WRONG value while HEAD still has "correct".
367+
_, err := store.db.ExecContext(ctx, "UPDATE config SET value = 'WRONG' WHERE `key` = 'issue_prefix'")
368+
if err != nil {
369+
t.Fatalf("simulate stale config change: %v", err)
370+
}
371+
372+
// Also dirty a real table so Commit() has something to commit.
373+
_, err = store.db.ExecContext(ctx, "INSERT INTO metadata (`key`, value) VALUES ('_gh2455_test', 'v') ON DUPLICATE KEY UPDATE value = 'v'")
374+
if err != nil {
375+
t.Fatalf("insert metadata: %v", err)
376+
}
377+
378+
// Call s.Commit() — the general-purpose path. With the old '-Am'
379+
// approach, this would stage ALL dirty tables including config,
380+
// committing the "WRONG" value. With the fix, Commit() skips config
381+
// entirely, so the committed issue_prefix remains "correct".
382+
if err := store.Commit(ctx, "test commit that should skip config"); err != nil {
383+
t.Fatalf("Commit: %v", err)
384+
}
385+
386+
// Verify issue_prefix was NOT corrupted — HEAD should still have "correct".
387+
var committedPrefix string
388+
err = store.db.QueryRowContext(ctx,
389+
"SELECT value FROM config AS OF 'HEAD' WHERE `key` = 'issue_prefix'").Scan(&committedPrefix)
390+
if err != nil {
391+
t.Fatalf("query HEAD prefix: %v", err)
392+
}
393+
if committedPrefix != "correct" {
394+
t.Errorf("GH#2455 regression: HEAD issue_prefix = %q, want %q", committedPrefix, "correct")
395+
}
396+
}
397+
398+
// TestCommitWithConfig_IncludesConfig verifies that CommitWithConfig DOES
399+
// commit config changes. This is used by CommitPending (bd dolt commit)
400+
// when the user intentionally modified config.
401+
func TestCommitWithConfig_IncludesConfig(t *testing.T) {
402+
store, cleanup := setupTestStore(t)
403+
defer cleanup()
404+
405+
ctx, cancel := testContext(t)
406+
defer cancel()
407+
408+
// Change issue_prefix and commit with CommitWithConfig (intentional change)
409+
if err := store.SetConfig(ctx, "issue_prefix", "newprefix"); err != nil {
410+
t.Fatalf("SetConfig: %v", err)
411+
}
412+
if err := store.CommitWithConfig(ctx, "intentional prefix change"); err != nil {
413+
t.Fatalf("CommitWithConfig: %v", err)
414+
}
415+
416+
// Verify the intentional change was committed
417+
var committedPrefix string
418+
err := store.db.QueryRowContext(ctx,
419+
"SELECT value FROM config AS OF 'HEAD' WHERE `key` = 'issue_prefix'").Scan(&committedPrefix)
420+
if err != nil {
421+
t.Fatalf("query HEAD prefix: %v", err)
422+
}
423+
if committedPrefix != "newprefix" {
424+
t.Errorf("CommitWithConfig should include config: HEAD issue_prefix = %q, want %q", committedPrefix, "newprefix")
425+
}
426+
}
427+
343428
// TestFindWispDependentsRecursive_NoDependents verifies wisps with no
344429
// dependents return an empty map.
345430
func TestFindWispDependentsRecursive_NoDependents(t *testing.T) {

0 commit comments

Comments
 (0)