[object Object]

← back to Cli Printing Press

fix(cli): force sync concurrency=1 under PRINTING_PRESS_VERIFY to avoid SQLITE_BUSY (#1441)

b8b1b6a4d4b6a8f037b8dc3f78625a1b676ea402 · 2026-05-15 10:46:55 -0700 · Trevin Chow

* fix(cli): force sync concurrency=1 under PRINTING_PRESS_VERIFY to avoid SQLITE_BUSY

Under PRINTING_PRESS_VERIFY=1 (mock/dry-run), the generated sync command's
worker pool used to race on SQLite writes once network latency disappeared,
tripping SQLITE_BUSY despite _busy_timeout. validate-narrative --full-examples
failed any quickstart or recipe that exercised sync on a CLI with more than a
handful of resources.

Force concurrency to 1 only when cliutil.IsVerifyEnv() returns true; real-world
sync keeps the parallel worker pool unchanged.

Touches sync.go.tmpl and graphql_sync.go.tmpl plus generator regression tests
that pin the import, the IsVerifyEnv() guard, and the after-default ordering.
Three sync.go golden fixtures refresh accordingly.

Closes #1205

* fix(cli): correct misleading ordering rationale in sync verify-env test helper

Greptile flagged the comment claim that placing the verify-env block before
the default-resolution block "would reset the pin on every call." That's
wrong — `1 < 1` is false, so the default block is a no-op once the verify
block sets concurrency to 1, regardless of ordering.

Reframe the assertion as a defensive guard against template churn rather
than a correctness invariant, and remove the false rationale from the
helper's docstring and assertion message.

Files touched

Diff

commit b8b1b6a4d4b6a8f037b8dc3f78625a1b676ea402
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Fri May 15 10:46:55 2026 -0700

    fix(cli): force sync concurrency=1 under PRINTING_PRESS_VERIFY to avoid SQLITE_BUSY (#1441)
    
    * fix(cli): force sync concurrency=1 under PRINTING_PRESS_VERIFY to avoid SQLITE_BUSY
    
    Under PRINTING_PRESS_VERIFY=1 (mock/dry-run), the generated sync command's
    worker pool used to race on SQLite writes once network latency disappeared,
    tripping SQLITE_BUSY despite _busy_timeout. validate-narrative --full-examples
    failed any quickstart or recipe that exercised sync on a CLI with more than a
    handful of resources.
    
    Force concurrency to 1 only when cliutil.IsVerifyEnv() returns true; real-world
    sync keeps the parallel worker pool unchanged.
    
    Touches sync.go.tmpl and graphql_sync.go.tmpl plus generator regression tests
    that pin the import, the IsVerifyEnv() guard, and the after-default ordering.
    Three sync.go golden fixtures refresh accordingly.
    
    Closes #1205
    
    * fix(cli): correct misleading ordering rationale in sync verify-env test helper
    
    Greptile flagged the comment claim that placing the verify-env block before
    the default-resolution block "would reset the pin on every call." That's
    wrong — `1 < 1` is false, so the default block is a no-op once the verify
    block sets concurrency to 1, regardless of ordering.
    
    Reframe the assertion as a defensive guard against template churn rather
    than a correctness invariant, and remove the false rationale from the
    helper's docstring and assertion message.
---
 internal/generator/generator_test.go               | 100 +++++++++++++++++++++
 internal/generator/templates/graphql_sync.go.tmpl  |   8 ++
 internal/generator/templates/sync.go.tmpl          |   8 ++
 .../printing-press-golden/internal/cli/sync.go     |   8 ++
 .../sync-walker-golden/internal/cli/sync.go        |   8 ++
 .../tier-routing-golden/internal/cli/sync.go       |   8 ++
 6 files changed, 140 insertions(+)

diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 0807cda8..d41f0486 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -7521,6 +7521,106 @@ func TestGeneratedSyncMaxPagesAndStickyCursor(t *testing.T) {
 	runGoCommand(t, outputDir, "build", "./...")
 }
 
+// assertVerifyEnvConcurrencyPin pins the verify-mode worker-pool override
+// in generated sync.go: cliutil import present, IsVerifyEnv() guard pins
+// concurrency to 1, and the guard sits after the default-resolution block.
+// Ordering is not load-bearing for correctness today (the default block's
+// `if concurrency < 1 { concurrency = 4 }` is a no-op when concurrency is
+// already 1), but pinning it as a defensive guard prevents future template
+// churn from drifting the placement and obscuring the worker-pool comment
+// block this override controls.
+func assertVerifyEnvConcurrencyPin(t *testing.T, syncContent, cliutilImportPath, label string) {
+	t.Helper()
+	assert.Contains(t, syncContent, `"`+cliutilImportPath+`"`,
+		label+": sync.go must import internal/cliutil to call IsVerifyEnv")
+	assert.Contains(t, syncContent, "if cliutil.IsVerifyEnv() {",
+		label+": sync.go must consult cliutil.IsVerifyEnv() before starting the worker pool")
+	assert.Regexp(t,
+		`if cliutil\.IsVerifyEnv\(\) \{\s*concurrency = 1\s*\}`,
+		syncContent,
+		label+": verify-env block must pin concurrency to 1")
+
+	defaultIdx := strings.Index(syncContent, "concurrency = 4")
+	verifyIdx := strings.Index(syncContent, "cliutil.IsVerifyEnv()")
+	require.NotEqual(t, -1, defaultIdx, label+": default-resolution block must be present")
+	require.NotEqual(t, -1, verifyIdx, label+": verify-env block must be present")
+	assert.Less(t, defaultIdx, verifyIdx,
+		label+": verify-env override must sit after the default-resolution block (as shipped)")
+}
+
+// TestGeneratedSyncForcesSingleWorkerUnderVerifyEnv pins the verify-mode
+// override in the REST sync template. Without it, the worker pool races on
+// SQLite writes once network latency disappears, tripping SQLITE_BUSY
+// despite _busy_timeout — which fails validate-narrative --full-examples
+// for any quickstart or recipe that exercises sync.
+func TestGeneratedSyncForcesSingleWorkerUnderVerifyEnv(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := &spec.APISpec{
+		Name:    "verifysync",
+		Version: "0.1.0",
+		BaseURL: "https://api.example.com",
+		Auth: spec.AuthConfig{
+			Type:    "api_key",
+			Header:  "Authorization",
+			Format:  "Bearer {token}",
+			EnvVars: []string{"VERIFYSYNC_API_KEY"},
+		},
+		Config: spec.ConfigSpec{
+			Format: "toml",
+			Path:   "~/.config/verifysync-pp-cli/config.toml",
+		},
+		Resources: map[string]spec.Resource{
+			"channels": {
+				Description: "Manage channels",
+				Endpoints: map[string]spec.Endpoint{
+					"list": {
+						Method:      "GET",
+						Path:        "/channels",
+						Description: "List channels",
+						Response:    spec.ResponseDef{Type: "array"},
+					},
+				},
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	syncGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "sync.go"))
+	require.NoError(t, err)
+
+	assertVerifyEnvConcurrencyPin(t, string(syncGo), naming.CLI(apiSpec.Name)+"/internal/cliutil", "REST sync.go")
+
+	runGoCommand(t, outputDir, "mod", "tidy")
+	runGoCommand(t, outputDir, "build", "./...")
+}
+
+// TestGeneratedGraphQLSyncForcesSingleWorkerUnderVerifyEnv pins the same
+// override in the GraphQL sync template. graphql_sync.go.tmpl drives
+// sync.go when isGraphQLSpec selects it; the race-on-SQLite problem under
+// PRINTING_PRESS_VERIFY=1 applies the same way.
+func TestGeneratedGraphQLSyncForcesSingleWorkerUnderVerifyEnv(t *testing.T) {
+	t.Parallel()
+
+	gqlSpec, err := graphql.ParseSDL(filepath.Join("..", "..", "testdata", "graphql", "test.graphql"))
+	require.NoError(t, err)
+
+	outputDir := filepath.Join(t.TempDir(), naming.CLI(gqlSpec.Name))
+	gen := New(gqlSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	syncGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "sync.go"))
+	require.NoError(t, err)
+
+	assertVerifyEnvConcurrencyPin(t, string(syncGo), naming.CLI(gqlSpec.Name)+"/internal/cliutil", "GraphQL sync.go")
+
+	runGoCommand(t, outputDir, "mod", "tidy")
+	runGoCommand(t, outputDir, "build", "./...")
+}
+
 // TestGeneratedSyncGatesSinceParamPerResource pins the fix for issue #900:
 // generators must only inject the incremental-cursor query parameter on
 // resources whose list endpoint actually declares it. Resources that don't
diff --git a/internal/generator/templates/graphql_sync.go.tmpl b/internal/generator/templates/graphql_sync.go.tmpl
index 0525ec57..98248e15 100644
--- a/internal/generator/templates/graphql_sync.go.tmpl
+++ b/internal/generator/templates/graphql_sync.go.tmpl
@@ -14,6 +14,7 @@ import (
 	"time"
 
 	"{{modulePath}}/internal/client"
+	"{{modulePath}}/internal/cliutil"
 	"{{modulePath}}/internal/store"
 	"github.com/spf13/cobra"
 )
@@ -126,6 +127,13 @@ Exit codes & warnings:
 			if concurrency < 1 {
 				concurrency = 4
 			}
+			// Under PRINTING_PRESS_VERIFY=1 (mock/dry-run), all goroutines
+			// reach SQLite without the natural serialization that network
+			// latency provides in real syncs, so the worker pool races on
+			// the writer and trips SQLITE_BUSY despite _busy_timeout.
+			if cliutil.IsVerifyEnv() {
+				concurrency = 1
+			}
 
 			started := time.Now()
 			work := make(chan string, len(resources))
diff --git a/internal/generator/templates/sync.go.tmpl b/internal/generator/templates/sync.go.tmpl
index 2faf3001..8848f4b9 100644
--- a/internal/generator/templates/sync.go.tmpl
+++ b/internal/generator/templates/sync.go.tmpl
@@ -21,6 +21,7 @@ import (
 {{- if .HasTierRouting}}
 	"{{modulePath}}/internal/client"
 {{- end}}
+	"{{modulePath}}/internal/cliutil"
 	"{{modulePath}}/internal/store"
 	"github.com/spf13/cobra"
 )
@@ -220,6 +221,13 @@ Resource scoping:
 			if concurrency < 1 {
 				concurrency = 4
 			}
+			// Under PRINTING_PRESS_VERIFY=1 (mock/dry-run), all goroutines
+			// reach SQLite without the natural serialization that network
+			// latency provides in real syncs, so the worker pool races on
+			// the writer and trips SQLITE_BUSY despite _busy_timeout.
+			if cliutil.IsVerifyEnv() {
+				concurrency = 1
+			}
 
 			started := time.Now()
 			work := make(chan string, len(resources))
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/sync.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/sync.go
index ed2ab002..ce6ad48a 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/sync.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/sync.go
@@ -9,6 +9,7 @@ import (
 	"github.com/spf13/cobra"
 	"net/url"
 	"os"
+	"printing-press-golden-pp-cli/internal/cliutil"
 	"printing-press-golden-pp-cli/internal/store"
 	"regexp"
 	"strconv"
@@ -185,6 +186,13 @@ Resource scoping:
 			if concurrency < 1 {
 				concurrency = 4
 			}
+			// Under PRINTING_PRESS_VERIFY=1 (mock/dry-run), all goroutines
+			// reach SQLite without the natural serialization that network
+			// latency provides in real syncs, so the worker pool races on
+			// the writer and trips SQLITE_BUSY despite _busy_timeout.
+			if cliutil.IsVerifyEnv() {
+				concurrency = 1
+			}
 
 			started := time.Now()
 			work := make(chan string, len(resources))
diff --git a/testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/cli/sync.go b/testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/cli/sync.go
index b2fff506..0d874121 100644
--- a/testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/cli/sync.go
+++ b/testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/cli/sync.go
@@ -13,6 +13,7 @@ import (
 	"strconv"
 	"strings"
 	"sync"
+	"sync-walker-golden-pp-cli/internal/cliutil"
 	"sync-walker-golden-pp-cli/internal/store"
 	"sync/atomic"
 	"time"
@@ -185,6 +186,13 @@ Resource scoping:
 			if concurrency < 1 {
 				concurrency = 4
 			}
+			// Under PRINTING_PRESS_VERIFY=1 (mock/dry-run), all goroutines
+			// reach SQLite without the natural serialization that network
+			// latency provides in real syncs, so the worker pool races on
+			// the writer and trips SQLITE_BUSY despite _busy_timeout.
+			if cliutil.IsVerifyEnv() {
+				concurrency = 1
+			}
 
 			started := time.Now()
 			work := make(chan string, len(resources))
diff --git a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/sync.go b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/sync.go
index 00ac7fa5..0c907a09 100644
--- a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/sync.go
+++ b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/sync.go
@@ -15,6 +15,7 @@ import (
 	"sync"
 	"sync/atomic"
 	"tier-routing-golden-pp-cli/internal/client"
+	"tier-routing-golden-pp-cli/internal/cliutil"
 	"tier-routing-golden-pp-cli/internal/store"
 	"time"
 )
@@ -183,6 +184,13 @@ Resource scoping:
 			if concurrency < 1 {
 				concurrency = 4
 			}
+			// Under PRINTING_PRESS_VERIFY=1 (mock/dry-run), all goroutines
+			// reach SQLite without the natural serialization that network
+			// latency provides in real syncs, so the worker pool races on
+			// the writer and trips SQLITE_BUSY despite _busy_timeout.
+			if cliutil.IsVerifyEnv() {
+				concurrency = 1
+			}
 
 			started := time.Now()
 			work := make(chan string, len(resources))

← dbfc6118 fix(cli): propagate PRINTING_PRESS_VERIFY=1 to browser-sessi  ·  back to Cli Printing Press  ·  fix(cli): strip root-level binaries from staged publish tree 096411ce →