ADR 0020: The replay driver goes upstream, and is not re-exported back
Status
Accepted. Supersedes ADR 0018
in its entirety: everything 0018 decided is now decided the same way, by
eventsource-py 0.12.0, in eventsource's code rather than ours. 0018 stays
in the tree as the record of why the shape is what it is — the reasoning was
the deliverable, and it is the reasoning upstream adopted.
ADR 0006 stands, and this removes
five names from __all__ (project, replay, ReplayReport,
ReplayFailure, ReplayFailedError) without touching the mechanism.
ADR 0007 stands — nothing
moved between layers; a module left the tree.
ADR 0001 and
ADR 0004 stand: no event payload
changed and the log is neither read nor written differently by anything here.
Context
This project wrote two things because the library underneath it did not offer
them:
projections/replay.py—project/replay,ReplayReport,
ReplayFailure, ReplayFailedError. eventsource's ProjectionCoordinator
polls on a timer for live catch-up; nothing drove a rebuild, and the
difference is load-bearing rather than cosmetic
(CheckpointTrackingProjection.handle re-raises, which is correct for a
subscription and wrong for a rebuild).
projections/base.py—StoreProjection, aDeclarativeProjectionholding
one store and restating its parent's full constructor so a subclass could
not silently narrow it.
Both were reported upstream. Both shipped in eventsource-py 0.12.0, as its
ADR 0054 and ADR 0055, alongside FeedReadOptions.aggregate_type (its ADR
0052), which is the other half of BACKLOG.md B68.
So the question this record settles is not whether to adopt them — a
downstream copy of a dependency's feature is debt by definition. It is what
happens to the five names on our public surface.
Decision
**Delete both modules and take the upstream implementations. Do not
re-export.** from eventsource import replay.
Why not re-export
redstring.__init__ already argues the general case, about types rather than
functions: *"Re-exporting them under our own name would be worse than
depending on them openly."* A re-export costs the surface five names it does
not own, and buys a caller one import line. The prices are not comparable:
every name in __all__ is a promise this package has to keep across its own
releases, and four of these five would be promises about someone else's
release cadence.
The alias project dies with it, and that is the part worth saying out loud,
because it was added for a downstream consumer whose vocabulary has a
project noun. The collision is still real and the answer is now
from eventsource import replay as replay_log — an import-site rename, which
is where a naming collision is best solved anyway, and which no longer
obliges this package to carry a second name for one function forever.
What the upstream version does that ours did not
Not a transcription. Five differences, and two of them are defects ours had:
| ours | upstream 0.12.0 | |
|---|---|---|
| read scoping | tenant_id |
tenant_id and aggregate_type, both pushed into the adapter's query |
| failure list | unbounded, pinning live tracebacks | capped by max_failures, with failures_truncated reporting what the cap dropped |
| streaming failures | none | on_failure=, fired for every failure retained or not, and before a strict=True raise |
ReplayReport.failed |
distinct positions | distinct event ids |
ReplayFailure.event_type |
type(event).__name__ |
the event's own registered event_type |
ReplayFailedError root |
RedstringError |
eventsource's ProjectionError |
The third row is BACKLOG.md B73, which argued exactly those two shapes — a
cap that says it truncated, or a callback — and deferred choosing between them
until someone had a replay failing at that scale. Upstream took both, which is
the right answer to a two-good-options question when neither costs the other.
The fourth row is a latent defect, not a preference. position is
Position | None by contract; a store with no feed sets it on nothing, and
our failed would then have folded an entire failed rebuild into a count of
one. No test here could have caught it, because both adapters in this
repository always set a position — the failure-shape table in CLAUDE.md has
a row for exactly this reasoning ("a contract two implementations satisfy by
accident is not a contract").
The sixth row is a correction. ReplayFailedError deriving from
RedstringError said the failure belonged to this library; what actually
happened is that a projection failed to process an event, which is
ProjectionError. except ProjectionError should catch a strict rebuild's
stop for the same reason it catches a live projection's.
The consequence for `StoreProjection`
GraphProjection and VectorProjection now inherit
StoreProjection.__init__(store, **options: Unpack[ProjectionOptions]).
All six parent options become keyword-only, where three of them
(checkpoint_repo, dlq_repo, enable_tracing) had been
positional-or-keyword in our hand-written version. `GraphProjection(store,
checkpoints) now raises TypeError`. That is a break with no shim and it is
an improvement: passing those positionally is how you get enable_tracing
where you meant retry_policy.
Consequences
**DOCUMENTED_FOREIGN_TYPES loses eight entries, and that is the gate
working.** GlobalEventFeed, EventSubscriber and Position were there for
project's signature; RetryPolicy, Tracer, TenantFilter,
ProjectionCheckpoints and DLQRepository were there for our
StoreProjection.__init__. No signature of ours mentions any of them now, so
test_no_documented_foreign_type_is_stale struck them —
ADR 0014's rule
firing on a list that describes rather than exempts.
The five constructor names are not undocumented as a result; they are
documented by ProjectionOptions, which is upstream's own name for that
option set. redstring.projections says so, reasoned about rather than
measured — the same standing exception the module docstring already records
for document_stream and the constructor a class inherits from a foreign
base.
The floor moves to >=0.12.0 and the cap to <0.13. Unlike the previous
two bumps, the floor moves because something here needs it:
redstring.projections imports StoreProjection from
eventsource.application.projections, which does not exist before 0.12.0.
tests/integration/test_declared_floors_work.py measures that rather than
asserting it.
The tests that covered replay stay, and change what they are for. They
now exercise this package's projections through the upstream driver — a real
MissingEntityError from GraphProjection, not an injected exception — which
is the integration nobody upstream can write. test_replay_alias.py is
deleted outright: it pinned an alias that no longer exists.
A future upstreaming should look like this one. What made it cheap was
that the reasoning had been written down at the time — ADR 0018 and BACKLOG
B68/B73 are what the upstream ADRs are arguing with, and B73's "here are the
two honest shapes and here is why neither is worth choosing yet" is
recognisably what got built. Deferred work recorded properly is a design
document that has already survived a use.