Skip to content

Feat: Initial implementation — pull-based HTTP event feeds - #1

Merged
Meldiron merged 68 commits into
mainfrom
feat-initial-implementation
Aug 3, 2026
Merged

Feat: Initial implementation — pull-based HTTP event feeds#1
Meldiron merged 68 commits into
mainfrom
feat-initial-implementation

Conversation

@Meldiron

@Meldiron Meldiron commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Introduces utopia-php/feed, a library for moving events between services with
pull-based HTTP event feeds (http-feeds.org)
instead of pushing them to every service that needs them.

Where this comes from

Extracted from two implementations of the same idea that grew up on either side
of one feed — a producer in appwrite-labs/cloud (#5046)
and a consumer in appwrite-labs/edge (#988).
Between them they had two event models, two pull loops, two cursor stores, and
two copies of the HTTP contract joining them
. The last one is the duplication
that mattered most: the two halves drifting apart is a wire incompatibility, not
a local bug.

docs/migration.md maps every existing class in both repos onto this library.

Design

Journal Where the events live. Assigns an ordered id on append, returns the events after a given id. Journal\Http reads another service's journal, so a remote feed and a local one are the same object to everything above it.
Feed The policy on one journal: stamps source/time, clamps a consumer-supplied limit, long-polls.
Cursor Where one consumer's position is kept — deliberately independent of Journal.
Consumer The pull loop, with the at-least-once semantics both original implementations relied on.
Protocol The HTTP contract — params, envelope, caching — held in one place so the two halves cannot drift.

The structural point: the producer stores no per-consumer state, which is
what makes adding a consumer free.

Events are Utopia\CloudEvents\CloudEvent — this library defines no event type
of its own.

Behaviour deliberately preserved from the originals

  • A consumer with no position starts at the oldest retained event, never the
    tip. This is what makes a staged rollout safe.
  • A run commits the events handled before a failure, then re-raises; the failed
    event retries and everything behind it waits.
  • A cursor store that is down is a warning, not a failure.
  • The feed:<name>:cursor:<consumer> key format and <ms>-<seq> ids, so
    positions already handed out survive the migration.

Test plan

  • 143 unit tests, including a round-trip suite where a real Feed is served
    through Protocol and consumed through Journal\Http — both halves of the
    contract, not a fixture written to match one side.
  • 17 E2E tests against a real Redis, covering what only a real server
    confirms: that XADD ids match the format positions are parsed with, and that
    a consumer below the MAXLEN trim horizon still reads.
  • PHPStan level max and Pint clean.

All green on PHP 8.5 in CI.

Blocking before release

  • utopia-php/cloudevents is pinned to a branch
    dev-feat-cloudevents-g2. That branch needs merging and tagging, and this
    composer.json updating to the tag, before anything here can be released.

Review notes

  • Two licence files. The repo was created with LICENSE (MIT, 2026
    Utopia.php); this branch also carries LICENSE.md (MIT, 2013 Eldad Fux)
    inherited from the utopia repo the scaffolding was based on. One should go —
    left in deliberately rather than picking for you.
  • CI builds one parameterised image per entry in the php-versions matrix in
    .github/workflows/tests.yml; adding a version is one line there.

🤖 Generated with Claude Code

Comment thread src/Feed/Protocol.php Outdated
Comment thread src/Feed/Consumer.php Outdated
Comment thread src/Feed/Journal/Memory.php Outdated
Comment thread .github/workflows/analysis.yml Outdated
@greptile-apps

greptile-apps Bot commented Jul 29, 2026

Copy link
Copy Markdown

Greptile Summary

This PR introduces a pull-based HTTP event-feed library.

  • Adds producer, server, consumer, cursor, store, batch, remote-feed, and identifier abstractions.
  • Provides memory, cache, Redis, pooled, and disabled backend adapters.
  • Defines strict CloudEvents batch transport behavior and migration-compatible cursor handling.
  • Adds documentation, unit and integration tests, Docker development infrastructure, and pinned CI workflows.

Confidence Score: 3/5

The PR is not yet safe to merge because shared cursor updates can still overwrite newer progress, seeks, or resets.

Cursor advancement compares the stored position and writes the replacement in separate operations, so another replica can update or delete the cursor between those operations and have its decision overwritten.

Files Needing Attention: src/Feed/Cursor.php, src/Feed/Consumer.php, src/Feed/Cursor/Redis.php

Important Files Changed

Filename Overview
src/Feed/Consumer.php Implements batch consumption, at-least-once progress persistence, manual seek/reset operations, and tip-start behavior.
src/Feed/Cursor.php Defines cursor persistence and the conditional advancement mechanism used to coordinate shared consumers.
src/Feed/Cursor/Redis.php Implements string-backed Redis cursors with compatibility reads for legacy stream-backed positions.
src/Feed/Remote.php Implements HTTP feed requests and strict decoding of plain CloudEvents batch arrays.
src/Feed/Store.php Defines shared store validation, polling, and CloudEvent serialization behavior.
src/Feed/Store/Redis.php Implements an approximately capped Redis stream journal with ordered reads.
.github/workflows/tests.yml Adds matrix-based adapter testing and full-suite coverage reporting with commit-pinned actions.

Reviews (18): Last reviewed commit: "Fix stability" | Re-trigger Greptile

Matej Bačo and others added 5 commits July 29, 2026 16:25
CI: appwrite/utopia-base:php-8.5-1.0.0 does not exist — 1.0.0 was only ever
published for 8.3 and 8.4. Both the PHP 8.5 and PHPStan jobs failed at the
FROM. Moved to 2.1.0, which is published for 8.3, 8.4 and 8.5.

Protocol::decode() required the "events" field instead of defaulting it. A
response with no envelope — a misrouted request, a proxy's JSON error page, an
endpoint that moved — decoded to an empty batch, which a consumer reads as
"caught up". That is indistinguishable from a genuinely empty feed and leaves
the consumer parked at a position that never advances again.

Journal now rejects a retention cap below one event. Non-positive caps meant
opposite things per backend: Redis reads MAXLEN 0 as "keep nothing", while
array_slice($events, -0) keeps everything, so the in-memory journal grew
unbounded.

Pinned actions/checkout to a commit SHA in all three workflows, so a moved
upstream tag cannot change what CI executes.

Documented that a consumer name belongs to one process: the position is written
with a plain set, so two processes sharing a name can move it backwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two processes running the same consumer overlap during a rolling restart, which
is a normal operation rather than a misconfiguration. They finish batches of
different lengths, so the departing one's older position could land last and a
later restart would replay everything between the two — on a busy feed,
thousands of events.

Positions are totally ordered, so Cursor::save() now compares before writing and
drops anything that is not an advance. Cursor::shouldAdvance() holds the rule,
and every implementation applies it.

The guard fails open: a position that cannot be compared, because the store
could not be read or because what came back is not a position, is treated as
behind. It exists to stop a position going backwards and must never become a
reason for one to stop going forwards.

The comparison is not atomic. Closing the window entirely needs a compare-and-set
in one operation, and each candidate costs more than the race does: Redis scores
are doubles and cannot hold <ms>-<seq> exactly, WATCH leaves state on a
connection about to return to a pool, and a one-entry stream changes the stored
type and breaks cursors written by an earlier version. The window is
microseconds against a poll interval of seconds, and losing the race costs a
replay, which every handler already tolerates.

Costs one extra read per advance. An idle poll saves nothing, so it still reads
nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The read-compare-write in Cursor::save() is not atomic, so two processes sharing
a consumer name can interleave and leave the older position stored. That was
reasoned about but only thinly recorded, which leaves the next reader to redo
the analysis.

Consumer listed three reasons a handler must tolerate seeing an event twice.
This is a fourth, and naming it there makes it part of the stated contract
rather than an unlisted edge: every one of the four re-delivers, none of them
skips, and that asymmetry is the point.

Cursor::save() now carries why the window is left open — each compare-and-set
candidate and what it costs, including that Cursor\Cache has no portable one at
all, since a Utopia cache reports generation '0' on adapters without leases. A
fix that held on some cache backends and silently not on others would be worse
than one uniform, stated guarantee.

Also drops a stale reference to a Lua script that no longer exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A feed position is a Redis stream id — <ms>-<seq> is the format XADD allocates —
and Redis refuses to append an id equal to or smaller than the one at the top of
a stream. Storing the position as the id of a one-entry stream makes "a position
never moves backwards" the server's rule, enforced by the same operation that
writes it, so two processes racing during a rolling restart cannot both decide
they are ahead. It also drops a round trip: XADD replaces the read-compare-write.

Cursor\Cache keeps the non-atomic guard. A Utopia cache has no portable
compare-and-set — getGeneration() returns '0' on adapters without lease support
— so a fix there would hold on some backends and silently not on others, which
is worse than one clearly stated guarantee. Documented per implementation rather
than as one blanket claim.

The upgrade from string-valued keys is transparent: load() reads a string where
it finds one, and the next save() replaces the key in place. The position is
carried across, because the consumer is advancing past exactly what the string
held, so nothing replays. docs/migration.md carries the operational notes — in
particular that rolling back to a build predating this hits WRONGTYPE, and that
anything reading these keys directly needs XREVRANGE rather than GET.

Also drops the phpstan ignore rules for the Redis stream commands. They were
added under phpstan 1.x; 2.2's bundled stubs cover these methods, so the rules
matched nothing and, with reportUnmatchedIgnoredErrors off, would have silently
masked real errors later.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
phpredis type() returns an integer constant, not a type name, so comparing it
to 'stream' could never hold. And testCursorsAreStoredUnderTheFeedTheyBelongTo
still read the key with GET, which returns false now the position lives in a
stream entry's id.

All three were assertions rather than behaviour: monotonicity, the digit
boundary, the legacy read and the invalid-id rejection all passed against a real
Redis on the previous run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread src/Feed/Cursor/Redis.php Outdated
Comment thread src/Feed/Consumer.php
Meldiron and others added 4 commits July 31, 2026 10:57
A batch on the wire is now the plain JSON array of CloudEvents the spec
defines — the {total, events} envelope is gone. Protocol::encode()
returns the bare array, Protocol::decode() expects one (keeping the
per-entry leniency policy), and the HTTP journal asks for the spec's
application/cloudevents-batch+json media type while staying tolerant of
servers that answer application/json. The limit query parameter stays,
documented as an extension beyond the spec, and an event carrying the
spec's optional method attribute is pinned to decode into extensions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Feed::read() and Feed::poll() now return a Batch — the events paired
with the limit the read was actually clamped to, so cacheControl() can
never be fed a number the batch was not built with. Batch counts and
iterates as its events, toArray() is the wire encoding, and lastId() is
the position a stateless relay tracks by hand.

Feed::serve() takes a route's raw query-parameter array and does the
whole request: extracts lastEventId, limit and timeout, coerces string
values, applies the defaults and clamps, treats an empty lastEventId as
absent, and rejects a malformed one with Exception\Invalid. A route
body no longer names Protocol at all, and Protocol is documented as
internal plumbing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A lastEventId of $ — a protocol extension like limit, borrowed from the
Redis XREAD convention — now means "the tip of the feed". Journals
resolve it to the newest entry at the moment of the call (an empty feed
resolves to the beginning of future events), pinned once per poll so
events landing mid-wait are still delivered. Journal\Http passes the
sentinel through for the producer to resolve inside the same request,
leaving no tip round trip to race; Feed::serve() lets it through its id
validation, and Id keeps rejecting it.

Consumers opt in with Start::Tip: when no position is stored, the first
poll uses the sentinel. A stored cursor always wins, and reset() with
Start::Tip means "forget everything, resume from now". Feed::tip()
exposes the newest event's id for local journals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
seek($eventId) treats the id as the last event handled: the next
consume() starts strictly after it. The position persists immediately
through the cursor store and mirrors in memory; a store failure
surfaces as Transport with the in-memory position unmoved, so a seek
that did not persist never looks like one that did. The id must satisfy
Id::isValid — the tip sentinel included in the rejects — but does not
need to exist in the feed, which is what makes seeking to a poison
event's own id the deliberate way to step past it. Documented next to
reset() in the README, with the poison-event recipe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Meldiron and others added 26 commits August 3, 2026 12:00
The README promises `Exception\Transport` when "the backend or network
failed: Redis errors, HTTP failures, a cursor store that is down", and
`Store\Redis`/`Cursor\Redis` honour it by wrapping `\RedisException`. The
cache-backed twins wrapped nothing. `Utopia\Cache\Adapter\Redis` rethrows
`\RedisException` once its internal retries are exhausted, so over a
Redis-backed cache that is down, `Store\Cache::read()/tip()/append()` and
all three `Cursor\Cache` methods let a raw `\RedisException` escape.

That breaks the base-class promise that every error this library raises
extends `Utopia\Feed\Exception`, and it breaks the README's canonical
consume loop: `catch (Transport)` does not catch it, so the consumer
process dies on a backend blip instead of retrying — the exact failure
mode the Transport contract exists to prevent.

Both adapters now wrap the cache calls, following the Redis adapters'
pattern. The try blocks stay narrow on purpose: `Cursor::key()` and the
event decoding raise `Invalid`, which is the caller's bug rather than the
backend's failure, and catching `\Throwable` around them would erase that
distinction. A test pins it — an unusable name stays `Invalid` even when
the backend behind the cursor is also down.

`BrokenCache` grew a `raises` mode for this, so it now covers both ways a
cache adapter fails: answering `false` and letting the backend's error
out. It raises a plain exception rather than a `\RedisException` so the
service-free suites stay service-free; what the wrapping cares about is
only that the error is not one of ours.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Redis is one of several store adapters. `Store\Memory`, `Store\Cache` and
consuming a feed over HTTP work without it, and the only references to
`\Redis` are in type hints of classes a Redis-less service never
instantiates. Declaring `ext-redis` under `require` claimed otherwise, so
this package's own manifest overstated what its code needs.

Moved to `suggest` with a note naming the four adapters that need it.

One thing the review that prompted this assumed does not hold, and the
README now says so plainly rather than implying the problem is solved:
this change does not free a downstream install. `utopia-php/cache` — a
hard dependency — requires `ext-redis` and `ext-memcached` itself, so a
machine without them still needs `--ignore-platform-req` (verified against
a scratch project: the resolver now stops at the cache package's
extensions, not at ours). That constraint is not this repo's to remove.

The README's development instructions traded the blanket
`--ignore-platform-reqs` for three targeted `--ignore-platform-req=ext-*`
flags. The blanket flag also skipped the PHP version check, which for a
library requiring PHP 8.5 is the one platform requirement that must not be
waived silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Feed and cursor keys were built by joining names with `:` — a character a
name is allowed to contain, since names are only validated as non-empty.
Joined raw, the mapping is not injective, and both collisions corrupt data
rather than losing it:

- A feed named `edge:cursor:x` keyed to `feed:edge:cursor:x`, which is also
  the cursor key of consumer `x` on feed `edge`. `Cursor\Redis::save()`
  then `SET`s over an `XADD` stream. The new Redis test shows this is not
  the harmless WRONGTYPE it looks like — the whole feed is gone afterwards.
- Two distinct pairs could share one position: (`a:cursor:b`, `c`) and
  (`a`, `b:cursor:c`) both joined to `feed:a:cursor:b:cursor:c`, giving two
  unrelated consumers the "sharing a name" hazard the README warns about
  without anyone sharing a name.

Percent-encoding `:`, and `%` itself so the encoding stays reversible,
makes the collisions unexpressible. Escaping rather than rejecting is
deliberate: `Remote` reads third-party feeds whose names are arbitrary
path segments, and refusing one over a detail of how this library stores
positions would make that feed unconsumable. A name containing neither
character comes through untouched, so the layout the README documents —
`feed:<name>`, `feed:<feed>:cursor:<consumer>` — still reads from a shell
for every name anyone would actually pick.

Both key shapes now live in one `Key` class. That also removes the
duplicated `'feed:' . $this->name` in `Store\Redis` and `Store\Cache`,
which was the reason a store could drift from the escaping the cursor
does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Most of what this task asked for already landed with the test rework:
`Producer/PoolTest`, `Server/PoolTest` and `Consumer/PoolTest` run the
shared scenarios over `Store\Pool` and `Cursor\Pool`, as their own CI job,
so the delegates, their return values and the inner store's constructor
validation are all exercised.

One claim the review named was genuinely unpinned, and it is the one that
matters most: `Store\Pool` exists because a long poll borrows per read
rather than once around the whole loop, so a held poll never ties up a
connection. Nothing tested that. Hoisting the borrow — an inviting
"optimization", since it looks like it saves pool traffic — would have
removed the class's entire reason to exist with the whole suite still
green, and would only surface in production as a pool exhausted by idle
consumers holding connections for up to 30 seconds each.

`CountingStack` records releases so the property is asserted directly: a
~500ms poll on a 50ms interval must release many times, not once.
Confirmed by hoisting the borrow — the test fails with "1 is greater
than 2" and nothing else in the suite notices.

Two leak tests come with it, on the store and cursor sides: after a round
of reads, tips, seeks and resets the pool is whole again. A leaked
connection fails nothing functionally until the pool runs dry, which in a
service is minutes into production rather than here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Store\Cache` keeps the whole feed under one key, so `read()` fetched and
re-validated every retained entry even to return nothing. Under the
inherited poll loop that ran per `pollInterval` tick, per waiting consumer,
for up to 30 seconds a request — and a caught-up consumer on a quiet feed
is the common case, not the exception.

The newest id now also lives under a small second key, so a poll that is
provably caught up answers from that instead of loading the feed. Two
properties keep it honest:

- The marker is written *before* the feed, so it can be ahead but never
  behind. Behind, it would report a caught-up consumer and the event would
  never arrive; ahead only costs a read that finds nothing.
- It is only ever used to skip a read, never to answer one. A missing,
  expired or unparseable marker falls straight through to the real read,
  and `tip()` still reads the feed rather than trusting it.

Also lowered the adapter's default `maxSize` from the 100 000 it inherited
from the Redis store to 1 000. That number was chosen for a backend where
trimming is server-side and reads are ranged; here retention is also the
size of every append's read-modify-write, so at the inherited default a
single `produce()` moved megabytes through the cache in both directions.
This is a behaviour change — a feed relying on 100 000 entries of
retention now has to ask for it — which is why it is happening before the
first release rather than after.

Appends are still O(feed size): that is inherent to one key per feed, and
the retention default is now scaled to it rather than hiding it. The
README says all of this where the adapter is introduced.

Costs are asserted, not just described: `CountingCache` records reads per
key, and a 400ms caught-up poll must read the marker repeatedly and the
feed exactly zero times.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All three workflows triggered on `pull_request` only, so nothing ever ran
on the default branch. The README's build badge points at `tests.yml`, a
workflow with no runs on `main` — it would have shown "no status"
indefinitely. More usefully, a merge that breaks `main` through a semantic
conflict with an earlier merge stayed invisible until it surfaced inside
somebody else's unrelated PR. All three now also run on push to `main`.

The linter ran pint inside the unpinned `composer` image. This codebase
uses PHP 8.5 syntax — `new Stream\Factory()->createStream(...)` without
parens, typed class constants — which pint can only parse on a new enough
runtime, so whichever PHP `composer:latest` happened to ship was an
unstated build dependency: the job would break, or quietly under-lint,
whenever the image moved. Everything else here pins carefully
(`composer:2.7`, `redis:7.2-alpine`, `utopia-base:php-8.5-2.1.0`, checkout
by SHA); this was the one exception.

It now runs against the project's own test image, the way the analysis job
already does, which pins the runtime and drops the duplicate dependency
install path with it. Verified by running the job's exact commands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The consuming side is deliberately interoperable: `Remote::event()` accepts
any non-empty id, http-feeds endpoints commonly use UUIDs, and `consume()`
tracks such a position and sends it back as `lastEventId` without
complaint. But `seek()` gated on `Id::isValid()`, which describes the
`{ms}-{seq}` shape a Redis stream mints — a producer-side implementation
detail.

Against a third-party feed everything therefore worked right up until the
day an operator needed the documented poison-event escape hatch, and then
`seek($poisonEventId)` rejected the very id the consumer had just handled
and saved. There was no workaround short of writing to the cursor store by
hand. The same inconsistency from the other side: `consume()` saved those
ids happily, so "positions must be well formed" was only ever enforced on
the manual path.

The rule is now the feed's to state rather than this library's. A local
`Store` mints its positions and pages by decoding them, so a malformed id
is a caller's mistake and is still refused. Any other `Readable` — a
`Remote`, or someone's own implementation — is the authority on its ids,
so any non-empty one is accepted. Both still refuse the tip sentinel,
which stands for wherever the feed ends when the request arrives: a start,
not a position, and already expressible as `reset()` on a START_TIP
consumer.

The shared consumer scenarios split accordingly: ids no feed could use
stay asserted everywhere, shape judgements run only where the feed owns
the shape, and `Consumer/HttpTest` gains the case this is all about — a
UUID seek that persists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The docblock promised `Invalid` when `lastEventId` is present but is
neither a position nor the tip sentinel, but the coercion ran first and
folded every non-string into `null`. PHP parses `?lastEventId[]=1-0` into
an array, so that request was *present*, was *not* a position, and read as
"from the oldest retained event".

The result was the worst possible answer to a malformed parameter: instead
of a 400, the caller got a full replay of the retained feed — the most
expensive response the endpoint has — and a caught-up consumer would read
it as a sudden flood of new events rather than as the error it was.

`limit` and `timeout` deliberately keep falling back to their defaults for
the same input. Both are the producer's to decide and neither can cause a
wrong answer, which is the existing "garbage limit" policy; a comment and
a test now say that the difference is a choice rather than an oversight.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`publish()` takes a "prepared `CloudEvent`" and rebuilds it with the
producer's own `source`, discarding whatever the caller set. That is
deliberate and tested, but nothing said so: the docblock listed only the
exceptions, and the README did not mention `publish()` at all. A caller
relaying an event received from another feed would reasonably expect a
prepared event to be published as prepared, and instead had its origin
silently rewritten.

The docblock and the README now name all three attributes the producer
owns — `source`, `id` (the store assigns it, since it is also the event's
position) and a missing `time` — and say what to do about the relay case:
keep the original origin in an extension attribute.

Writing it down surfaced a fourth. `specversion` is passed through by
`publish()` but silently normalised to `1.0` by the store round trip,
since that is the only version `CloudEvent::fromArray()` accepts — keeping
another one would leave an entry in the feed that nothing could read.
Documented and tested rather than left as a claim, along with the source
replacement itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`application/cloudevents-batch+json` was written out on both sides of the
wire: `Batch::MEDIA_TYPE` for the `Content-Type` a feed response carries,
`Remote::MEDIA_TYPE` for the `Accept` every read sends. They are one
contract — one side's Content-Type is literally the other side's Accept —
and `Readable` is where this library already keeps what the serving and
consuming sides share. Editing one copy, say for a parameterized variant,
would have desynchronized the pair silently; only a test comparing the two
literals would have caught it, and there was none.

The value now lives on `Readable`. `Remote` implements the interface so it
inherits the constant outright, and `Batch::MEDIA_TYPE` stays as an alias
of it: both call sites keep the name that reads naturally where they are
used, while the drift is structurally impossible rather than merely
tested.

The README's route example uses `Batch::MEDIA_TYPE` instead of repeating
the literal a third time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The library requires PHP >= 8.5 but tested with PHPUnit 9.6, which went
end of life in early 2024 — four majors behind. A brand-new library on a
brand-new PHP version has the longest runway to be broken by a point
release, and the least chance of a fix arriving for a test framework
nobody supports any more.

The migration also only ever gets more expensive: `phpunit.xml` used
PHPUnit-9-only attributes (`convertErrorsToExceptions`,
`backupStaticAttributes`) and the tests carried metadata in doc comments
(`@dataProvider`), which PHPUnit 10 dropped. Every test added before the
move would have added to it. Doing it at the initial PR is as cheap as it
will ever be.

All 13 `@dataProvider` annotations became `#[DataProvider]` attributes —
the providers were already `static`, so nothing else had to change — and
`phpunit.xml` was regenerated via `--migrate-configuration`, then
reformatted back to the file's own layout and given the schema reference
so editors can validate it. `.phpunit.cache/`, the result cache's new
home, is ignored.

Test and assertion counts are unchanged across every suite, which is what
says the data providers still feed: a provider PHPUnit no longer sees does
not error, it just silently runs the test once with no arguments.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Server::poll()` clamps with `max(0, min($timeout, MAX_TIMEOUT))` and
`serve()` coerces the query parameter before forwarding it. Neither had a
test. Every non-zero timeout in the suite went through `poll()` directly,
and every `serve()` test passed `'0'` or garbage, so a refactor that
dropped the third argument entirely would have left the whole suite green
while long polling over HTTP silently became a plain read — invisible in
what comes back, since a caught-up read answers with the same empty batch
either way.

The clamp itself is the security-relevant one: `timeout` arrives from an
untrusted HTTP client, so a regression means `?timeout=86400000` holds a
worker for as long as the caller asks. The one existing `MAX_TIMEOUT`
assertion covers the *consumer's* clamp on its own configuration, which is
a different clamp against a different caller.

`RecordingStore` captures what the server asked for, since that is the
only place clamping is observable, and `ServerTest` pins both parameters
across the range: forwarded as given, capped at the protocol maximum,
floored (negative timeout to 0, limit to 1), and falling back on garbage —
through `serve()` and through `poll()`. A position is asserted to arrive
untouched, so the clamping is all `serve()` does.

The behavioural half runs per adapter in `Server/Base`: `serve()` with a
600ms timeout on an empty feed must actually wait. Only a lower bound, so
it measures the code rather than the CI machine.

Confirmed all three fail when `serve()` stops forwarding the timeout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The README promises `Exception\Transport` when "the backend or network
failed: Redis errors, HTTP failures". The HTTP half was tested thoroughly;
the Redis half — the flagship production adapter — was not tested at all.
`grep RedisException tests/` found nothing, and `FailingCursor` only shows
how a consumer reacts to a `Transport` it raises itself, so the actual
`\RedisException` → `Transport` wrapping in `Store\Redis::append/tip/read`
and `Cursor\Redis::load/save/reset` never ran under test. A regression
letting the raw exception out would have shipped green and crashed every
consumer catching `Utopia\Feed\Exception` as documented.

All six operations are now covered, plus the consumer on top of them.
Confirmed by widening the catch so the exception escapes: seven failures,
all "RedisException ... does not match Transport".

The review suggested closing a connected client, which does not work here
— phpredis 6.3 reconnects transparently on the next command, so every call
succeeds. A client that was never connected raises `\RedisException` from
the extension for every command instead, which is both deterministic and a
real misconfiguration rather than a state only a test can reach.

Two defensive branches the review listed as dead are now reachable too,
and the pair documents a policy rather than an accident. A foreign value
under the feed's key makes Redis answer every stream command with an
error, returned rather than raised: `append()` must not report a position
for an event that is not in the feed, so it raises `Transport`, while
`read()` and `tip()` treat an unreadable feed as an empty one — a replay
at worst, where failing would stall every consumer. That is the same
policy the cache store already applies to a foreign value under its key.

`Store\Cache::append`'s false branch, the third one listed, was covered
when the cache adapters' wrapping landed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`phpunit.xml` had no include list, so `--coverage-text` could not produce a
meaningful report even if run: there was no way to measure coverage on this
branch, let alone track it. Every gap the review found — the untested
server timeout clamp, the dead Redis error branches, the missing
`datacontenttype` round trip — was invisible, and uncovered code in future
PRs would land with nothing to notice it.

`phpunit.xml` now scopes coverage to `src/`, so a report says what the
suite exercises of the library rather than of its own fixtures. The test
image carries pcov — line coverage only, which is all a report needs, at a
fraction of Xdebug's cost — loaded but switched off, so every ordinary run
is unaffected and only `composer coverage` turns it on.

Two scripts, because the interesting number and the fast one differ:
`coverage` runs the service-free suites (~8s), `coverage:all` adds Redis
and pool. CI runs the latter in its own job: the Redis and pooled adapters
are exactly the ones whose error paths are easiest to leave untested, so a
number that excluded them would flatter the suite.

Where it lands today: 97.56% of lines across all suites, 84.11% from the
service-free ones alone. No threshold — the number is there to be read,
and a badly chosen gate is worse than none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`grep datacontenttype tests/` used to find nothing, which is exactly why
the store round trip could drop it silently: the producer suite carefully
round-tripped `subject`, `dataschema`, `time`, extensions and even
digits-only extension names, and skipped this one. The bug and the test
gap were the same finding from two sides, and fixing one without the other
invites the regression straight back.

The producer-side round trip landed with that fix. This closes the other
two places the review named. `testRoundTripsAnEvent` now asserts the
attribute `produce()` sets, so the everyday path carries it rather than
only the deliberate `publish()` case.

And the `Remote` decode path, which was untested despite
`datacontenttype` being in its `ATTRIBUTES` list and read by `optional()`:
one event on the wire carrying every context attribute this library models
plus an extension, each asserted after decoding. `RemoteTest` previously
checked `id` and `data` and nothing else, so a decode that quietly dropped
`subject`, `time`, `dataschema` or `datacontenttype` would have passed.
A second case pins the absence: a feed that sends no `datacontenttype`
decodes without one, rather than having a value invented that the producer
never claimed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Several tests asserted an upper bound on elapsed time just above what the
correct code takes, which measures the CI runner rather than the code.
`testPollHonoursATimeoutShorterThanThePollInterval` allowed 300ms for a
100ms timeout and runs in four adapter suites — in `redis` and `pool` each
poll tick is a round trip into a container, on a shared GitHub runner. Two
hundred milliseconds of headroom is one noisy neighbour away from red, and
that failure mode is the worst kind: rare, unreproducible locally, and it
teaches everyone to hit re-run, which is how real regressions get waved
through.

Each bound is now derived from the elapsed time the regression it guards
would produce. Overshooting the deadline means sleeping a full 500ms
interval, and ignoring a configured interval means falling back to the
500ms default, so 0.45 catches both while leaving 350–400ms of slack over
the ~20–100ms the correct code takes. Same for the no-timeout read, where
sleeping at all costs a whole interval. The reasoning is written next to
each number so the next person changing one knows what it is protecting.

Confirmed by reintroducing both bugs: each fails exactly the test that
exists for it, at ~0.50s against the 0.45 bound.

Lower bounds are untouched — a sleep cannot finish early, so they cannot
flake. `testDelegatesLongPollingToTheProducer` keeps its clock check with
a note that the request count next to it is the assertion actually doing
the work.

Not changed: the deliberate 600ms waits multiplied across five adapter
suites. That is run time rather than flakiness, and removing it means a
clock abstraction through `Store::poll()` — a change to production code to
suit the tests, which is a trade worth making deliberately rather than in
passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Remote::event()` silently dropped any extension the spec cannot carry —
a name outside `[a-z0-9]+`, a value that is not bool/int/string — and
none of it was tested. One well-formed extension riding along was
covered; what happened to a float, an array, or an uppercase name was
nobody's decision, just whatever the code did.

The two decode paths also disagreed, and neither side of the disagreement
was covered, so nothing would have gone red when it mattered. Over HTTP a
foreign `"ratio": 1.5` was filtered out and the event delivered. Read from
a local store, the same event was merged verbatim into
`CloudEvent::fromArray()`, which rejects it — and since a store read
decodes every entry in the batch, that entry failed every read past it,
permanently, for every consumer, until it fell off the trim horizon. The
worst failure shape a feed has, arrived at by accident.

Both now go through one `Extensions::filter()`. Filtering, rather than
raising, is the version somebody would have chosen: a feed is read by
consumers older than its producer by design, and one odd attribute must
not cost the event and everything behind it. This is not hypothetical for
a local store either — a Redis stream is writable by any tool, and this
library's producer cannot be assumed the only writer, which is how the new
Redis test stages it.

Tests name each shape the filter drops, so dropping stays a choice: float,
array, object and null values; uppercase, dashed and underscored names.
And each shape it keeps, including a digits-only name.

Fixing that last one turned up the same hazard inside the suite:
`RemoteTest::raw()` built fixtures with `array_merge()`, which renumbers
integer keys, so a digits-only extension was lost in the fixture before
the code under test ever saw it. It unions now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`testRetentionTrimsToExactlyTheCap` and
`testAcceptsTheSmallestUsefulRetentionCap` were copy-pasted line for line
into `Producer/MemoryTest` and `Producer/CacheTest`, against the suite's
own design principle: one abstract scenario suite per component, extended
by every adapter. Two copies drift — tighten one and the other silently
stays weak — and a future adapter that trims exactly inherits nothing,
having to opt in by copying again.

Both now live in `Producer/Base` behind a `trimsExactly()` hook. The
property is the default, since it is what every adapter does except the
two backed by Redis, where `XADD ... MAXLEN ~` trims to a node boundary —
which is the whole reason the shared scenario only asserts the loose
bound. `Producer/RedisTest` and `Producer/PoolTest` declare `false` and
skip.

Confirmed by adapter: Memory and Cache run both, Redis and Pool report
them skipped rather than passing vacuously.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`FeedServer` read only the query string. The request path — which carries
the feed name `Remote` so carefully `rawurlencode()`s — was discarded, so
the fixture answered any name with the one feed it held. A consumer
pointed at feed `other` was served `edge` and read the right events by
accident; the only name check in the suite was client-side, in `Consumer`
rejecting a contradicting `feed:` argument. For a suite whose docblock
claims it exercises "the whole contract", routing was the missing half.

The endpoint now compares the decoded last path segment against the feed
it serves and answers 404 otherwise, and three tests cover what that
makes checkable:

- A consumer pointed at another feed gets `Transport` with status 404 and
  records no position. Confirmed to fail without the routing.
- A name needing encoding round-trips: `a b/c` is encoded into one path
  segment and decoded back to the name the producer knows. The existing
  encoding test asserts the URI string, which cannot show that anything
  decodes it.
- The `Accept` the consumer sends is the `Content-Type` the producer
  answers with, checked against each other rather than each against a
  literal — the loop left open when the media type was declared once on
  `Readable`. `Recorder` captures the served content type for it.

Splitting the path before decoding is deliberate and commented: a feed
called `a/b` travels as `a%2Fb`, and decoding first would split it into a
path nobody asked for. So is tolerating a path with no slash at all — a
consumer built over a client with no base URI sends the bare name, which
is exactly how this suite builds one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Consumer/Base::setUp()` always builds the feed as a `MemoryStore`; the
adapter subclasses swap only the cursor. So `Consumer/RedisTest` ran a
Redis *cursor* against a *memory* feed, and after the test rework nothing
anywhere had a `Consumer` read a real Redis stream. The consumer's paging
arithmetic — `Id::after`, strictly-after reads, the batch loop — was
exercised only against ids this library mints itself, never against the
ones `XADD` assigns or the approximate trimming `XRANGE` reads back.

Added to `Consumer/RedisTest` rather than by deriving `Consumer/Base`'s
store from the trait, which keeps the change local: the shared scenarios
still run against the simplest store there is, so a failure in them is
still the consuming side's rather than a store's.

Four scenarios, three of them lost with the old `E2E/RedisTest`: consume
and resume through a persisted cursor without replaying, two named
consumers over one stream, and reset replaying what the stream retains.
The fourth is new and is the one the gap was really about — paging a
backlog in batches of three. `XADD` assigns ids within a millisecond by
bumping the sequence, so a batch boundary regularly falls between two ids
sharing a timestamp, which is exactly where paging by string comparison
goes wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Store\Cache` was exercised by the producer suite (store-level reads) and
the server suite, but never by a `Consumer`: `Consumer/CacheTest` swapped
only the cursor, so the feed it consumed was still a `MemoryStore`. The
integration the adapter exists for — a service that already carries a
cache keeping both the feed and the position there — was the one thing not
covered.

Added to `Consumer/CacheTest` rather than by deriving `Consumer/Base`'s
store from the trait, matching the Redis change and keeping the shared
scenarios pointed at the simplest store there is.

The drain-and-restart case is the one lost with the old `StoreCacheTest`.
The other two are for how this adapter reads differently from the rest: a
caught-up poll tick answers from the tip marker instead of loading the
feed, so a test has to prove "caught up" still means caught up and not
"nothing more, ever" — and that paging a backlog in batches works over an
adapter that scans the whole feed for each page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing checked `subject`, `source`, `time`, `dataschema` or extensions
after an HTTP round trip. `Consumer/Base::drain()` records only `type`,
`RemoteTest::testReadsAFeedOverHttp` asserted `id` and `data`, and
`BatchTest::testToArrayIsTheWireEncoding` asserted `id` and
`specversion`. The producer and server suites do round-trip those
attributes, but through the *store* — so an encoder or decoder that
dropped one on the wire specifically would pass the whole suite, and the
wire is the one place a dropped attribute cannot be recovered from.

`Consumer/HttpTest` now pushes one fully populated event through the real
code in both directions — `Batch` encoding it in the endpoint, `Remote`
decoding it on the way back — and asserts every attribute on the far side,
including a nested payload and all three extension value types. That
restores what the old `RoundTripTest` covered, and more.

`BatchTest` gains the encode side in isolation: one event asserted as a
whole array rather than attribute by attribute, so an attribute that
stopped being encoded fails rather than going unnoticed. Plus the property
that makes the wire format readable — an absent optional attribute is
omitted, not sent as null, since the spec has no null attribute values and
a consumer has to tell "not set" from "set to nothing".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The reworked `CursorTest` kept only the `Cursor\None` cases. Most of what
the old parameterized suite covered is now exercised indirectly —
`Consumer/Base` runs against every cursor adapter and asserts on
`load()` throughout — but two things are not reachable from there at all.

`Consumer::__construct()` validates its own name before the cursor is ever
touched, so the adapters' `Invalid` path had no test outside `None`. And
nothing checked that each adapter routes `load`, `save` and `reset`
through the shared `Cursor::key()` rather than building a key of its own —
an adapter that did would skip the validation and drift from the
documented `feed:<feed>:cursor:<consumer>` layout at the same time, and
only Redis has a layout test.

`tests/Feed/Cursor/Base.php` follows the same shape as the other suites,
subclassed for Memory, Cache, Redis and Pool, each joining its adapter's
existing CI job. It covers the direct API the consumer scenarios reach
only sideways: unknown consumer, save/load, overwrite, isolation by
consumer and by feed, reset, reset of a position that was never saved, and
reset touching only the consumer it names.

The key assertion is the last one — all three operations against all three
unusable name combinations, nine cases per adapter. Confirmed load-bearing
by making `Cursor\Memory` build its own key: all nine fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The file had two `## 0.1.0` sections, the top one documenting breaking
changes over the bottom one, which was labelled "Initial release". One
version cannot both be the initial release and break it.

The top section also documented APIs that do not exist. `Start::Tip` is
`Consumer::START_TIP`, a string constant rather than an enum.
`Protocol::MAX_BATCH`/`MAX_TIMEOUT` live on `Readable`. `Protocol::encode()`,
`Protocol::decode()` and `Protocol::MEDIA_TYPE` have no class at all — those
responsibilities are `Batch`'s and `Remote`'s — so the note about a route
never needing to name `Protocol` named something a route could not have
named anyway.

And since this is the first release, migrations between versions nobody
has ever run are not changelog material. Journal → Store and
Protocol → Readable/Batch/Remote are design history, which is what git
history is for; the reasoning that survived is already in the README.

Collapsed into a single `## 0.1.0 — Initial release` describing what
actually ships, using the real names, and written after the rest of this
branch landed so it documents the API as released rather than as planned —
including the seek relaxation, the media type on `Readable`, the cache
store's tip marker and its smaller retention default.

Every class and constant named here was checked against `src/`, as was
every number: 1000, 30s, 500ms, 1 000 and 100 000 entries, 30 days.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The commits from 58cf7ac to 20f0d7c explained themselves at length: several
comments argued the case for a decision over three or four sentences where
one would do, and a few restated what the line below already said.

Every comment added across that range is now either one line, a short
paragraph, or gone. What survives is the part a reader cannot get from the
code — why `purge()`'s false is not checked, why the tip marker is written
before the feed, why the key builder escapes rather than rejects, why a
timing bound is the number it is. The rationale that only mattered while
the change was being made lives in the commit messages, which is where it
belongs.

Net −322 lines, no behaviour change: all 669 tests, PHPStan and Pint stay
green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread src/Feed/Cursor/Redis.php Outdated
@Meldiron
Meldiron merged commit a22ecd4 into main Aug 3, 2026
10 checks passed
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.

1 participant