Skip to content

fix(storage): read GSI propagation delay live so runtime changes apply - #245

Merged
amrith merged 4 commits into
mainfrom
fix/gsi-zero-delay-synchronous-visibility
Aug 10, 2026
Merged

fix(storage): read GSI propagation delay live so runtime changes apply#245
amrith merged 4 commits into
mainfrom
fix/gsi-zero-delay-synchronous-visibility

Conversation

@LeeroyHannigan

@LeeroyHannigan LeeroyHannigan commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

What

Makes both storage backends honour a runtime change of the GSI propagation delay
immediately. The delay getter now reads gsi_propagation_delay_ms from the catalog
on each write instead of serving a value cached with a 30-second refresh, in
crates/storage-sqlite/src/store.rs and crates/storage-postgres/src/lib.rs, with
the four write paths in each backend updated to await it.

PostgreSQL callers keep their indexes.is_empty() short-circuit, so a table with no
secondary indexes performs no extra query at all.

Three further changes, from reviewing the SQLite half:

  • The delay is read before write_lock.lock().await at all four SQLite call
    sites. It is a runtime setting rather than an invariant of the write, so it does
    not need to be read under the lock, and that lock serialises every write in the
    process, so work inside it is the backend's throughput bottleneck.
  • The error arm logs instead of swallowing. Both poll workers already log through
    tracing::debug!; now a live read that falls back to the cache does too, so a
    degraded catalog serves a stale delay loudly rather than silently.
  • The default is a named constant, DEFAULT_GSI_PROPAGATION_DELAY_MS, per backend
    crate, shared by the live read and the poll worker. The literal was previously
    duplicated and the live read had added a third copy.

Why

Setting gsi_propagation_delay_ms to 0 at runtime is supposed to make index updates
synchronously visible. With the cached value, writes kept using the stale non-zero
delay for up to 30 seconds, so the index update stayed async and queries against the
GSI returned 0 items immediately after a write. This is one of the red CI jobs on
#207, where the zero-delay GSI sync test sets the delay to 0 and immediately expects
synchronous visibility.

PostgreSQL had the identical defect. Both backends are fixed here rather than in two
PRs, following #248 where the same shared defect was corrected in both backends in
one change.

Closes #

Testing done

Every claim below was executed, on a fresh deployment per backend and on ports away
from any other local instance.

  • SQLite: test_gsi_sync_path_with_zero_delay passes 20/20 consecutive runs.
    Full TestGsiAsyncPropagation class 5/5.
  • PostgreSQL: the same test failed twice before this change with the same
    symptom as SQLite, assert 0 == 1, and now passes 20/20 consecutive runs.
  • Negative control: reverting only the SQLite getter body to the cached read,
    keeping the async signature so it still compiles, reproduces the failure exactly.
    So the test discriminates and the fix is what makes it pass.
  • Crate unit tests: 16/16 SQLite, 14/14 PostgreSQL, 0 filtered out.
  • cargo fmt --all -- --check exit 0. cargo clippy -D warnings exit 0 on both the
    sqlite and postgres feature sets.

One pre-existing failure found and deliberately not touched.
test_gsi_configured_delay_range fails on PostgreSQL, observing about 1010 ms against
a 500 ms bound with the delay set to 50 ms. An A/B on a fresh server shows it failing
identically with and without this change, so it is not a regression here. It looks
like worker scheduling granularity dominating the configured delay, which deserves its
own investigation.

Worth knowing about CI. run-integration (PostgreSQL) was green on the previous
revision of this PR while PostgreSQL was still broken, so CI did not catch this class
of defect. Whether that is because the harness restarts the server, letting PostgreSQL
pick the setting up at construction, is unconfirmed.

Checklist

  • I have read CONTRIBUTING.md
  • All tests pass (cargo test --workspace)
  • Code is formatted (cargo fmt --check)
  • Clippy is clean (cargo clippy -- -D warnings on both feature sets; the repo
    carries pre-existing -W clippy::pedantic warnings and this change adds none)
  • I have added or updated tests for new functionality — no new test: the existing
    zero-delay test in the Python suite already covers the behaviour on both
    backends, and it is what the fix makes pass
  • I have updated documentation if behavior changed — no doc change; the setting's
    documented meaning is unchanged, it is now actually honoured
  • Breaking changes are noted below (if any)
  • If this changes the wire protocol, Storage trait, auth model, on-disk
    format, or public CLI surface, an RFC has been accepted or is linked
    below. Otherwise, an ADR captures the decision (link below).

ADR / RFC: n/a. Internal bug fix, no contract surface changed.

Breaking changes

None.


By submitting this pull request, I confirm that my contribution is made under
the terms of the Apache License 2.0 and I agree to the Developer Certificate of
Origin (DCO). See CONTRIBUTING.md for details.

… immediately

The write path decided sync-vs-async GSI propagation from an AtomicU64
cache of the gsi_propagation_delay_ms setting, refreshed by a background
worker only every 30 seconds. An out-of-process change via
'extenddb settings set gsi_propagation_delay_ms 0' was therefore
invisible for up to 30 seconds: writes kept enqueueing async index
updates, and an immediate GSI query returned nothing. This is the exact
failure in test_gsi_sync_path_with_zero_delay, which sets the value to 0
and asserts the very next write is synchronously visible.

Fix: gsi_default_delay() now reads the settings row from the catalog on
each write. SQLite is a local file, so this is an indexed point lookup
with negligible cost next to the write it precedes. On a read error the
cached value (still refreshed by the poll worker) is the fallback; on a
successful read the cache is re-warmed so fallback values stay fresh.
A missing row means the default 10ms, matching poll_gsi_delay.

Verification: test_gsi_sync_path_with_zero_delay fails on the previous
binary (negative control) and passes 20/20 consecutive runs with this
change. Full pytest phase 1: 903 passed; the only failures are the
known console-test CERTIFICATE_VERIFY_FAILED self-signed-cert artifact
of the local runner, identical counts on unmodified main.
The prior commit was formatted with a standalone rustfmt invocation using
--edition 2021; the workspace is edition 2024, whose import ordering
differs. Imports-only change, verified with cargo fmt --all -- --check
on toolchain 1.97 (matching CI).
@LeeroyHannigan LeeroyHannigan mentioned this pull request Aug 7, 2026
3 tasks
The previous commit fixed SQLite only. PostgreSQL had the identical defect and
this completes the fix, following the precedent of #248 where the same shared
defect was corrected in both backends in one change.

Proven rather than assumed. Before this commit, a fresh PostgreSQL deployment
failed test_gsi_sync_path_with_zero_delay with the same symptom as SQLite did,
"assert 0 == 1", reproduced twice: all four write paths resolved the delay from
the cached atomic (put_item, update_item, delete_item, transactions), which is
seeded once at construction and refreshed only by a 30-second poll worker, so a
runtime change to 0 was ignored for up to 30 seconds. After this commit the same
test passes 20 consecutive runs. The synchronous path itself already existed in
the backend, so only the stale read had to be corrected.

Callers keep their `indexes.is_empty()` short-circuit, so a table with no
secondary indexes performs no extra query at all.

Three improvements from reviewing the SQLite half:

  * The delay is now read BEFORE `write_lock.lock().await` at all four SQLite
    call sites. It is a runtime setting rather than an invariant of the write, so
    it does not need to be read under the lock, and that lock serialises every
    write in the process: work inside it is the backend's throughput bottleneck.
    The value was already up to 30 seconds stale before this fix, so nothing
    depended on reading it there.

  * The error arm no longer swallows the failure. Both poll workers log through
    `tracing::debug!`, so a live read that falls back to the cache now logs too;
    a degraded catalog serves a stale delay loudly rather than silently.

  * The default is a named constant, `DEFAULT_GSI_PROPAGATION_DELAY_MS`, in each
    backend crate, used by both the live read and the poll worker. The literal
    was previously repeated, and the live read added a third copy.

One pre-existing failure was found and deliberately left alone:
test_gsi_configured_delay_range fails on PostgreSQL, observing about 1010 ms
against a 500 ms bound with the delay set to 50 ms. An A/B on a fresh server
shows it failing identically with and without this change, so it is not a
regression here. It looks like worker scheduling granularity dominating the
configured delay, which is a separate investigation.

Verification: SQLite and PostgreSQL each pass the zero-delay test 20/20
consecutive; the full TestGsiAsyncPropagation class is 5/5 on SQLite; 16/16 and
14/14 crate unit tests with 0 filtered out; `cargo fmt --all -- --check` exit 0;
`cargo clippy -D warnings` exit 0 on both feature sets.
@LeeroyHannigan LeeroyHannigan changed the title fix(sqlite): read GSI propagation delay live so runtime changes apply fix(storage): read GSI propagation delay live so runtime changes apply Aug 10, 2026
…propagation honours the configured delay

Every asynchronous GSI propagation on PostgreSQL took ~1 second regardless of
the configured delay. With gsi_propagation_delay_ms=50, observed propagation
was consistently ~1010 ms. This was invisible before the live delay-read fix
in the previous commit, because the configured value was never actually
exercised; making the setting work exposed it.

Root cause, found by instrumented timeline rather than code reading. Since
PostgreSQL 14, EXTRACT(EPOCH FROM ...) returns `numeric`, and sqlx refuses to
decode `numeric` into f64. next_ready_wait's query therefore FAILED on every
partition that had a pending row, and `.ok().flatten()` collapsed that error
into `None`, indistinguishable from "no rows pending". The worker then slept
its full 1 s idle backstop instead of sleeping until the row's `ready_at`.

The sequence, from a single-run correlated timeline: a write commits and
notifies; all four workers wake within the same millisecond; the row's
jittered `ready_at` is ~25-50 ms in the future, so the claim query correctly
finds nothing ready; next_ready_wait should then return ~40 ms but instead
errors and reports None; the worker parks for the full backstop; the row is
claimed ~1 s later.

This exonerates the notification design entirely. Three earlier hypotheses
(shared notify_waiters losing wakes, notify_one permit routing, registering
the waiter before the readiness check) were each implemented and measured at
~1 s, because the wake was never the problem.

The fix is the ::float8 cast. The error is also now logged through
tracing::error instead of being swallowed, so a decode or connection failure
in this query can never silently degrade propagation latency again; the
negative-control run logged it five times, once per write.

Verification: test_gsi_configured_delay_range passes 20/20 consecutive runs
(previously 0/anything, ~1010 ms vs the 500 ms bound); negative control
removing only the cast reproduces the exact original failure; zero-delay test
still passes; full TestGsiAsyncPropagation class green; fmt exit 0; clippy
-D warnings exit 0.

@amrith amrith left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

approved

@amrith
amrith added this pull request to the merge queue Aug 10, 2026
Merged via the queue into main with commit 0411845 Aug 10, 2026
14 checks passed
diegotoledano95 added a commit to diegotoledano95/extenddb that referenced this pull request Aug 10, 2026
 Pull in ExtendDB#239 (GSI pagination tiebreaker), ExtendDB#245 (SQLite stale GSI delay),
 and ExtendDB#246 (Postgres restore race) — the three pre-existing backend bugs the
 dual-target suite surfaced. Resolve the add/add conflict on
 restore_active_completeness.rs in favor of this branch version, which
 carries the reviewer-requested retry-on-scan-error oracle fix and a bounded
 observer; main branch Postgres restore fix lands via its own source change.
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