go-sqlite-db-split-workspace-lookup
Root cause. The service keeps two separate sqlite files — cron.db (owns cron_jobs) and workspaces.db (owns workspaces). serve.go opens both, but CronHandler was constructed with only the cron handle and queried SELECT … FROM workspaces on it. SQLite namespaces tables per file, so the cron handle reports SQL logic error: no such table: workspaces (1) → HTTP 500.
Fix — three parts:
1. Handler gains a workspaceDB field (cron.go):
type CronHandler struct {
db *sql.DB // cron.db — owns cron_jobs
workspaceDB *sql.DB // workspaces.db — shared, opened earlier in serve.go
}
func NewCronHandler(db, workspaceDB *sql.DB) *CronHandler {
return &CronHandler{db: db, workspaceDB: workspaceDB}
}
2. New resolveWorkspaceID() helper queries the shared DB, never the cron handle, and maps sql.ErrNoRows to a sentinel:
var errWorkspaceNotFound = errors.New("workspace not found")
func (h *CronHandler) resolveWorkspaceID(ctx context.Context, slug string) (int64, error) {
var id int64
err := h.workspaceDB.QueryRowContext(ctx,
`SELECT id FROM workspaces WHERE slug = ?`, slug).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return 0, errWorkspaceNotFound
}
return id, err
}
RunDueJobs now calls it: wsID, err := h.resolveWorkspaceID(ctx, j.WorkspaceSlug) and returns 500 with a descriptive message only on real errors.
3. Wiring in serve.go — the shared workspaces handle is opened earlier and passed through (works in main() and tests alike):
func serve(cronDBPath, workspacesDBPath string) *CronHandler {
cronDB := mustOpenDB(cronDBPath)
workspacesDB := mustOpenDB(workspacesDBPath) // opened earlier, shared by all handlers
return NewCronHandler(cronDB, workspacesDB)
}
Regression test (cron_test.go) — two real temp sqlite files via t.TempDir(): seeds cron_jobs into cron.db, workspaces into workspaces.db, then asserts (a) resolveWorkspaceID("acme") == 42, (b) unknown slug → errWorkspaceNotFound sentinel, (c) end-to-end RunDueJobs over httptest returns 200 with ran job 1 workspace=42, and (d) the setup itself is valid by confirming workspaces is genuinely absent from the cron handle.
Verified in a standalone module (`/tmp/dbsplit`, Go 1.26, `modernc.org/sqlite` pure-Go driver): 1. **Bug reproduced first:** with `resolveWorkspaceID` reverted to query `h.db`, the regression test failed with the exact production error: ``` cron_test.go:71: resolveWorkspaceID(acme) = SQL logic error: no such table: workspaces (1) --- FAIL: TestCronHandler_SplitDBWorkspaceLookup ``` 2. **Fix applied:** test passes; `gofmt -l` clean, `go vet` clean. 3. **Full suite + race detector:** `go test -race -count=1 ./...` → `ok dbsplit 1.030s` — **3 tests, 3 passed**. 4. **Edge cases tested:** - Unknown slug returns the `errWorkspaceNotFound` sentinel (checked via `errors.Is`), not a raw sqlite error. - Orphan due job (workspace deleted) → HTTP 500 with a clear "workspace not found" message, no panic. - No due jobs → 200 with empty body. - `TestServe_Wiring` proves `serve()` threads both non-nil handles into the handler. - Split integrity cross-check: querying `workspaces` from the cron handle still errors — confirming the test would catch any regression back to single-handle behavior. ---
{"model": "deepseek-v4-flash", "problem_class": "go-sqlite-db-split-workspace-lookup", "result": "passed", "tests": 3}