Skip to content

v1.7.5

Choose a tag to compare

@github-actions github-actions released this 11 Jun 06:31
· 113 commits to main since this release

What's changed in v1.7.5

  • chore: add red-team concurrency and failure-injection conformance tests (#85) (by @patrickleet)

    Pin guarantees the publish-on-commit + snapshot work introduced that were
    previously only covered by unit tests against fakes. All tests exercise
    EXISTING behavior; none required a production change, and all pass against
    current code (no #[ignore]d tests).

    Tests added:

    • racing_commits_one_wins_one_conflicts (scenario.rs, all 3 backends):
      N tasks load the same aggregate at v0 and commit behind a barrier; asserts
      exactly 1 success, N-1 ConcurrentWrite, and a gap/duplicate-free final
      stream — the unique-PK backstop behind the version-check TOCTOU window under
      real concurrency.
    • expired_outbox_lease_is_reclaimed_by_second_worker (outbox.rs, all 3
      backends): worker A claims a short lease and "crashes"; after expiry worker B
      reclaims and completes; A's late complete/release are fenced (InvalidState).
      Exercises the SQL claimed_until expiry predicate end-to-end.
    • publish_failure_after_commit_retains_outbox_row_until_delivered (outbox.rs,
      all 3 backends): dispatcher with a publisher that fails twice then succeeds;
      row goes Pending after each failure (attempts incrementing), then Published,
      with exactly one bus delivery.
    • read_model_failure_mid_plan_rolls_back_events_and_outbox
      (sqlite_repository + postgres_repository): a commit with events + outbox + a
      two-mutation read-model plan whose second mutation hits a real DB constraint
      (SQLite trigger / Postgres CHECK); asserts events, outbox row, and the first
      mutation are all absent (whole-tx rollback).
    • unknown_event_type_in_stream_fails_hydration_with_clear_error +
      repo-path variant (upcasting): a stream with an unregistered event_name fails
      hydration/get with a RepositoryError::Replay naming the event.
    • queued_repo_n_writers_commit_in_fifo_order (queued_repo): 10 writers contend
      for one aggregate; asserts final version == N+1 and event order equals the
      lock-grant order.
    • replay determinism proptest (new tests/replay_property/): proptest over
      arbitrary command sequences — reload reproduces in-memory state; snapshot
      hydration at any frequency equals full replay.

    Adds proptest as a dev-dependency. Postgres-backed variants env-gate on
    DATABASE_URL and skip locally; SQLite/HashMap variants run without docker.

    Bugs discovered: none. Every test passes against current code.

    Implements [[tasks/red-team-conformance-tests]]

    Co-authored-by: Claude Fable 5 noreply@anthropic.com

  • fix: route corrupt PostgresBus rows through failure policy (#80) (by @patrickleet)

    • fix: route corrupt PostgresBus rows through failure policy

    A bus_queue/bus_log row whose required columns (name, kind,
    payload) failed to decode was silently lost. message_from_row used
    try_get(...).unwrap_or_default(), so a corrupt row became a Message
    with an empty name. The runner classifies an empty/unrouted name as
    "no handler", which takes the ack-and-ignore path: QueueReceived::ack
    deletes the row and LogReceived::ack advances the consumer offset past
    it. The corrupt row vanished with no trace, bypassing the otherwise
    careful never-silently-drop failure policy.

    Silent-data-loss path:
    recv -> message_from_row (unwrap_or_default -> name = "")
    -> runner: router.handles(kind, "") == false
    -> received.ack() (queue: DELETE row; log: advance offset)
    -> message gone, no dead-letter, no log

    Fix:

    • message_from_row returns Result<Message, TransportError>, raising a
      PERMANENT error when a required column is missing or fails to decode.
      message_id/content_type/metadata keep tolerant defaults (optional
      or schema-defaulted; they don't make a message unhandleable).
    • The Postgres recv claims the row/offset before decoding, so the claim
      must still be settled when decoding fails. QueueReceived/LogReceived
      carry the decode error and surface it via a new
      ReceivedMessage::decode_error() (defaults to None, so the other 8
      adapters are unchanged).
    • The runner checks decode_error() first and routes the claimed row
      through the configured FailurePolicy — dead-letter by default — exactly
      like a permanent dispatch failure. Queue corrupt rows are dead-lettered
      (deleted, logged), log corrupt rows advance the offset past the poison
      entry (don't get stuck), and the failure is visible instead of silent.

    Tests:

    • runner unit tests (no DB): a decode-error delivery with an unhandled
      (empty) name is dead-lettered under the default policy (not ack-and-
      ignored), parked under Park, and stops with a permanent error under Stop.
    • postgres_transport integration tests (env-gated on DATABASE_URL): a
      corrupt bus_queue row leaves the queue while the valid row beside it is
      handled; a corrupt bus_log row advances the consumer offset past it
      while the valid following event is handled.

    Implements [[tasks/postgres-bus-corrupt-row-handling]]

    • fix: claim corrupt name-NULL postgres bus rows so they settle

    The prior commit routed undecodable rows through the failure policy, but
    that path was unreachable for the most common corruption: a NULL name.

    Both sources select work by name — the queue claim filters
    WHERE name = ANY($2) and the log read filters WHERE name = ANY($1).
    A row whose name column is NULL matches neither, so it is never claimed
    (queue) or never read (log). The decode-error handling never fired because
    the corrupt row was never even fetched. The queue row therefore sat
    undelivered forever (the failing test: queue still had 1 row), and a
    corrupt log entry was silently skipped when the offset jumped to a later
    healthy entry — dropped with no trace, the exact ack-and-ignore behavior
    the hardening set out to prevent.

    The missing piece was not settlement identity (the claim already captures
    seq independently of payload decode); it was visibility: an un-routable
    row must still be selectable so the runner can settle it.

    Fix: both sources also select rows with a NULL name (un-routable
    poison). Such a row belongs to no consumer, so claiming/reading it to
    dead-letter it is correct — FOR UPDATE SKIP LOCKED keeps the queue claim
    safe under competing listeners, and each log group advances its own offset
    past it. The runner then surfaces the decode error and routes it through
    the failure policy (dead-letter by default: the queue row is deleted, the
    log offset advances past the entry).

    Also strengthen the log test: it now places a corrupt entry as the highest
    seq, so a consumer that silently skips by name would leave the offset short
    of max_seq. Reaching max_seq proves the corrupt entries were settled
    through the policy, not skipped. Verified against a real Postgres 18
    (docker compose): the improved test fails without the LogSource fix and
    passes with it; full postgres_transport (9) and lib (253) suites green.

    Refines [[tasks/postgres-bus-corrupt-row-handling]]

See full diff: v1.7.4...v1.7.5