Skip to content

test(oracle): graceful/brutal/rolling restart strategies for postgres oracle tests#4677

Open
robacourt wants to merge 5 commits into
mainfrom
rob/oracle-rolling-deploy-tests
Open

test(oracle): graceful/brutal/rolling restart strategies for postgres oracle tests#4677
robacourt wants to merge 5 commits into
mainfrom
rob/oracle-rolling-deploy-tests

Conversation

@robacourt

Copy link
Copy Markdown
Contributor

Summary

Extends the oracle property test's restart coverage (RESTART_SERVER_EVERY) from a single hard-coded graceful shutdown to three interchangeable restart strategies, selected via RESTART_TYPE, plus an opt-in flag to retry transient poll errors so the test never fails on a non-production availability blip.

This is test-only — no product code changes, nothing ships to consumers, so no changeset.

Changes

Restart strategies (RESTART_TYPE)

A Support.OracleHarness.RestartStrategy behaviour with three implementations, resolved by for_type/1:

  • graceful (default) — clean stop_supervised + restart the same stack_id, restoring from disk. Identical to the previous behaviour.
  • brutalProcess.exit(pid, :kill) crash (no terminate callbacks), reconcile the ExUnit child spec, wait for the tree to fully terminate, then recover the same stack_id/storage.
  • rolling — faithful rolling deploy: a new stack (own stack_id/storage/HTTP server) contends on the same replication slot via the Postgres advisory lock, boots to read_only, then the old stack is gracefully stopped (releasing the lock; persistent slot + publication survive) and the new stack takes over as the single active writer.

Supporting refactors:

  • ComponentSetup.start_stack_supervisor! made public with :id/:await_ready? opts (behaviour-preserving defaults).
  • Bandit setup factored into IntegrationSetup.start_bandit_client/2 with a shared Finch pool so a second HTTP server can coexist during a rolling deploy.

Opt-in transient-error retry (RETRY_TRANSIENT_ERRORS, default off)

When enabled, the shape checker retries a transient poll error (5xx response or a connection-level failure) within the check timeout instead of failing immediately. These are availability blips that production hides from clients during a restart/deploy (HTTP drains before teardown; the LB switches after the new instance is ready), so they are not data-consistency signals.

Default behaviour is unchanged. Regardless of the flag, 409/must-refetch, 4xx, and data mismatches always fail, so real bugs are never masked.

Notes for reviewers

  • The restart oracle path is opt-in and not run in CI (CI runs the oracle test without RESTART_SERVER_EVERY). These strategies are a developer tool for hunting restart/deploy consistency bugs.
  • Verified against a live Postgres: graceful is behaviour-identical to the previous path; brutal recovers cleanly; rolling performs the full lock hand-off and serves all shapes from the new stack's fresh resnapshot. The transient-error classifier was unit-checked ({5xx, 4xx, transport} -> {retry, fail, retry}) and confirmed not to mask the real 409 signal.

Test Plan

  • RESTART_TYPE=graceful RESTART_SERVER_EVERY=2 mix test test/integration/oracle_property_test.exs --include oracle
  • RESTART_TYPE=brutal RESTART_SERVER_EVERY=2 mix test test/integration/oracle_property_test.exs --include oracle
  • RESTART_TYPE=rolling RESTART_SERVER_EVERY=2 mix test test/integration/oracle_property_test.exs --include oracle
  • Default run (no RESTART_TYPE) unaffected

Generated with Claude Code

robacourt added 2 commits July 2, 2026 10:18
The oracle property test could restart the stack every N batches
(RESTART_SERVER_EVERY) but only ever did a graceful shutdown. Introduce a
Support.OracleHarness.RestartStrategy behaviour with three implementations
selected via RESTART_TYPE:

  - graceful (default) - clean stop_supervised + restore from disk
  - brutal   - Process.exit(pid, :kill) crash, then recover same stack_id
  - rolling  - a new stack contends on the same replication slot via the
               advisory lock, then the old stack is gracefully stopped so the
               new one takes over (faithful to cloud rolling deploys)

start_stack_supervisor! is now public with :id/:await_ready? opts, and Bandit
setup is factored into IntegrationSetup.start_bandit_client/2 with a shared
Finch pool so a second HTTP server can coexist during a rolling deploy.
Add RETRY_TRANSIENT_ERRORS (default off). When enabled, the shape checker
retries a transient poll error (5xx response or a connection-level failure)
within the check timeout instead of failing immediately. These are
availability blips that production hides from clients during a restart/deploy
(HTTP drains before teardown; the load balancer switches after the new
instance is ready), so they are not data-consistency signals.

Default behaviour is unchanged: any poll error fails. Regardless of the flag,
409/must-refetch, 4xx errors and data mismatches still fail, so real bugs are
never masked.
@codecov

codecov Bot commented Jul 2, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 60.00%. Comparing base (841b51a) to head (e85698d).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4677      +/-   ##
==========================================
- Coverage   60.61%   60.00%   -0.62%     
==========================================
  Files         436      395      -41     
  Lines       45435    43747    -1688     
  Branches    12578    12581       +3     
==========================================
- Hits        27541    26250    -1291     
+ Misses      17816    17419     -397     
  Partials       78       78              
Flag Coverage Δ
electric-telemetry ?
elixir ?
elixir-client ?
packages/agents 72.64% <ø> (ø)
packages/agents-mcp 77.70% <ø> (ø)
packages/agents-mobile 80.67% <ø> (ø)
packages/agents-runtime 83.72% <ø> (ø)
packages/agents-server 75.49% <ø> (+0.01%) ⬆️
packages/agents-server-ui 8.32% <ø> (ø)
packages/electric-ax 51.06% <ø> (ø)
packages/experimental 87.73% <ø> (ø)
packages/react-hooks 86.48% <ø> (ø)
packages/start 82.83% <ø> (ø)
packages/typescript-client 91.83% <ø> (ø)
packages/y-electric 56.05% <ø> (ø)
typescript 60.00% <ø> (+<0.01%) ⬆️
unit-tests 60.00% <ø> (-0.62%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@robacourt robacourt changed the title test(oracle): graceful/brutal/rolling restart strategies + opt-in transient-error retry test(oracle): graceful/brutal/rolling restart strategies for postgres oracle tests Jul 2, 2026
@claude

claude Bot commented Jul 2, 2026

Copy link
Copy Markdown

Claude Code Review

Summary

Test-only PR that generalizes the oracle property test's mid-run restart into three interchangeable strategies (graceful/brutal/rolling, via RESTART_TYPE), plus an opt-in RETRY_TRANSIENT_ERRORS flag that tolerates 5xx/connection blips during a restart window. No product code changes. The one new commit since iteration 3 (resolve stack_supervisor_id from ctx in all restart strategies) resolves the last actionable suggestion from my previous review.

What's Working Well

  • Suggestion Working with Postgres WAL format from Elixir #1 is now fixed (new since last review). restart_complete_stack/1 and brutally_restart_complete_stack/1 no longer hardcode Electric.StackSupervisor; both now resolve sup_id = Map.get(ctx, :stack_supervisor_id, Electric.StackSupervisor) — the same pattern rolling_restart_complete_stack/1 already used. The resolved id is threaded through consistently:

    • stop_supervised(sup_id) (graceful) and the temporary-spec cleanup stop_supervised(sup_id) (brutal) target the actual child id.
    • It is passed to start_stack_supervisor!(..., id: sup_id), which honors it (id = Keyword.get(opts, :id, Electric.StackSupervisor), component_setup.ex:694).
    • Both helpers now return %{stack_supervisor: ..., stack_supervisor_id: sup_id}, so Map.merge(ctx, restart_stack(...)) in oracle_harness.ex:195 preserves the id across successive restarts. All three strategies now round-trip stack_supervisor_id symmetrically.

    The single-strategy common case is unchanged: with no :stack_supervisor_id in ctx, sup_id defaults to Electric.StackSupervisor, exactly as before. The doc comment on restart_complete_stack/1 still accurately describes the behaviour.

  • Everything from iterations 1–3 still stands. The polled_ok? false-pass fix (iteration 3), correct transient_error?/1 classification, bounded retry loop, brutal-kill recovery via wait_for_stack_processes_down/2, and the sound rolling slot-persistence assumption are all intact. No regressions introduced. No changeset needed (test-only).

Issues Found

Critical (Must Fix)

None.

Important (Should Fix)

None.

Suggestions (Nice to Have)

One low-priority suggestion from earlier remains open — restating for tracking, not as a blocker:

Optional @specs on the new public support functionsstart_bandit_client/2, finch_client_opts/1, and the new ComponentSetup helpers are public but unspecced. Test-support code isn't held to the product @spec bar, so purely optional.

Issue Conformance

No linked issue. Per repo convention this is normally worth flagging, but the change is a self-contained developer-tooling improvement with a clear, detailed PR description and an explicit manual test plan. Implementation matches the described scope exactly, with no scope creep. The new commit's message accurately describes the change ("No change in the common single-strategy case").

Previous Review Status

Iteration 4: the sole new commit (resolve stack_supervisor_id from ctx in all restart strategies) directly and correctly addresses previous Suggestion #1, which I now consider resolved. The only remaining item is the optional @spec suggestion, which is non-blocking. No new issues introduced. This PR looks ready to merge.


Review iteration: 4 | 2026-07-02

robacourt added 3 commits July 2, 2026 10:41
Each strategy module was a one-line delegate to Support.ComponentSetup with no
runtime polymorphism (selection is a static RESTART_TYPE string), so the
behaviour + four modules were unnecessary indirection. Replace them with a
private restart_stack/2 dispatch in oracle_harness.ex that pattern-matches the
type and calls the ComponentSetup function directly; an unknown type raises.
With RETRY_TRANSIENT_ERRORS on, a window that timed out without a single
successful poll previously returned stale state to assert_consistent!, which
could pass silently if the oracle happened to be unchanged for that check.
Thread a polled_ok? flag through do_await/3 and fail explicitly when the retry
window completes no successful poll, so a genuinely-down server always fails
rather than incidentally passing. Default behaviour (flag off) is unchanged.
…ategies

graceful and brutal hardcoded the Electric.StackSupervisor child id while
rolling read Map.get(ctx, :stack_supervisor_id, Electric.StackSupervisor).
Make graceful/brutal use the same pattern (and preserve the id in their return
merge) so the helpers stay correct if strategies are ever composed. No change
in the common single-strategy case, where the id defaults to
Electric.StackSupervisor.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant