Files
buzz/crates
Luke Tornquist 76cd4294f6 fix(relay): close a REQ whose history read failed instead of sending EOSE (#8692)
## Summary

**Merge order:** this lands and deploys **before**
[#8688](https://github.com/block/buzz/pull/8688). That PR's buzz-acp
resume cursor counts on a failed history read never ending in EOSE.
Until #8688 ships, buzz-acp re-sends a REQ as soon as it gets an error
CLOSED. The relay's REQ rate limit bounds that loop, and buzz-acp
already parks on a rate-limited CLOSED. #8688 replaces this with a
per-subscription backoff.

When a historical query fails for a reason other than a statement
timeout, the relay logs the error and still sends `EOSE`. EOSE means
"you have every stored event" (`ARCHITECTURE.md` §5, NIP-01). A client
that resumes from its last-seen point therefore treats the failed read
as complete, advances past it, and never replays that history. This is
RPR #1: a failure must not become an authoritative empty result.

The history task now returns the CLOSED reason to send:

- **Statement timeout:** `["CLOSED", sub_id, "error: query timed out"]`,
unchanged.
- **Any other DB error:** `["CLOSED", sub_id, "error: database error"]`.
- **Cleanup:** CLOSED goes through the existing `close_if_owner` path,
so the subscription, its sub-registry filters and its topic refcount are
released, the same as on a timeout.
- **Docs:** `ARCHITECTURE.md` §5 now states this contract.

**Clients don't hot-loop on it.** Checked at `main` 7c165c37:

- **Desktop:** `relayClosedPolicy.ts` / `relayClosedRecovery.ts`
classify `error: database error` as retryable. Live subscriptions back
off from 1s to 30s and keep their `since`. History-mode requests reject.
- **Mobile:** `relay_closed_policy.dart` / `relay_session.dart` do the
same, with their own capped backoff.
- **buzz-acp:** today it re-REQs at once, bounded by the relay's REQ
rate limit. [#8688](https://github.com/block/buzz/pull/8688) adds a
per-subscription backoff that also restores the loss record.

### What got simpler

- **One constant for the CLOSED reason.** `"error: database error"` was
a string literal repeated across `req.rs` (5×) and `count.rs` (4×). It
is now one constant, `DATABASE_ERROR_CLOSED`.
- **One mapping function.** `history_read_closed_reason` maps a DB error
to its CLOSED reason in one place.

### Identified debt (not changed here)

- **NIP-50 search path.** After a non-timeout full-text-search or
hydrate error, the search path still sends `EOSE` with partial results.
It needs the same treatment, but it has a separate result-assembly path,
so it's left out of this PR.

### Related issue

Adds the regression wpfleger96 asked for in [this review on
#8653](https://github.com/block/buzz/pull/8653#pullrequestreview-5692776411):
a history error followed by a reconnect still replays the history.

### Testing

New Postgres test
`api::bridge::postgres_tests::failed_history_read_closes_and_reconnect_replays_the_history`:

1. Lock the replica's `events` table past its 300ms `statement_timeout`
and send a history REQ. It must get exactly `["CLOSED","t","error: query
timed out"]`.
2. Break the `events` table on both the writer and replica pools and
send a REQ. It must get exactly `["CLOSED","h","error: database
error"]`.
3. After each CLOSED, the subscription map, the sub-registry filters and
the topic refcount must all be cleared.
4. Restore the table and send the same REQ on a new connection. It must
get the stored event, then `EOSE`.

Run on Blox at 95a8255a:

- `cargo fmt --check`
- `cargo clippy -p buzz-relay --all-targets -D warnings`
- Postgres lane via `scripts/postgres-test-run.sh -p buzz-relay`
(499/499)
- The new test and `routed_read_cancel_maps_to_timeout_contract` 5×
- **Mutation checks:** each of these makes the new test fail at its
frame assertion.
  - Restoring `EOSE` for a non-timeout error.
  - Mapping every failure to `error: database error`.
- **`just test` integration half:** run by hand, because
`_ensure-services` can't start its Redis container on that workstation
while a native redis-server holds the port.
- Workspace `cargo test --test '*'`: every binary passes except two
`buzz-acp` `git_bootstrap` tests.
- Those two fail the same way on that workstation without this change,
so they come from the environment.
  - `buzz-db` and `buzz-auth` integration tests pass.

End-to-end against a locally built relay at e5621f94, with buzz-acp from
#8688:

- **While `events` was missing:** every channel REQ got `CLOSED error:
database error`, and no channel EOSE was sent.
- **After the table was restored:** the next retry replayed the missed
mention, followed by EOSE.

The later commit only changes tests and docs.

buzz-review-completed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:03:05 -04:00
..