Skip to content

Skip wpgx cache invalidation when DML affects zero rows#15

Open
jaxxjj wants to merge 1 commit into
Stumble:mainfrom
jaxxjj:skip-invalidation-on-noop-dml
Open

Skip wpgx cache invalidation when DML affects zero rows#15
jaxxjj wants to merge 1 commit into
Stumble:mainfrom
jaxxjj:skip-invalidation-on-noop-dml

Conversation

@jaxxjj

@jaxxjj jaxxjj commented Jun 11, 2026

Copy link
Copy Markdown

Problem

The wpgx :exec / :execrows / :execresult invalidation hooks run unconditionally. An UPDATE guarded by a WHERE clause that matches nothing (a common idempotent-write pattern, e.g. SET x = $1 WHERE id = $2 AND x <> $1) still evicts every cache key in its invalidate list.

When such a no-op write sits on a hot path, it repeatedly evicts entries that back unrelated hot reads — every cached lookup behind those keys goes back to the database even though nothing changed. We hit this in production: a per-inbound-message bookkeeping UPDATE kept evicting the cached binding lookup that the same message pipeline reads, making that 60s cache permanently cold.

Fix

Gate the PostExec invalidation hook on the command tag the generated code already receives from WExec (and currently discards in the :exec arm):

// Zero-row DML changed nothing: cached entries are still valid, skip
// invalidation. Non-row commands (e.g. TRUNCATE) report zero rows but
// do mutate state, so they always invalidate.
if cmd.RowsAffected() == 0 && (cmd.Insert() || cmd.Update() || cmd.Delete()) {
    return nil
}
  • The DML-type check keeps zero-row-tag commands that do mutate state (TRUNCATE, DDL) invalidating as before.
  • Methods are called on the pgconn.CommandTag value directly, so no new imports are needed in files without :execresult.
  • No generated signatures change; queries without an invalidate annotation generate byte-identical code.

Validation

  • go test ./internal/codegen/... passes.
  • Regenerated a production codebase (≈25 query files carrying exec-family invalidate annotations): only those files change, output is gofmt-clean, the codebase compiles, and its integration suites (real Postgres + Redis via testcontainers, exercising dcache invalidation) pass.

Known semantic edge

A statement-level trigger that mutates other tables can fire even when the triggering statement matches zero rows; such side effects would no longer invalidate. Row-level triggers are unaffected (they don't fire on zero rows). This felt like the right trade-off for a caching layer keyed to the queried tables, but happy to put the gate behind an opt-in option if you'd rather keep the old default.

The exec/execrows/execresult invalidation hooks run unconditionally:
an UPDATE guarded by a WHERE that matches nothing still evicts every
listed cache key. On hot write paths this evicts entries that back
unrelated hot reads, forcing them to the database for no benefit.

Gate the PostExec hook on the command tag: skip when RowsAffected()
is zero and the statement is row-level DML. The DML-type check keeps
zero-row-tag commands that do mutate state (e.g. TRUNCATE)
invalidating. Queries without an invalidate annotation generate
byte-identical code, and no signatures change.
@Stumble

Stumble commented Jun 11, 2026

Copy link
Copy Markdown
Owner
WITH audit AS (
    INSERT INTO audit_log (...) VALUES (...)
  )
  UPDATE users
  SET name = $1
  WHERE id = $2 AND name <> $1;

bad case ^

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants