Minor release. New public API for stopping ordered consumers and watches, plus the outcome of three full review rounds against the NATS.io specification that closed 59 findings, most of them cases where a message could be lost or a consumer could stall silently.
Upgrading is recommended for anyone using KV or Object Store watches, ordered consumers, pull consumers, WebSocket transport, or drain().
Added
[feature]JetStreamContext::stopOrderedConsumer(int $sid)stops an ordered consumer, or a KV / Object Store watch, even after automatic recreates rotated its internal subscription id, and deletes the server-side ephemeral consumer instead of leaving it to expire. A plainunsubscribe($sid)only ever worked until the first recreate, so this is now the documented way to stop any of them.[feature]KeyValueBucket::bind()resolves a mirrored bucket's read and write prefixes fromSTREAM.INFO. It is required on any handle that did not itself runcreate(), including a freshkeyValue()handle in the same process, before reads and write-through behave correctly.[feature]NatsHeaders::get()looks a header up case-insensitively, preferring an exact-case match. Publishers differ in how they canonicalize header names, so an exact-case array lookup could silently miss a header that was in fact present.[feature]ObjectStoreBucket::watch()gained anexactNameparameter for watching an object whose own name contains*or>, andObjectStoreWatchOptionsgained anidleHeartbeatargument.[feature]subscribeOrderedConsumer()gainedconsumerOverridesandonConsumerCreated.
Fixed, the highlights
Messages that could be lost, and flows that could stall:
- KV and Object Store watches are lossless. Both now ride the ordered-consumer machinery, so they detect sequence gaps and replay from the last seen revision instead of turning a slow-consumer drop or a reconnect window into a silent permanent hole. The Object Store watch previously requested no idle heartbeat at all and could hang forever with no signal.
- A watchdog-triggered recreate that collided with a reconnect used to leave the watchdog latched, stalling the consumer or watch permanently and silently. It now retries with a fresh budget once the connection is open again.
- The pipelined pull engine no longer treats a transient 503 (no JetStream API responder, which happens during a server restart or a leader election) as terminal. Without an
onErrorhandler the worker used to stop for good whilehandle()resolved normally, so it looked like a clean drain while messages piled up. fetchBatch()anddirectGetBatch()reply inboxes are exempt from the slow-consumer drop, so a burst of small replies no longer silently loses the head of a fetch on amax_deliver: 1consumer.- KV buckets created with
sourcesnow attach the ADR-57 subject transforms, so sourced entries land under this bucket's prefix where reads can actually see them. Mirror buckets write through to the origin instead of publishing into a stream that ingests nothing. - Pipelined Object Store uploads verify that chunk acknowledgements arrive in order, so a 503 retry cannot reorder chunks and store an object whose bytes no longer match its digest. Failed uploads purge their partial chunks.
- Caller-owned push consumers perform the ADR-9 heartbeat gap check, including detecting a consumer replaced server-side, and report the mismatch through the error listener and the logger.
- A permission-rejected pull reply inbox now fails fast, naming the wildcard permission to grant, instead of polling forever with no signal.
drain()andflush()can no longer hang forever against a stalled peer. Drain writes are bounded by the drain budget, handler publishes issued during a drain share that same budget, and drain always reaches theClosedstate.
Protocol and specification correctness:
- The WebSocket transport enforces RFC 6455 and RFC 7692 strictly: masked server frames, fragmented or oversized control frames,
RSV1without negotiated compression, and handshakes missing theUpgradeheaders or echoing an unsolicited extension all fail the connection. A fragmented PING used to splice its continuation into an in-progress data message, which corrupted the payload silently. Control-frame answers are now held as data slots, so a coalesced ping flood collapses to one pong for the newest ping and the mandatory Close echo always goes out. - Header values are written to the wire verbatim. The previous trim mutated values carrying a signature or checksum.
$SRVdiscovery responses serialize emptymetadataas{}rather than[], which Go-based tooling rejected outright, making a metadata-less service invisible tonats micro ls.- Object Store
addLink()matches nats.go's guard,watch()encodes exact names, andlist()falls back to a leader read on buckets without direct get. - A micro-service request whose handler errored and whose error reply then also failed counted two errors, so
$SRV.STATScould report more errors than requests.
Contract changes worth reading before upgrading
These correct clear bugs, so they are flagged as fixes rather than breaks, but they change what a caller observes:
drain()no longer throws when a write fails against a dead socket. It reports the failure through the error listener and still closes cleanly. Previously it threw and left the connection stranded mid-drain.WebSocketFrameCodec::decode()no longer throws on a strictness violation. It returns the frames parsed before the violation and reports the violation through a new by-reference$terminalparameter, so already-decoded data is never discarded. It also rejects masked frames unless you passallowMasked: true.WebSocketFrameCodec::unmask()is deprecated in favour of that parameter.- KV source and mirror names: the
bucketalias is alwaysKV_-prefixed, so a bucket literally namedKV_xnow resolves to its own stream rather than to bucketx. An explicitname, or a bare string entry, keeps the nats.go convention of being used as-is when it already starts withKV_. - A mirrored KV bucket handle that did not run
create()must callbind()before its reads and writes route correctly.
Quality gates
PHPStan level 8, 2025 unit tests, 138 live integration tests, 47 Behat scenarios, 45 runnable examples executed against a live server, ~99.4% combined statement coverage (97% floor enforced in CI) and ~94% Infection covered MSI (90% floor enforced in CI).
The full per-finding record of the three review rounds, including the ones that turned out to be regressions introduced by an earlier round's fix, is in the issues/ directory.