Repository navigation
magnetar 1.7.2
Fixed
-
Consumer::resubscribe()now closes the consumer before re-subscribing it, instead of handing the broker a second permit window it never agreed to.
The in-place recovery re-emittedCommandSubscribefor the SAME, still-live consumer id, on the premise its own documentation stated: "the broker recreates its dispatcher slot atavailablePermits = 0".
Apache Pulsar does not.
ServerCnx.handleSubscribelooks the consumer id up in its own per-connectionconsumersmap before anything else, and aconsumers.putIfAbsenthit on an already-completed consumer logs a warning, callssendSuccessResponse(requestId), and returns — no newConsumer, no dispatcher or subscription touch, and nothing on that branch readsMESSAGE_PERMITS,availablePermitsorflowPermits.
Verified identical at v4.0.4ServerCnx.java:1320-1326, v4.2.4:1408-1414and master:2037-2044; the sibling branches are deliberately not no-ops (a still-pending creation is answeredServiceNotReady, an exceptionally-completed one a mapped error), and neitherSubTypenor durability is read there at all.
So the client zeroed its permit mirrors against a broker state that never happened, the broker kept its slot and its balance, and the re-subscribeSuccessthen granted a full freshreceiver_queue_sizewindow on top of what the broker already held — once per recovery attempt, while the wedge the call exists to clear could not move.
Measured on the differential harness with a faithful live-id broker model: one recovery took an 8-message receiver queue's broker-observed balance from8to16.
Issue #414's production signature is a subscription permit counter at-177300, and a recovery that adds permits the broker never agreed to is moving that same counter, in the opposite direction, on the client's own initiative.
The recovery is now two phases on the same live socket, with no transport reconnect: aCommandCloseConsumerfor this consumer id, and then — on that close'sSuccessonly — the permit-mirror zeroing, the issue #346 in-flight-ack sweep, and theCommandSubscribewhose ownSuccessreleases the initialCommandFlow.
Every mutation moved to the second phase, so a broker that rejects the close leaves the consumer byte-for-byte as the caller found it: still wedged, but still holding the positivepermit_balancethatis_stall_candidaterequires, so it stays visible to the watchdog and eligible for another attempt.
Zeroing up front and then failing to close would have produced a consumer that is wedged AND undetectable.
Failoverand non-durable subscriptions are now refused outright, returningErrwithout emitting anything: the close is real, so for aFailoverconsumer it triggers an election and a priority-order reshuffle to repair one slot, and for a non-durable one it discards the cursor that lives only as long as the consumer, making the re-attach restart from the configured start position (ADR-0099).
Both refusals sit inresubscribe_consumer_in_placeand not in the eligibility predicate shared with issue #307's broker-initiatedCommandCloseConsumerarm, where the broker has already closed the consumer and refusing would only leave it detached forever.
Three consequences are worth planning for.
A recovery now costs one extra broker round trip before the grant re-arms.
It also costs real redelivery: the broker genuinely drops the consumer, so everything it held un-acked returns to the subscription's redelivery pool, and acks the grouping tracker emits inside the close-to-re-subscribe window are dropped and their messages redelivered — at-least-once is preserved, duplicates are not.
Andconsumer_stall_auto_recoveryno longer pays down a dispatcher-wide leak: ADR-0103's "one attempt credits the aggregate back exactlyreceiver_queue_size" was true only because of the over-commit removed here, so one attempt is now permit-neutral on the subscription's aggregate andpulsar-admin topics unloadremains the escalation for a negative counter.
A connection lost between the two phases is won by the reconnect path —resetandfail_all_pendingdrop the pending close without leaking an outcome, andrebuild_consumersowns the re-attach — and a racing issue #307 broker close leaves exactly oneCommandSubscribe, not two.
(issue #414; ADR-0108, amending ADR-0101 §3's step list and itsavailablePermits = 0premise and superseding ADR-0103's budget arithmetic) -
A dropped
ack()future no longer leaks a permanentoutcomesentry — same issue #241 leak shape as the producer-close fix, now on the ack bookkeeping.
Both runtime engines'RequestFut::dropclear the parked waker and take any outcome that already landed, but neither callscancel_request:magnetar-runtime-moonpool/src/consumer.rsandmagnetar-runtime-tokio/src/consumer.rsboth stop atunregister_waker+take_outcome.
So a caller that dropped itsack()future before it resolved — aselect!timeout, a cancelled task — left thepending_requestsentry behind with no waker, which is exactly the state three sweeps incrates/magnetar-proto/src/conn.rsthen hit:fail_acks_orphaned_by_consumer_reattach(the same-brokerCommandCloseConsumersweep, issue #346), theack_response_timeoutbackstop reap inhandle_timeout, and theAckResponsecommand arm.
All three recorded anOpOutcomein the connection'soutcomesmap unconditionally, mirroring neither theSuccessarm'sunsubscribe_has_waiterguard nor itsProducerCloseForgotten/ConsumerCloseForgottenfire-and-forget handling, both added for issue #241's continuous-eviction leak.
Nothing ever prunesoutcomeswholesale — entries leave only viatake_outcomeorcancel_request— so every dropped ack whose resolution reached one of these three sites left one permanent entry.
The two sweep sites now record an outcome only whenself.wakersstill holds a waker for the request, mirroringunsubscribe_has_waiter; theAckResponsearm now records one only whenpending_requests.removeactually found an entry, mirroring theSuccessarm'sNone => {}case — a late broker reply for an ack an earlier sweep already resolved no longer overwritesoutcomeswith a second, undrainable entry.
pending_requests.remove, thetotal_acks_failedaccounting, and the emittedConnectionEvent::AckResponsestay unconditional in all three sites; only theoutcomeswrite and its paired wake are guarded.
A caller that still holds itsack()future sees no change: the guard only ever skips a record nothing is left to consume.
(issue #346-adjacent, same defect class as issue #241; not issue #414 — noresubscribe_consumer_in_placecall is involved) -
A dead-lettered dispatch unit now returns its flow permit, so a poison-heavy
Sharedsubscription no longer wedges atavailablePermits=0.
The broker charges one permit per dispatch unit —Consumer#sendMessagesdebitsackedCount - totalMessages— and a unit the client will route to its dead-letter buffer is insidetotalMessages, because the broker has no idea what the client intends to do with the entry.
ConsumerState::classify_and_queuemirrored that debit correctly:record_dispatch_unit()runs unconditionally, before the queued-versus-dead-lettered branch.
What it never did was hand the permit back.
consumed_since_flow— the countermaybe_flowcompares againstmax(receiver_queue_size / 2, 1)— was credited only bypop_message, the incomplete-chunk buffer, and the PIP-33 marker filter, and a dead-lettered unit is never queued, so it is never popped.
No later path compensated either:drain_dead_letteris astd::mem::take,Connection::acknever touches the flow ledger, andrepublish_dead_lettersis drain, send, ack.
So each dead-lettered unit was a net-1permit, drifting one way by exactly the dead-letter count until a churn boundary zeroed both mirrors.
A receiver queue's worth of poison drovepermit_balanceto zero withconsumed_since_flowstill at zero, and nothing could recover it:maybe_flowwas unreachable with nothing left to pop,is_stall_candidaterequirespermit_balance > 0so the issue #414 watchdog and ADR-0103's automatic recovery never fired, andis_flow_starvedis read only by the Failover promotion re-arm andConnection::initial_flow— churn and election boundaries aSharedsubscription never reaches on its own.
The broker showedavailablePermits=0andmsgRateOut=0with no error and no reconnect, andConsumer::available_permits()read0.
The dead-letter branch now callsrecord_broker_permit_consumed()before it buffers the message, which is the rule the other three refund sites already followed and the one Java follows in both dispatch shapes:ConsumerImpl.messageReceivedcallsincreaseAvailablePermits(cnx)immediately after it skips an over-redelivered message, andreceiveIndividualMessagesFromBatchaccumulatesskippedMessagesand callsincreaseAvailablePermits(cnx, skippedMessages)once per entry.
There is no new emission site:conn.rs'sMessagearm already callsmaybe_flow()after everydeliver, so the inbound frame that crosses the half-queue threshold carries theCommandFlowout, batched entries whose members all dead-letter included.
The invariant is now uniform —granted window == permit_balance + consumed_since_flow + queue.len()— withDeliveredrefunded at pop,Bufferedrefunded at buffering,Droppedmoving neither side, and an ADR-0105 ack-set-cleared position moving neither side because the broker never charged it.
Two consequences are worth planning for.
The accidental backpressure is gone, so a poison-heavy topic keeps dispatching anddead_letter_pending— an unboundedVec— grows until the application callsdrain_dead_letterorrepublish_dead_letters; nothing auto-drains it.
And permits ALONE no longer wedge the consumer: a dead-lettered unit stays unacked at the broker untilrepublish_dead_lettersacks it, so an application that never drains still stops at the broker'smaxUnackedMessagesPerConsumer.
On upgrade, a subscription that had parked atavailablePermits=0resumes draining and its DLQ topic may receive a burst.
(issue #437; ADR-0107, amending ADR-0082's "pop-drivenconsumed_since_flow" parenthetical and ADR-0105's "consumed_since_flowonly moves onpop_message" clause) -
A
Failoverconsumer whose permit balance was drained to zero without crossing the flow threshold is re-armed on promotion.
ConsumerState::is_flow_starvedistruewhen the broker has dispatched every granted permit and even popping the whole remaining queue could not pushconsumed_since_flowacross the half-queue threshold — somaybe_flowis unreachable and both sides wait on each other forever.
It is now read by bothinitial_flowgates: the issue #307CommandActiveConsumerChange { is_active: true }promotion re-arm andConnection::initial_flowitself, whose additivegranted_permits == 0gate a previously-fed consumer never satisfies again outside a churn boundary.
A fed consumer with permits still in flight gets no grant, so the issue #427 no-double-grant contract is unchanged.
This was shipped before the issue #437 refund above and is retro-documented here; with the refund in place the predicate is no longer reachable through well-formed wire frames and remains as defence-in-depth for a broker-side debit the client mirror missed.
(issue #443, PR #444; recorded in ADR-0107) -
A broker
CommandCloseProduceron a connection that stays up no longer leaves that producer detached until the process restarts.
pulsar-admin topics unloadof one partition of a partitioned topic makes the owning broker remove the producer and write the frame on a connection that keeps serving every other producer and consumer on it — no TCP drop, so no supervised reconnect and norebuild_producers.
TheType::CloseProducerarm only shut the send-drain gate and pushed aProducerClosedByBrokerevent that nothing reads for an already-assembled producer, so the slot sat atclosed = false, broker_ready = false, open_request_id = Nonefor the life of the connection and every publish the round-robin router sent to that partition failedcode=-1 send timeoutforever.
The producer is now re-attached in place on the same connection with a bumpedepoch, exactly as theCommandCloseConsumertwin has done since issue #307, and the gate stays shut until the broker's freshCommandProducerSuccessreplays the staged publishes.
AServiceNotReadyanswer while the bundle reloads — the expected one, since the broker fences the topic before writing the close — rides the existing operation-retry leg (2 s initial backoff plus a lookup) and gives up with a terminal error after the configured budget instead of hanging.
Anassigned_broker_service_urlon the close takes the same in-place path: no path owned that event shape on a live socket, and an Extensible-Load-Manager multi-phase unload makes it the default close shape.
Awarn!now records every such close with the handle, topic, request id, epoch and truncated assigned URL.
ProducerClosedByBrokeris surfaced only while a producer open is still in flight, where its parked waiter is the one reader that can consume it; a closed or unknown handle pushes nothing.
(issue #451; ADR-0106) -
magnetarctlno longer rejects the-1sentinel when it is passed space-separated on the policy-setting commands.
By default clap reads a token beginning with-as a flag, and none of these arguments carried one of the opt-ins that relax that, so--time-minutes -1was read as an unknown short flag andmagnetarctl admin namespaces set-retention acme/ns --time-minutes -1 --size-mb -1failed witherror: unexpected argument '-1' found— even though-1is the documented "infinite / unlimited" value on every one of those flags.
That error also carried clap's owntip: to pass '-1' as a value, use '-- -1', which is wrong here:--ends option parsing rather than escaping the next token, so following the tip returned the sameunexpected argument '-1' foundwith the tip itself removed.
--flag=-1was the only spelling that worked, and it is not the spelling the help text or the docs suggest.
Twenty of the affected arguments declaredefault_value_t = -1, so their own default was a value the parser refused when an operator typed it by hand.
allow_negative_numbers = trueis now set on all 24 of them, coveringset-retention,set-backlog-quota,set-dispatch-rate,set-subscription-dispatch-rate,set-replicator-dispatch-rateandset-publish-rateunder bothadmin namespacesandadmin topics.
The opt-in gates on the shape of the token rather than on the leading hyphen alone — unlike the broaderallow_hyphen_values— so a malformed--time-minutes -1abcis still rejected and the=form keeps working unchanged.