Skip to content

fix: released binaries could not start — swap SQLite driver to pure Go - #128

Open
skyoo2003 wants to merge 5 commits into
mainfrom
fix/pure-go-sqlite
Open

fix: released binaries could not start — swap SQLite driver to pure Go#128
skyoo2003 wants to merge 5 commits into
mainfrom
fix/pure-go-sqlite

Conversation

@skyoo2003

Copy link
Copy Markdown
Owner

Stacked on #127. Base is chore/release-hardening; GitHub will retarget this to main when that merges.

Summary

Every downloadable artifact DevCloud has published since v0.2.0 exits at startup. .goreleaser.yaml:5-7 builds with CGO_ENABLED=0 while internal/storage/sqlite/store.go imported mattn/go-sqlite3, which is cgo-only. That combination compiles — the driver ships a no-cgo stub — and fails at run time.

Related Issue

Refs #127

Changes

Reproduction, before the fix:

$ CGO_ENABLED=0 go build -o devcloud ./cmd/devcloud   # exit 0
$ ./devcloud -config cfg.yaml
level=ERROR msg="failed to init service" service=s3 error="init s3: enable WAL:
  Binary was compiled with 'CGO_ENABLED=0', go-sqlite3 requires cgo to work. This is a stub"
exit 1

cmd/devcloud/main.go treats a failed default-service init as fatal, so it never gets past s3.

Scope. Broader than it first looks — the versioned container images are affected too, because .goreleaser.yaml:83 builds them from Dockerfile.goreleaser, which copies the GoReleaser binary:

Artifact Built by State
tar.gz / zip binaries (6 targets) GoReleaser, CGO_ENABLED=0 dead on arrival
Homebrew formula (#121) ships those binaries dead on arrival
versioned *-alpine images Dockerfile.goreleaser ← GoReleaser binary dead on arrival
rolling latest image cd.ymldocker/Dockerfile, CGO_ENABLED=1 worked

Present since v0.2.0: git show v0.2.0:.goreleaser.yaml has CGO_ENABLED=0 and the same tag's go.mod has go-sqlite3. Introduced in #26.

The fix. modernc.org/sqlite is a pure Go translation of SQLite. Its RegisterCollationUtf8(zName string, impl func(left, right string) int) error takes exactly the signature of the existing compareNumericText, so the NUMTEXT collation ports unchanged — and because registration is process-wide, the per-connection ConnectHook disappears. The diff is one file, init() going from five lines to one.

The package's public surface does not change, so none of its 108 importers move. IsUniqueConstraintError already matched on message text rather than a driver error type, and the on-disk format is SQLite's, so existing data_dir contents still open.

The gate that was missing. Nothing exercised the released build configuration, which is why this survived two releases and every review round on #127. Everything now builds and tests at CGO_ENABLED=0, so the boto3 suite runs against a binary built exactly as the released one is — those 775 tests are the smoke test.

With cgo gone, six workflows no longer install libsqlite3-dev, two Dockerfiles drop gcc/musl-dev/sqlite-dev, the runtime image drops sqlite-libs, and docs/troubleshooting.md loses a section telling users to install headers for a build error they can no longer hit.

Test Plan

  • The failing case now passes: a CGO_ENABLED=0 binary starts, initialises every service, and answers the admin API with HTTP 200.
  • boto3 suite against that binary: 775 passed. Previously it could not start.
  • CGO_ENABLED=0 go test ./... — 109 packages, exit 0. Same at CGO_ENABLED=1.
  • NUMTEXT preserved: internal/storage/sqlite/store_test.go (38-digit precision ordering) and the DynamoDB suite that consumes it via store.go:627 both pass.
  • All six release targets cross-compile at CGO_ENABLED=0.
  • The linux/amd64 binary is static, with no reference to libsqlite3 — which is why the runtime image no longer needs sqlite-libs.
  • golangci-lint run ./... — 0 issues.

Not verified locally: the container build. No Docker daemon in this environment, and cd.yml does not run on pull requests, so docker/Dockerfile is first exercised on merge.

Cost: the compatibility suite runs about 15% slower (63s against 55s), the expected price of transpiled C.

Checklist

  • Self-reviewed the code
  • Added/updated tests — existing coverage was already sufficient to catch a bad port; the new gate is the build mode, not a new test
  • Lint/format passes (golangci-lint run)
  • Updated documentation (if applicable)
  • Added a Changie changelog fragment — added in a follow-up commit, once this PR has a number to cite

@github-actions github-actions Bot added documentation Improvements or additions to documentation dependencies Dependency updates ci CI/CD workflows and scripts docker Docker and container related labels Aug 9, 2026
@skyoo2003 skyoo2003 self-assigned this Aug 9, 2026
Base automatically changed from chore/release-hardening to main August 9, 2026 12:58
.goreleaser.yaml builds with CGO_ENABLED=0 while the store imported
mattn/go-sqlite3, which is cgo-only. That combination compiles: the driver
ships a stub for the no-cgo build, and the stub fails at run time. Every
tagged binary from v0.2.0 onward therefore exited at startup with

  init s3: enable WAL: Binary was compiled with 'CGO_ENABLED=0',
  go-sqlite3 requires cgo to work. This is a stub

so the tar.gz/zip artifacts and the Homebrew formula were dead on arrival.
Only the container image worked, because docker/Dockerfile builds with
CGO_ENABLED=1 against sqlite-dev.

modernc.org/sqlite is a pure Go translation of SQLite and needs no toolchain
per target. Its RegisterCollationUtf8 takes func(left, right string) int, the
exact signature of compareNumericText, so NUMTEXT ports as-is — and because
registration is process-wide, the per-connection ConnectHook goes away.

The public surface of this package is unchanged, so none of its 108 importers
move. IsUniqueConstraintError already matched on the message text rather than
a driver error type, and the on-disk file format is SQLite's, so existing
data_dir contents still open.

Verified: full suite green under both CGO_ENABLED=1 and CGO_ENABLED=0; the
boto3 suite green (775) against a CGO_ENABLED=0 binary, which previously could
not start; all six release targets cross-compile. The compatibility suite runs
about 15% slower (63s against 55s), the expected cost of transpiled C.
With the driver in pure Go, nothing in the tree needs cgo or SQLite headers.
Six workflows installed libsqlite3-dev, two Dockerfiles installed
gcc/musl-dev/sqlite-dev, the runtime image installed sqlite-libs, and
troubleshooting told users to install headers to fix a build error they can no
longer hit. All of it goes.

The substantive part is CGO_ENABLED: everything now builds and tests at 0, the
mode .goreleaser.yaml publishes in. Testing CGO_ENABLED=1 while shipping
CGO_ENABLED=0 is how a binary that cannot open its own database passed every
gate for two releases. The boto3 suite in particular now runs against a binary
built exactly as the released one is, so those 775 tests are the smoke test for
what users download.

Verified: full suite green at CGO_ENABLED=0 (109 packages); golangci-lint
clean; the linux/amd64 binary is static with no reference to libsqlite3, which
is why the runtime image no longer needs sqlite-libs. The container build
itself is unverified locally — no Docker daemon here — and cd.yml does not run
on pull requests, so it is first exercised on merge.
@skyoo2003
skyoo2003 force-pushed the fix/pure-go-sqlite branch from 27f7a60 to e230fb5 Compare August 9, 2026 13:01

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e230fb5311

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/storage/sqlite/store.go
mattn/go-sqlite3 set PRAGMA busy_timeout to 5000ms on every connection
whether or not the DSN asked for it (sqlite3.go:1201, :1623).
modernc.org/sqlite applies the pragma only when the DSN carries
_busy_timeout or _timeout (sqlite.go:364-365), and Open passed a bare
path, so the swap dropped that 5-second retry window and left SQLite's
default of no waiting at all.

The window matters here. The gateway serves on net/http
(gateway.go:68,97), so handlers run concurrently, and Open sets no
connection limit — database/sql opens as many connections as callers
ask for. Two handlers writing one service database therefore hold two
SQLite connections contending for a single write lock. DynamoDB's
RWMutex does not prevent this: PutItem and DeleteItem take RLock
(store.go:360, :470), which guards the in-memory table map, not the
write transaction beneath it. s3 metadata tags, secretsmanager, sns and
cloudwatchlogs take no lock at all. Nothing in the tree retries on
SQLITE_BUSY, so the loser's error surfaces to the SDK caller as a
spurious "database is locked" — measured at 0.96ms to failure, against
the 5s it used to wait.

The setting travels in the DSN rather than as an Exec beside the
journal_mode pragma because busy_timeout is per-connection state. One
Exec configures whichever pooled connection answers and leaves the rest
at zero: measured at 1.06ms to failure, indistinguishable from no fix.
journal_mode gets away with a single Exec only because the database
file records it.

5000ms restores mattn's prior behaviour exactly. The regression test
holds the write lock for 200ms and asserts the second writer waits it
out; it fails with SQLITE_BUSY in 0.10s against the unfixed Open.

Reported in review on #128.
The go-vet and go-build hooks forced CGO_ENABLED=1. That was never a
response to the cgo-only SQLite driver — both lines date from the
bootstrap commit 7d3b17d, before internal/storage/sqlite existed — but
with the driver now pure Go it is the last place in the tree that asks
a contributor for a C toolchain. Someone without one had every commit
rejected by hooks testing a build configuration the project no longer
produces.

CGO_ENABLED=0 matches .goreleaser.yaml, ci.yml, compat.yml,
smithy-sync.yml and the Makefile, so the hooks now vet and build what
the release actually contains rather than a second, untested mode.

Verified: CGO_ENABLED=0 go vet ./... and go build ./... both exit 0,
and no CGO_ENABLED=1 remains anywhere in the tree.
@github-actions github-actions Bot added the tests Test code and test infrastructure label Aug 9, 2026
@skyoo2003

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 88b3d2c588

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

_ = tx.Rollback()
}()

start := time.Now()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Start timing before launching the unlock goroutine

If the new goroutine is scheduled immediately, its 200 ms sleep begins before start is recorded, so the lock can be released legitimately less than hold after this timestamp and the exact elapsed >= hold assertion fails. This makes TestOpen_SecondWriterWaitsForTheLock scheduling-dependent; record the start before launching the goroutine or allow timing slack while still proving that the second writer waited.

Useful? React with 👍 / 👎.

// mattn/go-sqlite3 applied 5s unconditionally; modernc.org/sqlite installs
// no busy handler unless asked, so without this a second concurrent writer
// fails instantly with SQLITE_BUSY instead of waiting its turn.
db, err := sql.Open(driverName, dbPath+"?_pragma=busy_timeout(5000)")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Use a file URI before appending DSN parameters

When a service has an existing database at dbPath, appending ?_pragma=... to this ordinary filename changes the filename passed to SQLite rather than adding URI parameters, because SQLite only interprets the query portion for names beginning with file:. On Unix this opens a new database literally named like service.db?_pragma=busy_timeout(5000), making persisted service state appear lost after upgrading; on Windows the ? also makes the filename invalid and can prevent startup. Construct an escaped file: URI before adding the pragma, or configure the pragma without altering the filename.

Useful? React with 👍 / 👎.

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

Labels

ci CI/CD workflows and scripts dependencies Dependency updates docker Docker and container related documentation Improvements or additions to documentation tests Test code and test infrastructure

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant