Skip to content

Fix standby crash and replay stall on index load after basebackup - #58

Merged
matt-welch merged 2 commits into
mainfrom
fix-ext54-standby
Oct 5, 2026
Merged

matt-welch merged 2 commits into
mainfrom
fix-ext54-standby

Conversation

@matt-welch

@matt-welch matt-welch commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Description

Since 9f4a9fa (PR #54), a streaming standby built from a basebackup that holds an index's saved copy cannot load that index. On an assert-enabled build its worker aborts on the first load and the standby crash-loops. On a release build the standby stays up, but every search against the index fails, query cancel is ignored by the worker, and standby replay of CREATE DATABASE ... STRATEGY file_copy or DROP DATABASE stops until someone terminates the worker by hand. A standby whose basebackup predates the save reaches the same failure on its first search, independently of PR #54.

An index's saved copy under $PGDATA/vamana_indexes/ is plain files that never replicate, while the metapage's hasSavedIndex flag is WAL-logged. A standby can therefore see the flag set with no directory. LoadIndexFromPages responds to that state by clearing the flag, and clearing it writes WAL, which fails during recovery. Two sequences reach that state on a standby:

  1. Basebackup before the save. The flag arrives later by replay. The directory never does.
  2. Basebackup after the save. A standby creates an index's replication slot only when it loads the index, so at first load the slot has not reached its initial consistent point. PR Fix silent row loss from crashes before slot consistency #54's check correctly discards the saved copy, deletes the directory and retries. The retry then finds the flag set with no directory behind it.

The failed WAL write lands in VamanaFinishAndReleaseBuffer's PG_CATCH. That handler releases the buffer after errfinish() has zeroed InterruptHoldoffCount, so the release decrements a count that is already zero. The worker catches the error and keeps running with the unbalanced count:

  • Assert-enabled builds abort in LWLockRelease.
  • Release builds wrap the count to UINT32_MAX. From then on the worker processes no interrupts at all. Cancel is ignored, and no ProcSignalBarrier is ever absorbed.

This PR fixes both paths in two commits:

  • Hold interrupts in the metapage write error handler. Re-take the hold before releasing the buffer, as LWLockReleaseAll does for each lock. This applies to any error out of GenericXLogFinish, on a primary or a standby.
  • Do not write WAL when a standby lacks a saved copy. Clear the stale flag only on a server that persists indexes, which is the primary. Otherwise fall through to the existing heap rebuild for a missing saved copy. Primary behaviour is unchanged.

PR #54's consistency check stays in force on standbys. It is load-bearing there too: the saved copy lacks every row committed after it was written, and the standby's slot starts after those rows, so draining it can never deliver them. With the check skipped on a standby, the standby's index permanently missed 40 of 60 rows committed after the save. With the check in place, it returned all 60. The check's comment now says so.

Related Issues

N/A

Type of Change

  • Bug fix
  • New feature
  • Refactor / code cleanup
  • Documentation update
  • Test addition or update
  • Build / CI change

Pre-Merge Checklist

Build

  • make completes without errors or warnings
  • make install completes successfully

Tests

  • If this PR introduces no new behavior: existing regression tests (make installcheck) and TAP tests (test/t/) pass with no failures
  • If this PR introduces new behavior: test cases covering it were added to test/sql/ and/or test/t/
  • If this PR adds a standalone unit-test module under test/modules/: it builds and passes (make -C test/modules/<module> installcheck)

Documentation

  • Relevant docs under docs/ updated if architecture or usage changed

Testing Notes

  • 04_bgw_robustness.pl gains a block driven by a new injection point inside the handler's PG_TRY. It checks that the worker survives a failed metapage write, that DROP DATABASE (which waits on a ProcSignalBarrier) completes, and that the index serves searches afterwards. Without the first commit, it trips the assertion on an assert-enabled build and leaves DROP DATABASE waiting on the worker on a release build.

  • 62_standby_saved_copy.pl covers the basebackup-after-save sequence, and 63_standby_backup_before_save.pl covers basebackup-before-save. Each checks that:

    • the standby starts;
    • its index returns rows committed after the save and after the standby started;
    • the worker never restarts;
    • the log shows no crash and no attempted WAL write;
    • replay of CREATE DATABASE ... STRATEGY file_copy is not held up;
    • the worker honours pg_terminate_backend and its replacement serves the full index.

    Without these changes, both files fail 10 of 12 checks on an assert-enabled build and 5 of 12 on a release build. With the first commit alone, both fail 4 of 12 on either build.

  • 12_launcher.pl and 16_warmup.pl already reproduced the crash on assert-enabled builds and pass with this PR.

Full TAP suite results:

  • Release PostgreSQL 18.1: all 49 files, 1168 tests pass. 18_build_offset_overflow.pl is skipped behind its slow-tests gate, by design.
  • Assert-enabled PostgreSQL 18.1: 46 files pass and 18 is skipped. 08_standby_replay.pl and 09_standby_replay_faults.pl fail on a separate, pre-existing standby assertion, Assert(IsTransactionState()) in VamanaFreeCacheEntryResources. They fa
    il identically with this PR reverted.

This run also needs a separate fix to the C initializer of svs.max_build_memory. That fix was submitted on its own and was applied locally. Without it, no assert-enabled server starts with svs preloaded.

VamanaFinishAndReleaseBuffer releases the metapage buffer from a
PG_CATCH when GenericXLogFinish throws. By then errfinish() has reset
InterruptHoldoffCount to zero, including the hold taken when the buffer's
content lock was acquired, so the release's RESUME_INTERRUPTS() has
nothing left to consume.

The background worker catches these errors and keeps running, so the
imbalance outlives the error:

- On an assert-enabled build, the Assert(InterruptHoldoffCount > 0) in
  LWLockRelease aborts the worker and the postmaster reinitializes the
  server.
- On a release build the count wraps to UINT32_MAX and the worker never
  processes an interrupt again. Query cancel is ignored and
  ProcSignalBarriers are never absorbed, so DROP DATABASE on a primary,
  and replay of CREATE DATABASE ... STRATEGY file_copy or DROP DATABASE
  on a standby, wait for the worker forever.

Take the hold again before the release, as LWLockReleaseAll does for
each lock it releases.

Add an injection point inside the handler's PG_TRY, and a test in
04_bgw_robustness.pl that fires it on a metapage write and checks that
the worker survives, that DROP DATABASE still completes and that the
index serves searches once the failure is cleared. Without the fix the
test trips the assertion on an assert-enabled build and leaves
DROP DATABASE waiting on the worker on a release build.

Signed-off-by: Matt Welch <matt.welch@intel.com>
An index's saved copy under vamana_indexes/ is written with plain file
I/O and never replicates, but the metapage's hasSavedIndex flag is
WAL-logged, so a standby can see the flag set with no directory behind
it. LoadIndexFromPages answers that state by clearing the flag, which
writes WAL. During recovery that fails with "cannot make new WAL
entries during recovery", the load fails, and every search against
the index on that standby fails with it.

Two ordinary sequences lead there:

- The standby's basebackup predates the save. The flag arrives later
  by replay and the directory never does.
- The basebackup includes the saved copy. A standby creates an
  index's replication slot only when it loads the index, so at the
  first load that slot has not reached its initial consistent point.
  The check added in 9f4a9fa (PR #54) then correctly discards the
  copy, deletes its directory and retries, and the retry finds the
  flag set with the directory gone. Since that commit this happens
  on the first boot of every standby built from a backup that holds
  a saved copy. Through the error handler fixed in the previous
  commit it crash-looped the standby on assert-enabled builds and
  left the worker unable to absorb barriers on release builds.

Clear the flag only on a server that persists indexes, which is the
primary, and otherwise fall through to the heap rebuild that already
handles a missing saved copy. The flag is the primary's to manage.

The consistency check from 9f4a9fa stays in force on a standby. The
saved copy lacks every row committed after it was written, and the
standby's slot only starts after those rows, so draining it can never
deliver them. Skipping the check there leaves them permanently missing
from the standby's index; the heap rebuild is what keeps it complete.
State that in the check's comment.

Add 62_standby_saved_copy.pl and 63_standby_backup_before_save.pl, one
per sequence. Each checks that the standby starts and its index answers
queries, including rows committed after the save and after the standby
started; that the worker never restarts and the log shows no crash and
no attempted WAL write; that replay of CREATE DATABASE ... STRATEGY
file_copy is not held up; and that the worker honours
pg_terminate_backend and comes back serving. Without this change both
fail on assert-enabled and release builds.

Signed-off-by: Matt Welch <matt.welch@intel.com>
@matt-welch
matt-welch requested a review from a team October 1, 2026 20:10

@asonje asonje left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Overall: solid, well-targeted fix. No logic issues found.

One observation about the larger issue, out of scope of this PR:

hasSavedIndex is node-local state stored in replicated storage. It describes a
directory on this node's filesystem, but it lives in a WAL-logged relation
page, so it travels independently of the thing it describes and can only be
corrected by writing WAL. That combination is the bug — the standby inherits an
assertion about local disk that isn't true locally, and the one mechanism
available to fix it is the one mechanism a standby doesn't have.

This PR closes the path that happened to reach it. The shape is still there:
nothing stops the next VamanaSetHasSavedIndex or VamanaWriteMetaPageDynamic
caller from landing in the same place, and each will need its own guard.

Two things worth thinking about before that happens:

  • vamana_indexes/ is a cache of something fully reconstructible from the heap,
    and it's in the basebackup only because it sits under $PGDATA. A standby
    discards it anyway via the #54 slot check. Core's convention here is explicit
    (pg_notify, pg_internal.init, pg_stat_tmp and friends are excluded): a
    local cache shouldn't ship, the receiving node should rebuild. Tests 62 and 63
    already demonstrate both standbys reach the same state.

  • The flag can't simply be dropped in favour of stat() — it's written
    atomically with nextExternalId and the other counters, so it's really the
    validity bit for those, and the crash window at vamanaio.c:553-559 currently
    depends on it. But that's an argument for moving the validity bit next to the
    files it describes, not for keeping it in a replicated page.

@matt-welch
matt-welch merged commit aec0b61 into main Oct 5, 2026
5 checks passed
@matt-welch
matt-welch deleted the fix-ext54-standby branch October 5, 2026 16:03
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