Skip to content

v2.8.0

Latest

Choose a tag to compare

@bpacholek bpacholek released this 08 Aug 12:05

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 plain unsubscribe($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 from STREAM.INFO. It is required on any handle that did not itself run create(), including a fresh keyValue() 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 an exactName parameter for watching an object whose own name contains * or >, and ObjectStoreWatchOptions gained an idleHeartbeat argument.
  • [feature] subscribeOrderedConsumer() gained consumerOverrides and onConsumerCreated.

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 onError handler the worker used to stop for good while handle() resolved normally, so it looked like a clean drain while messages piled up.
  • fetchBatch() and directGetBatch() 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 a max_deliver: 1 consumer.
  • KV buckets created with sources now 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() and flush() 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 the Closed state.

Protocol and specification correctness:

  • The WebSocket transport enforces RFC 6455 and RFC 7692 strictly: masked server frames, fragmented or oversized control frames, RSV1 without negotiated compression, and handshakes missing the Upgrade headers 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.
  • $SRV discovery responses serialize empty metadata as {} rather than [], which Go-based tooling rejected outright, making a metadata-less service invisible to nats micro ls.
  • Object Store addLink() matches nats.go's guard, watch() encodes exact names, and list() 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.STATS could 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 $terminal parameter, so already-decoded data is never discarded. It also rejects masked frames unless you pass allowMasked: true. WebSocketFrameCodec::unmask() is deprecated in favour of that parameter.
  • KV source and mirror names: the bucket alias is always KV_-prefixed, so a bucket literally named KV_x now resolves to its own stream rather than to bucket x. An explicit name, or a bare string entry, keeps the nats.go convention of being used as-is when it already starts with KV_.
  • A mirrored KV bucket handle that did not run create() must call bind() 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.