Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,25 @@ ORDER BY
LIMIT @first;
```

This wicked forked sqlc adds 3 abilities to query: cache, timeout and invalidate.
`timeout` bounds the overall operation, including cache lookup. Cached `:one`
and `:many` queries can additionally set `sql_timeout` to start a separate SQL
budget inside the database callback:

```sql
-- name: GetBook :one
-- -- timeout: 1s
-- -- cache: 10m
-- -- sql_timeout: 250ms
SELECT * FROM books WHERE id = @id;
```

A 350ms cache miss leaves up to 250ms for SQL, rather than consuming its entire
budget. SQL still obeys any earlier operation or caller deadline. Without
`sql_timeout`, generated timeout behavior is unchanged. The option requires
`cache` and `timeout`, and cannot exceed the overall timeout. A nil cache still
uses the SQL budget. Redis transport deadlines remain the client's responsibility.

This wicked forked sqlc adds caching, timeouts and invalidation to queries.

Options are written as `-- -- key: value` comments above each query. sqlc passes
them through its standard query comments, and the wicked backend parses and
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ SELECT * FROM books WHERE id = @id;
Run `sqlc generate` to generate code, or `sqlc diff` to check that it is up to date.
Existing wicked projects keep the same configuration and commands.

For cached reads, optional `-- -- sql_timeout: 250ms` starts a separate SQL budget
after cache lookup while retaining `timeout` as the overall bound. See the guide
for the combined budget example; existing queries keep their current behavior.

See the [guide](GUIDE.md) for schema conventions, query options, and runtime setup,
or [bookstore](https://github.com/Stumble/bookstore) for a working example.
[Upstream documentation](https://docs.sqlc.dev) covers standard sqlc features.
Expand Down
71 changes: 71 additions & 0 deletions docs/changelogs/2026-10-23-cache-sql-timeout-budget.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Separate cached reads from SQL execution budgets

## 1. Problem

Cached wpgx reads start `timeout` before DCache. A slow cache miss can exhaust
that context before SQL starts. A local delayed-cache experiment reproduces
this independently of a slow database.

## 2. Behavior

- Preserve `timeout` as the overall operation budget for existing queries.
- Add optional `sql_timeout` for cached `:one` and `:many` queries. Start it inside
the database callback; it remains bounded by the operation/caller deadline.
- Preserve cache keys, TTLs, invalidation, return values and public method signatures.

## 3. Decisions

Use an opt-in annotation, not a global change to documented timeout semantics.
Consumers can opt in with a 1s operation budget and a 250ms SQL budget. This does not change Redis configuration or singleflight error handling.

## 4. Implementation

Parse/validate the annotation, render its context inside both cached read
callbacks, document it, and exercise generated output. Consumer adoption remains explicit and separate from this generator change.

Normalize ANTLR 4.13.1 exit workarounds during SQLite generation: move the
dummy goto before its label instead of leaving it after return. Keep the
grammar, ANTLR version and parser tables unchanged. This fixes 115 existing
full-vet findings; regeneration retains the fix.

## 5. Verification

Run option tests, generator golden/compatibility tests, pinned Go 1.26.8 build,
formatting and CI-equivalent tests/vet. A consuming service additionally verifies slow cache misses, SQL deadline
enforcement and parent cancellation against its runtime dependency versions. No production access is needed for those tests.

## 6. Authorization and checklist

User authorized isolating cache/SQL budgets and creating PRs on 2026-10-23.
No merge or release is included.

- [x] Regression tests fail before implementation.
- [x] Implement opt-in annotation and regenerate intended fixtures.
- [x] Verify legacy generated behavior and runtime consumer.
- [ ] Review, run publication checks and create linked PRs.

## 7. Results

- Parser regression failed before implementation, then passed.
- Go 1.26.8: `make build CGO_ENABLED=0 COMMIT_HASH=v2.4.0-dev` and
`CGO_ENABLED=0 go build ./...` passed; all nested generated fixtures built.
- `go run ./scripts/test-local -- go test -count=1 -tags=examples -timeout=20m ./...`
passed across 34 test-bearing packages with command-scoped external proxies
and localhost exclusions. An earlier direct-network attempt stalled fetching
a WASM plugin; its diagnostic stack identified the external HTTP read.
- `make fmt` completed; unrelated existing formatting changes in three parser
adapters were restored. Changed Go files are formatted with Go 1.26.8.
- Full `CGO_ENABLED=0 go vet ./...` passed after fixing the 115 baseline
unreachable-code findings through the ANTLR generation pipeline. The
normalization regression runs both exit paths, checks idempotence and
validates the result with `go run` and `go vet`. The generated parser diff
contains exactly 115 goto moves and no other changes.
- Pinned bookstore commit `2a7f7e396451821a8fe3054eb265037b830af68d`:
`make sqlc`, `make sqlc-verify`, and unchanged generated diff passed;
`go test -race -count=1 -p 1 -timeout=10m ./pkg/usecases` passed (6.842s).
- No new dependency, schema, cache-key or existing-method signature changes.

## 8. Remaining work

Consumer adoption, merges and deployment remain separate. The user requested
fixing the existing full-vet baseline rather than accepting an exception.
3 changes: 3 additions & 0 deletions internal/codegen/wicked/gen.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,9 @@ func Generate(ctx context.Context, req *plugin.GenerateRequest) (*plugin.Generat
}
var missing []error
for _, q := range queries {
if q.Option.SQLTimeout > 0 && q.Cmd != metadata.CmdOne && q.Cmd != metadata.CmdMany {
return nil, fmt.Errorf("%s/%s: sql_timeout requires a cached :one or :many query", options.Package, q.MethodName)
}
if q.Option.Timeout == 0 {
missing = append(missing, fmt.Errorf("%s/%s does not have a timeout option", options.Package, q.MethodName))
}
Expand Down
18 changes: 18 additions & 0 deletions internal/codegen/wicked/option.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const (
WPgxOptionKeyInvalidate = "invalidate"
WpgxOptionKeyCountIntent = "count_intent"
WpgxOptionKeyTimeout = "timeout"
WpgxOptionKeySQLTimeout = "sql_timeout"
WpgxOptionKeyAllowReplica = "allow_replica"
)

Expand All @@ -19,6 +20,7 @@ type WPgxOption struct {
Invalidates []string
CountIntent bool
Timeout time.Duration
SQLTimeout time.Duration
AllowReplica bool
}

Expand Down Expand Up @@ -67,9 +69,25 @@ func parseOption(options map[string]string, queryNames map[string]bool) (rv WPgx
} else {
return rv, fmt.Errorf("Unknown allow_replica value: %s", v)
}
case WpgxOptionKeySQLTimeout:
rv.SQLTimeout, err = time.ParseDuration(v)
if err != nil {
return
}
if rv.SQLTimeout < time.Millisecond {
return rv, fmt.Errorf("sql_timeout duration too short: %s", v)
}
default:
return rv, fmt.Errorf("Unknown option: %s", k)
}
}
if rv.SQLTimeout > 0 {
if rv.Cache == 0 || rv.Timeout == 0 {
return rv, fmt.Errorf("sql_timeout requires cache and timeout options")
}
if rv.SQLTimeout > rv.Timeout {
return rv, fmt.Errorf("sql_timeout cannot exceed timeout")
}
}
return
}
22 changes: 22 additions & 0 deletions internal/codegen/wicked/options_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,28 @@ func TestQueryOptions(t *testing.T) {
}
}

func TestSQLTimeoutOption(t *testing.T) {
opts, err := parseQueryOptions([]string{" -- timeout: 1s", " -- cache: 1m", " -- sql_timeout: 250ms"}, true, nil)
if err != nil {
t.Fatal(err)
}
if opts.Timeout != time.Second || opts.SQLTimeout != 250*time.Millisecond {
t.Fatalf("budgets = %+v", opts)
}
for _, comments := range [][]string{
{" -- timeout: 1s", " -- cache: 1m", " -- sql_timeout: invalid"},
{" -- timeout: 1s", " -- cache: 1m", " -- sql_timeout: 0"},
{" -- timeout: 1s", " -- cache: 1m", " -- sql_timeout: 1us"},
{" -- timeout: 1s", " -- sql_timeout: 250ms"},
{" -- cache: 1m", " -- sql_timeout: 250ms"},
{" -- timeout: 1s", " -- cache: 1m", " -- sql_timeout: 2s"},
} {
if _, err := parseQueryOptions(comments, true, nil); err == nil {
t.Errorf("accepted invalid SQL budget: %v", comments)
}
}
}

func TestInvalidQueryOptions(t *testing.T) {
for _, tc := range []struct {
comment string
Expand Down
8 changes: 8 additions & 0 deletions internal/codegen/wicked/templates/wpgx/queryCode.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ func _{{.MethodName}}(ctx context.Context, q {{.ConnType}}, {{.Arg.Pair}} {{.Inv
}
{{else}}
dbRead := func() (any, time.Duration, error) {
{{- if gt .Option.SQLTimeout.Milliseconds 0 }}
qctx, cancel := context.WithTimeout(qctx, time.Millisecond * {{.Option.SQLTimeout.Milliseconds}})
defer cancel()
{{- end }}
cacheDuration := time.Duration(time.Millisecond * {{.Option.Cache.Milliseconds}})
row := q.GetConn().WQueryRow(qctx, "{{.UniqueLabel}}", {{.ConstantName}}, {{.Arg.Params}})
var {{.Ret.Name}} *{{.Ret.Type}} = new({{.Ret.Type}})
Expand Down Expand Up @@ -163,6 +167,10 @@ func _{{.MethodName}}(ctx context.Context, q {{.ConnType}}, {{.Arg.Pair}} {{.Inv
}
{{else}}
dbRead := func() (any, time.Duration, error) {
{{- if gt .Option.SQLTimeout.Milliseconds 0 }}
qctx, cancel := context.WithTimeout(qctx, time.Millisecond * {{.Option.SQLTimeout.Milliseconds}})
defer cancel()
{{- end }}
cacheDuration := time.Duration(time.Millisecond * {{.Option.Cache.Milliseconds}})
rows, err := q.GetConn().WQuery(qctx, "{{.UniqueLabel}}", {{.ConstantName}}, {{.Arg.Params}})
if err != nil {
Expand Down
63 changes: 63 additions & 0 deletions internal/endtoend/testdata/wicked_compat/go/query.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions internal/endtoend/testdata/wicked_compat/query.sql
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
-- name: GetRecord :one
-- -- timeout: 1s
-- -- cache: 1m
-- -- sql_timeout: 250ms
SELECT * FROM records WHERE id = @id;

-- name: ListRecords :many
-- -- timeout: 1s
-- -- cache: 1m
-- -- sql_timeout: 250ms
SELECT * FROM records ORDER BY id;

-- name: CreateRecord :exec
-- -- timeout: 1s
-- -- invalidate: [GetRecord]
Expand Down
5 changes: 4 additions & 1 deletion internal/engine/sqlite/parser/Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
sqlite_parser.go: SQLiteLexer.g4 SQLiteParser.g4 antlr-4.13.1-complete.jar
GO ?= go

sqlite_parser.go: SQLiteLexer.g4 SQLiteParser.g4 antlr-4.13.1-complete.jar Makefile ../../../../scripts/fix-antlr-exits/main.go
java -jar antlr-4.13.1-complete.jar -Dlanguage=Go SQLiteLexer.g4 SQLiteParser.g4
$(GO) run ../../../../scripts/fix-antlr-exits $@

antlr-4.13.1-complete.jar:
curl -O https://www.antlr.org/download/antlr-4.13.1-complete.jar
Expand Down
Loading