Skip to main content

magnetar_proto/
txn.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Transactional client state machine (PIP-31).
4//!
5//! Pulsar's transactional API (`TC` = Transaction Coordinator) lets a single client publish to
6//! multiple partitions and acknowledge messages on multiple subscriptions atomically. The wire
7//! protocol introduces five RPC pairs in `BaseCommand` (raw integers `50` … `61`):
8//!
9//! | Outgoing                                | Incoming                                          | Purpose                                          |
10//! |-----------------------------------------|---------------------------------------------------|--------------------------------------------------|
11//! | [`pb::CommandNewTxn`]                   | [`pb::CommandNewTxnResponse`]                     | Allocate a new transaction id at the TC.         |
12//! | [`pb::CommandAddPartitionToTxn`]        | [`pb::CommandAddPartitionToTxnResponse`]          | Register a topic-partition the txn will write.   |
13//! | [`pb::CommandAddSubscriptionToTxn`]     | [`pb::CommandAddSubscriptionToTxnResponse`]       | Register a subscription the txn will ack.        |
14//! | [`pb::CommandEndTxn`]                   | [`pb::CommandEndTxnResponse`]                     | Commit or abort the transaction.                 |
15//! | [`pb::CommandEndTxnOnPartition`] / `…OnSubscription` | matching responses                            | Broker-fanned-out commit / abort (out of scope). |
16//!
17//! The state machine lives in [`TxnClient`]. It is **sans-io and channel-free**: every request
18//! returns a [`pb::BaseCommand`] (or more precisely the inner protobuf message — the connection
19//! wraps it) that the caller is expected to wire onto the connection's outbound buffer; every
20//! response is consumed via the matching `handle_…_response` method which transitions the
21//! [`TransactionMetadata`] and returns the user-facing outcome. Waker slabs let user futures
22//! observe completion without involving channels (see [GUIDELINES.md]
23//! §"No-channels rule"). Mirrors `TransactionImpl.java` in the Java client.
24//!
25//! # State diagram
26//!
27//! ```text
28//!                 ┌─────────┐ end_txn(Commit)      ┌────────────┐ Success ┌────────────┐
29//!                 │  Open   │ ───────────────────▶ │ Committing │ ──────▶ │ Committed  │
30//!                 └─────────┘                      └────────────┘         └────────────┘
31//!                       │  end_txn(Abort)             ┌────────────┐ Success ┌────────────┐
32//!                       └────────────────────────────▶│  Aborting  │ ──────▶ │  Aborted   │
33//!                                                     └────────────┘         └────────────┘
34//!                                                              │
35//!                                                  Broker error│ on commit/abort
36//!                                                              ▼
37//!                                                       ┌────────────┐
38//!                                                       │  Errored   │
39//!                                                       └────────────┘
40//! ```
41//!
42//! [GUIDELINES.md]: https://github.com/CleverCloud/magnetar/blob/main/GUIDELINES.md
43
44use core::time::Duration;
45use std::collections::{HashMap, HashSet};
46use std::task::Waker;
47
48use slab::Slab;
49
50use crate::pb;
51use crate::types::RequestId;
52
53/// A Pulsar transaction id (`128-bit`, split into two 64-bit halves on the wire).
54///
55/// Mirrors `org.apache.pulsar.client.api.transaction.TxnID` — `mostSigBits` (TC node id) plus
56/// `leastSigBits` (sequence within the TC).
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
58pub struct TxnId {
59    /// Most-significant 64 bits — typically encodes the originating transaction-coordinator id.
60    pub most_sig_bits: u64,
61    /// Least-significant 64 bits — typically encodes the sequence within the TC.
62    pub least_sig_bits: u64,
63}
64
65impl TxnId {
66    /// Construct a `TxnId` from the protobuf-wire halves.
67    pub const fn new(most_sig_bits: u64, least_sig_bits: u64) -> Self {
68        Self {
69            most_sig_bits,
70            least_sig_bits,
71        }
72    }
73}
74
75impl core::fmt::Display for TxnId {
76    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
77        write!(f, "{}:{}", self.most_sig_bits, self.least_sig_bits)
78    }
79}
80
81/// Lifecycle of a transaction tracked by [`TxnClient`].
82///
83/// Mirrors `TransactionImpl.State` in the Java client.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum TxnState {
86    /// Transaction is open and accepting `add_partition` / `add_subscription` / sends.
87    Open,
88    /// `end_txn(Commit)` has been issued; awaiting `CommandEndTxnResponse`.
89    Committing,
90    /// Transaction has been committed by the TC.
91    Committed,
92    /// `end_txn(Abort)` has been issued; awaiting `CommandEndTxnResponse`.
93    Aborting,
94    /// Transaction has been aborted (either by the user or due to an error).
95    Aborted,
96    /// Transaction terminated due to a broker-side error (unrecoverable).
97    Errored,
98}
99
100/// User-visible action passed to [`TxnClient::end_txn`].
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum TxnAction {
103    /// Commit the transaction (best-effort 2PC: TC writes the commit marker on every
104    /// partition + subscription).
105    Commit,
106    /// Abort the transaction.
107    Abort,
108}
109
110impl TxnAction {
111    /// Convert to the wire protobuf enum.
112    pub const fn to_pb(self) -> pb::TxnAction {
113        match self {
114            Self::Commit => pb::TxnAction::Commit,
115            Self::Abort => pb::TxnAction::Abort,
116        }
117    }
118}
119
120/// Errors that a TC RPC can surface to the user future.
121///
122/// The numeric codes come from [`pb::ServerError`]; we keep the broker message so consumers do
123/// not lose diagnostics. The mapping mirrors `TransactionCoordinatorClientException` in the Java
124/// client.
125#[derive(Debug, Clone, thiserror::Error)]
126pub enum TxnError {
127    /// `ServerError::TransactionConflict` — a competing transaction holds the resource (e.g.
128    /// a subscription).
129    #[error("transaction conflict")]
130    Conflict,
131    /// `ServerError::TransactionNotFound` / `ServerError::TransactionCoordinatorNotFound` — the
132    /// TC does not know about the txn we referenced.
133    #[error("transaction not found")]
134    NotFound,
135    /// Driver-side timeout — the broker did not respond within the configured operation timeout.
136    /// The TC itself does not return this; the driver layer surfaces it.
137    #[error("transaction timed out")]
138    Timeout,
139    /// The transaction has already been aborted (locally or remotely).
140    #[error("transaction aborted")]
141    Aborted,
142    /// Any other broker error — `ServerError` code and message.
143    #[error("broker error {0}: {1}")]
144    Broker(i32, String),
145}
146
147impl TxnError {
148    /// Translate a `(ServerError, message)` pair from a TC response into a [`TxnError`].
149    pub fn from_broker(code: i32, message: String) -> Self {
150        // `try_from` is generated by prost for the enum; an unknown code falls through to
151        // `Broker(code, message)`.
152        match pb::ServerError::try_from(code) {
153            Ok(pb::ServerError::TransactionConflict) => Self::Conflict,
154            Ok(
155                pb::ServerError::TransactionNotFound
156                | pb::ServerError::TransactionCoordinatorNotFound,
157            ) => Self::NotFound,
158            Ok(pb::ServerError::InvalidTxnStatus) => Self::Aborted,
159            _ => Self::Broker(code, message),
160        }
161    }
162}
163
164/// Per-transaction bookkeeping.
165///
166/// Tracks the lifecycle state plus the set of topics published to and subscriptions acked under
167/// this transaction. The TC needs every partition + subscription explicitly registered before
168/// the end-txn marker so it can fan out commit / abort markers correctly.
169#[derive(Debug, Clone)]
170pub struct TransactionMetadata {
171    /// Transaction identifier.
172    pub id: TxnId,
173    /// Lifecycle state.
174    pub state: TxnState,
175    /// TC node id (`tc_id` from `CommandNewTxn`) — held so we can re-target retries.
176    pub coordinator_id: u64,
177    /// Transaction timeout configured on `new_txn`.
178    pub timeout: Duration,
179    /// Set of topics (partitions) that the transaction has been registered against.
180    pub produced_topics: HashSet<String>,
181    /// Map of subscription name → topics the subscription was registered against under this txn.
182    pub acked_subscriptions: HashMap<String, Vec<String>>,
183}
184
185impl TransactionMetadata {
186    fn new(id: TxnId, coordinator_id: u64, timeout: Duration) -> Self {
187        Self {
188            id,
189            state: TxnState::Open,
190            coordinator_id,
191            timeout,
192            produced_topics: HashSet::new(),
193            acked_subscriptions: HashMap::new(),
194        }
195    }
196}
197
198/// Per-request bookkeeping so we can correlate inbound responses with the originating call and
199/// figure out which transaction metadata to mutate.
200// reason: `request_id` is carried for the derived `Debug` trace context; the dispatch keys
201// off the surrounding registry HashMap entry, not the struct field. The crate-wide blanket
202// allow was removed; scope it here so future drift in *other* modules still trips.
203#[allow(dead_code)]
204#[derive(Debug, Clone)]
205struct PendingNewTxn {
206    request_id: RequestId,
207    waker_key: usize,
208}
209
210// reason: see `PendingNewTxn` — `request_id` is Debug payload, the registry HashMap is the
211// dispatch key.
212#[allow(dead_code)]
213#[derive(Debug, Clone)]
214struct PendingAddPartition {
215    request_id: RequestId,
216    txn: TxnId,
217    topic: String,
218    waker_key: usize,
219}
220
221// reason: see `PendingNewTxn`.
222#[allow(dead_code)]
223#[derive(Debug, Clone)]
224struct PendingAddSubscription {
225    request_id: RequestId,
226    txn: TxnId,
227    subscription: String,
228    topic: String,
229    waker_key: usize,
230}
231
232// reason: see `PendingNewTxn`.
233#[allow(dead_code)]
234#[derive(Debug, Clone)]
235struct PendingEndTxn {
236    request_id: RequestId,
237    txn: TxnId,
238    action: TxnAction,
239    waker_key: usize,
240}
241
242/// Transaction-coordinator client state machine.
243///
244/// The driver owns one `TxnClient` per `Connection` that has talked to a transaction
245/// coordinator. Each method either *encodes* a TC command (producing a `pb::Command…`) or
246/// *consumes* a TC response and surfaces a `Result` to the caller. No I/O. No channels.
247#[derive(Debug)]
248pub struct TxnClient {
249    coordinator_id: u64,
250    /// Wakers for in-flight `CommandNewTxn` requests, keyed by request id.
251    pending_new_txn: Slab<Waker>,
252    new_txn_by_request: HashMap<RequestId, PendingNewTxn>,
253    /// Wakers for in-flight `CommandAddPartitionToTxn` requests.
254    pending_add_partition: Slab<Waker>,
255    add_partition_by_request: HashMap<RequestId, PendingAddPartition>,
256    /// Wakers for in-flight `CommandAddSubscriptionToTxn` requests.
257    pending_add_subscription: Slab<Waker>,
258    add_subscription_by_request: HashMap<RequestId, PendingAddSubscription>,
259    /// Wakers for in-flight `CommandEndTxn` requests.
260    pending_end_txn: Slab<Waker>,
261    end_txn_by_request: HashMap<RequestId, PendingEndTxn>,
262    /// Live transactions keyed by id.
263    transactions: HashMap<TxnId, TransactionMetadata>,
264}
265
266impl TxnClient {
267    /// Construct a fresh client bound to a specific TC node id (`tc_id`).
268    pub fn new(coordinator_id: u64) -> Self {
269        Self {
270            coordinator_id,
271            pending_new_txn: Slab::new(),
272            new_txn_by_request: HashMap::new(),
273            pending_add_partition: Slab::new(),
274            add_partition_by_request: HashMap::new(),
275            pending_add_subscription: Slab::new(),
276            add_subscription_by_request: HashMap::new(),
277            pending_end_txn: Slab::new(),
278            end_txn_by_request: HashMap::new(),
279            transactions: HashMap::new(),
280        }
281    }
282
283    /// Returns the TC node id this client targets.
284    pub const fn coordinator_id(&self) -> u64 {
285        self.coordinator_id
286    }
287
288    /// Look up an in-flight transaction by id (read-only).
289    pub fn transaction(&self, id: TxnId) -> Option<&TransactionMetadata> {
290        self.transactions.get(&id)
291    }
292
293    /// Number of currently-tracked transactions.
294    pub fn len(&self) -> usize {
295        self.transactions.len()
296    }
297
298    /// `true` if no transactions are tracked.
299    pub fn is_empty(&self) -> bool {
300        self.transactions.is_empty()
301    }
302
303    /// Register a waker that should be woken when the matching `CommandNewTxnResponse` arrives.
304    ///
305    /// Returns a slab key; the response handler discards the waker once it has fired.
306    pub fn register_new_txn_waker(&mut self, request_id: RequestId, waker: Waker) {
307        if let Some(pending) = self.new_txn_by_request.get_mut(&request_id) {
308            self.pending_new_txn[pending.waker_key] = waker;
309        }
310    }
311
312    /// Register a waker for `CommandAddPartitionToTxnResponse`.
313    pub fn register_add_partition_waker(&mut self, request_id: RequestId, waker: Waker) {
314        if let Some(pending) = self.add_partition_by_request.get_mut(&request_id) {
315            self.pending_add_partition[pending.waker_key] = waker;
316        }
317    }
318
319    /// Register a waker for `CommandAddSubscriptionToTxnResponse`.
320    pub fn register_add_subscription_waker(&mut self, request_id: RequestId, waker: Waker) {
321        if let Some(pending) = self.add_subscription_by_request.get_mut(&request_id) {
322            self.pending_add_subscription[pending.waker_key] = waker;
323        }
324    }
325
326    /// Register a waker for `CommandEndTxnResponse`.
327    pub fn register_end_txn_waker(&mut self, request_id: RequestId, waker: Waker) {
328        if let Some(pending) = self.end_txn_by_request.get_mut(&request_id) {
329            self.pending_end_txn[pending.waker_key] = waker;
330        }
331    }
332
333    /// Build a [`pb::CommandNewTxn`] to open a new transaction with the configured timeout.
334    ///
335    /// The caller must wrap the result in a `BaseCommand` of type `NEW_TXN` and place it onto
336    /// the connection's outbound buffer. The transaction-id and `Open` state are only recorded
337    /// once the matching response is consumed via [`Self::handle_new_txn_response`].
338    ///
339    /// The TTL field carries **milliseconds** — `TransactionMetadataStoreService.newTransaction(
340    /// tcId, timeoutInMills, owner)` consumes it directly, with no unit conversion. The Java
341    /// client mirrors this by passing `unit.toMillis(timeout)` into the field
342    /// (`TransactionMetaStoreHandler.newTxnAsync` → `Commands.newTxn`). Sending seconds here
343    /// (e.g. `30`) makes the broker interpret the txn as having a 30 ms TTL and abort it before
344    /// the next round-trip lands. Upstream renamed the field `txn_ttl_seconds` →
345    /// `txn_ttl_millis` in Pulsar 5.0.0-M1 to match; field number 2 and the wire value are
346    /// unchanged, so this is a rename only.
347    pub fn new_txn(&mut self, request_id: u64, timeout_ms: u64) -> pb::CommandNewTxn {
348        let waker_key = self.pending_new_txn.insert(noop_waker());
349        let pending = PendingNewTxn {
350            request_id: RequestId(request_id),
351            waker_key,
352        };
353        self.new_txn_by_request
354            .insert(RequestId(request_id), pending);
355        pb::CommandNewTxn {
356            request_id,
357            txn_ttl_millis: Some(timeout_ms),
358            tc_id: Some(self.coordinator_id),
359            // PIP-473 scalable transaction coordinator. Absent = the legacy coordinator, which
360            // is what a v4 client sends and what this client speaks today.
361            scalable: None,
362        }
363    }
364
365    /// Consume a `CommandNewTxnResponse`. On success the transaction is registered as
366    /// `TxnState::Open` and its id is returned. On broker error a [`TxnError`] is returned and
367    /// no metadata is stored.
368    ///
369    /// Returns `Ok(None)` if the request id is unknown (stale response — the caller can ignore).
370    pub fn handle_new_txn_response(
371        &mut self,
372        resp: pb::CommandNewTxnResponse,
373    ) -> Result<Option<TxnId>, TxnError> {
374        let request_id = RequestId(resp.request_id);
375        let Some(pending) = self.new_txn_by_request.remove(&request_id) else {
376            return Ok(None);
377        };
378        let waker = self.pending_new_txn.try_remove(pending.waker_key);
379
380        if let Some(code) = resp.error {
381            if let Some(w) = waker {
382                w.wake();
383            }
384            return Err(TxnError::from_broker(
385                code,
386                resp.message.unwrap_or_default(),
387            ));
388        }
389
390        let txn_id = TxnId::new(
391            resp.txnid_most_bits.unwrap_or(0),
392            resp.txnid_least_bits.unwrap_or(0),
393        );
394        let timeout = Duration::from_secs(0); // populated by the caller via `set_timeout`
395        let metadata = TransactionMetadata::new(txn_id, self.coordinator_id, timeout);
396        self.transactions.insert(txn_id, metadata);
397        if let Some(w) = waker {
398            w.wake();
399        }
400        Ok(Some(txn_id))
401    }
402
403    /// Build a `CommandAddPartitionToTxn`. The topic is stored locally so the response handler
404    /// can mark it as registered on the transaction metadata.
405    pub fn add_partition(
406        &mut self,
407        request_id: u64,
408        txn: TxnId,
409        topic: String,
410    ) -> pb::CommandAddPartitionToTxn {
411        let waker_key = self.pending_add_partition.insert(noop_waker());
412        let pending = PendingAddPartition {
413            request_id: RequestId(request_id),
414            txn,
415            topic: topic.clone(),
416            waker_key,
417        };
418        self.add_partition_by_request
419            .insert(RequestId(request_id), pending);
420        pb::CommandAddPartitionToTxn {
421            request_id,
422            txnid_least_bits: Some(txn.least_sig_bits),
423            txnid_most_bits: Some(txn.most_sig_bits),
424            partitions: vec![topic],
425            // PIP-473 scalable transaction coordinator. Absent = the legacy coordinator.
426            scalable: None,
427        }
428    }
429
430    /// Consume a `CommandAddPartitionToTxnResponse`. On success the topic is recorded in
431    /// `produced_topics`. On broker error a [`TxnError`] is returned and the transaction is
432    /// transitioned to `Errored`.
433    pub fn handle_add_partition_response(
434        &mut self,
435        resp: pb::CommandAddPartitionToTxnResponse,
436    ) -> Result<(), TxnError> {
437        let request_id = RequestId(resp.request_id);
438        let Some(pending) = self.add_partition_by_request.remove(&request_id) else {
439            return Ok(());
440        };
441        let waker = self.pending_add_partition.try_remove(pending.waker_key);
442
443        if let Some(code) = resp.error {
444            if let Some(meta) = self.transactions.get_mut(&pending.txn) {
445                meta.state = TxnState::Errored;
446            }
447            if let Some(w) = waker {
448                w.wake();
449            }
450            return Err(TxnError::from_broker(
451                code,
452                resp.message.unwrap_or_default(),
453            ));
454        }
455
456        if let Some(meta) = self.transactions.get_mut(&pending.txn) {
457            meta.produced_topics.insert(pending.topic);
458        }
459        if let Some(w) = waker {
460            w.wake();
461        }
462        Ok(())
463    }
464
465    /// Build a `CommandAddSubscriptionToTxn`. The `(subscription, topic)` pair is stored locally
466    /// for the response handler.
467    pub fn add_subscription(
468        &mut self,
469        request_id: u64,
470        txn: TxnId,
471        subscription: String,
472        topic: String,
473    ) -> pb::CommandAddSubscriptionToTxn {
474        let waker_key = self.pending_add_subscription.insert(noop_waker());
475        let pending = PendingAddSubscription {
476            request_id: RequestId(request_id),
477            txn,
478            subscription: subscription.clone(),
479            topic: topic.clone(),
480            waker_key,
481        };
482        self.add_subscription_by_request
483            .insert(RequestId(request_id), pending);
484        pb::CommandAddSubscriptionToTxn {
485            request_id,
486            txnid_least_bits: Some(txn.least_sig_bits),
487            txnid_most_bits: Some(txn.most_sig_bits),
488            subscription: vec![pb::Subscription {
489                topic,
490                subscription,
491            }],
492            // PIP-473 scalable transaction coordinator. Absent = the legacy coordinator.
493            scalable: None,
494        }
495    }
496
497    /// Consume a `CommandAddSubscriptionToTxnResponse`. On success the `(subscription, topic)`
498    /// pair is recorded.
499    pub fn handle_add_subscription_response(
500        &mut self,
501        resp: pb::CommandAddSubscriptionToTxnResponse,
502    ) -> Result<(), TxnError> {
503        let request_id = RequestId(resp.request_id);
504        let Some(pending) = self.add_subscription_by_request.remove(&request_id) else {
505            return Ok(());
506        };
507        let waker = self.pending_add_subscription.try_remove(pending.waker_key);
508
509        if let Some(code) = resp.error {
510            if let Some(meta) = self.transactions.get_mut(&pending.txn) {
511                meta.state = TxnState::Errored;
512            }
513            if let Some(w) = waker {
514                w.wake();
515            }
516            return Err(TxnError::from_broker(
517                code,
518                resp.message.unwrap_or_default(),
519            ));
520        }
521
522        if let Some(meta) = self.transactions.get_mut(&pending.txn) {
523            meta.acked_subscriptions
524                .entry(pending.subscription)
525                .or_default()
526                .push(pending.topic);
527        }
528        if let Some(w) = waker {
529            w.wake();
530        }
531        Ok(())
532    }
533
534    /// Build a `CommandEndTxn` transitioning the transaction to `Committing` / `Aborting`.
535    ///
536    /// Returns the wire command. The transaction stays in the intermediate state until
537    /// [`Self::handle_end_txn_response`] is called with the broker's reply.
538    pub fn end_txn(&mut self, request_id: u64, txn: TxnId, action: TxnAction) -> pb::CommandEndTxn {
539        let waker_key = self.pending_end_txn.insert(noop_waker());
540        let pending = PendingEndTxn {
541            request_id: RequestId(request_id),
542            txn,
543            action,
544            waker_key,
545        };
546        self.end_txn_by_request
547            .insert(RequestId(request_id), pending);
548        if let Some(meta) = self.transactions.get_mut(&txn) {
549            meta.state = match action {
550                TxnAction::Commit => TxnState::Committing,
551                TxnAction::Abort => TxnState::Aborting,
552            };
553        }
554        pb::CommandEndTxn {
555            request_id,
556            txnid_least_bits: Some(txn.least_sig_bits),
557            txnid_most_bits: Some(txn.most_sig_bits),
558            txn_action: Some(action.to_pb() as i32),
559            // PIP-473 scalable transaction coordinator. Absent = the legacy coordinator.
560            scalable: None,
561        }
562    }
563
564    /// Consume a `CommandEndTxnResponse`. On success the transaction transitions to
565    /// `Committed` / `Aborted`. On broker error it transitions to `Errored`.
566    ///
567    /// Returns the resulting [`TxnState`] (so the caller can wake the user future with the final
568    /// outcome).
569    pub fn handle_end_txn_response(
570        &mut self,
571        resp: pb::CommandEndTxnResponse,
572    ) -> Result<TxnState, TxnError> {
573        let request_id = RequestId(resp.request_id);
574        let Some(pending) = self.end_txn_by_request.remove(&request_id) else {
575            // Stale response — best we can do is invent a benign state. Real drivers should
576            // never see this because they index responses by request id before delegating here.
577            return Ok(TxnState::Errored);
578        };
579        let waker = self.pending_end_txn.try_remove(pending.waker_key);
580
581        if let Some(code) = resp.error {
582            if let Some(meta) = self.transactions.get_mut(&pending.txn) {
583                meta.state = TxnState::Errored;
584            }
585            if let Some(w) = waker {
586                w.wake();
587            }
588            return Err(TxnError::from_broker(
589                code,
590                resp.message.unwrap_or_default(),
591            ));
592        }
593
594        let final_state = match pending.action {
595            TxnAction::Commit => TxnState::Committed,
596            TxnAction::Abort => TxnState::Aborted,
597        };
598        if let Some(meta) = self.transactions.get_mut(&pending.txn) {
599            meta.state = final_state;
600        }
601        if let Some(w) = waker {
602            w.wake();
603        }
604        Ok(final_state)
605    }
606
607    /// Drop a transaction from the local registry. The caller is responsible for ensuring the TC
608    /// has been notified (via `end_txn`) — this is purely a memory hygiene operation.
609    pub fn forget(&mut self, txn: TxnId) {
610        self.transactions.remove(&txn);
611    }
612}
613
614/// Construct a no-op [`Waker`] used as a placeholder slot in the pending-op slabs.
615///
616/// We populate the slab immediately when a request is enqueued (so the slab key is stable for
617/// later `register_*_waker` calls). The placeholder is overwritten before the first poll
618/// completes; if no waker is ever registered, dropping the slot is harmless. `Waker::noop`
619/// has been stable since Rust 1.85 (our MSRV).
620fn noop_waker() -> Waker {
621    Waker::noop().clone()
622}
623
624#[cfg(test)]
625mod tests {
626    use super::*;
627
628    fn ok_new_txn_response(request_id: u64, most: u64, least: u64) -> pb::CommandNewTxnResponse {
629        pb::CommandNewTxnResponse {
630            request_id,
631            txnid_most_bits: Some(most),
632            txnid_least_bits: Some(least),
633            error: None,
634            message: None,
635        }
636    }
637
638    #[test]
639    fn new_txn_round_trip_returns_id_and_marks_open() {
640        let mut client = TxnClient::new(7);
641        let cmd = client.new_txn(1, 30_000);
642        assert_eq!(cmd.request_id, 1);
643        assert_eq!(cmd.tc_id, Some(7));
644        // The protobuf field is mis-named: the broker reads it as milliseconds (see
645        // `TransactionMetadataStoreService.newTransaction(tcId, timeoutInMills, owner)` in
646        // pulsar-broker/src/main/java/org/apache/pulsar/broker/TransactionMetadataStoreService.
647        // java). Java client sends `unit.toMillis(timeout)` here, so we mirror that.
648        assert_eq!(cmd.txn_ttl_millis, Some(30_000));
649
650        let id = client
651            .handle_new_txn_response(ok_new_txn_response(1, 99, 42))
652            .expect("ok")
653            .expect("txn id present");
654        assert_eq!(id, TxnId::new(99, 42));
655
656        let meta = client.transaction(id).expect("registered");
657        assert_eq!(meta.state, TxnState::Open);
658        assert_eq!(meta.coordinator_id, 7);
659        assert!(meta.produced_topics.is_empty());
660        assert!(meta.acked_subscriptions.is_empty());
661    }
662
663    #[test]
664    fn add_partition_records_topic_on_success() {
665        let mut client = TxnClient::new(0);
666        let _ = client.new_txn(1, 0);
667        let id = client
668            .handle_new_txn_response(ok_new_txn_response(1, 0, 1))
669            .unwrap()
670            .unwrap();
671
672        let cmd = client.add_partition(2, id, "persistent://p/n/t".to_owned());
673        assert_eq!(cmd.request_id, 2);
674        assert_eq!(cmd.txnid_least_bits, Some(1));
675        assert_eq!(cmd.partitions, vec!["persistent://p/n/t".to_owned()]);
676
677        client
678            .handle_add_partition_response(pb::CommandAddPartitionToTxnResponse {
679                request_id: 2,
680                txnid_least_bits: Some(1),
681                txnid_most_bits: Some(0),
682                error: None,
683                message: None,
684            })
685            .expect("ok");
686
687        let meta = client.transaction(id).unwrap();
688        assert!(meta.produced_topics.contains("persistent://p/n/t"));
689        assert_eq!(meta.state, TxnState::Open);
690    }
691
692    #[test]
693    fn add_subscription_records_subscription_on_success() {
694        let mut client = TxnClient::new(0);
695        let _ = client.new_txn(1, 0);
696        let id = client
697            .handle_new_txn_response(ok_new_txn_response(1, 0, 2))
698            .unwrap()
699            .unwrap();
700
701        let cmd =
702            client.add_subscription(3, id, "sub-a".to_owned(), "persistent://p/n/t".to_owned());
703        assert_eq!(cmd.request_id, 3);
704        assert_eq!(cmd.subscription.len(), 1);
705        assert_eq!(cmd.subscription[0].subscription, "sub-a");
706
707        client
708            .handle_add_subscription_response(pb::CommandAddSubscriptionToTxnResponse {
709                request_id: 3,
710                txnid_least_bits: Some(2),
711                txnid_most_bits: Some(0),
712                error: None,
713                message: None,
714            })
715            .expect("ok");
716
717        let meta = client.transaction(id).unwrap();
718        let topics = meta.acked_subscriptions.get("sub-a").expect("present");
719        assert_eq!(topics, &vec!["persistent://p/n/t".to_owned()]);
720    }
721
722    #[test]
723    fn end_txn_commit_happy_path_marks_committed() {
724        let mut client = TxnClient::new(0);
725        let _ = client.new_txn(1, 0);
726        let id = client
727            .handle_new_txn_response(ok_new_txn_response(1, 0, 10))
728            .unwrap()
729            .unwrap();
730
731        let cmd = client.end_txn(2, id, TxnAction::Commit);
732        assert_eq!(cmd.txn_action, Some(pb::TxnAction::Commit as i32));
733        assert_eq!(client.transaction(id).unwrap().state, TxnState::Committing);
734
735        let final_state = client
736            .handle_end_txn_response(pb::CommandEndTxnResponse {
737                request_id: 2,
738                txnid_least_bits: Some(10),
739                txnid_most_bits: Some(0),
740                error: None,
741                message: None,
742            })
743            .expect("ok");
744        assert_eq!(final_state, TxnState::Committed);
745        assert_eq!(client.transaction(id).unwrap().state, TxnState::Committed);
746    }
747
748    #[test]
749    fn end_txn_abort_happy_path_marks_aborted() {
750        let mut client = TxnClient::new(0);
751        let _ = client.new_txn(1, 0);
752        let id = client
753            .handle_new_txn_response(ok_new_txn_response(1, 0, 11))
754            .unwrap()
755            .unwrap();
756
757        let cmd = client.end_txn(2, id, TxnAction::Abort);
758        assert_eq!(cmd.txn_action, Some(pb::TxnAction::Abort as i32));
759        assert_eq!(client.transaction(id).unwrap().state, TxnState::Aborting);
760
761        let final_state = client
762            .handle_end_txn_response(pb::CommandEndTxnResponse {
763                request_id: 2,
764                txnid_least_bits: Some(11),
765                txnid_most_bits: Some(0),
766                error: None,
767                message: None,
768            })
769            .expect("ok");
770        assert_eq!(final_state, TxnState::Aborted);
771        assert_eq!(client.transaction(id).unwrap().state, TxnState::Aborted);
772    }
773
774    #[test]
775    fn broker_transaction_conflict_maps_to_conflict_error() {
776        let mut client = TxnClient::new(0);
777        let _ = client.new_txn(1, 0);
778        let err = client
779            .handle_new_txn_response(pb::CommandNewTxnResponse {
780                request_id: 1,
781                txnid_most_bits: None,
782                txnid_least_bits: None,
783                error: Some(pb::ServerError::TransactionConflict as i32),
784                message: Some("concurrent txn".to_owned()),
785            })
786            .expect_err("conflict");
787        assert!(matches!(err, TxnError::Conflict));
788        // No metadata should be inserted on error.
789        assert!(client.is_empty());
790    }
791
792    #[test]
793    fn broker_transaction_not_found_maps_to_not_found_error() {
794        let mut client = TxnClient::new(0);
795        let _ = client.new_txn(1, 0);
796        let id = client
797            .handle_new_txn_response(ok_new_txn_response(1, 0, 4))
798            .unwrap()
799            .unwrap();
800        let _ = client.end_txn(2, id, TxnAction::Commit);
801
802        let err = client
803            .handle_end_txn_response(pb::CommandEndTxnResponse {
804                request_id: 2,
805                txnid_least_bits: Some(4),
806                txnid_most_bits: Some(0),
807                error: Some(pb::ServerError::TransactionNotFound as i32),
808                message: Some("gc'd".to_owned()),
809            })
810            .expect_err("not found");
811        assert!(matches!(err, TxnError::NotFound));
812        assert_eq!(client.transaction(id).unwrap().state, TxnState::Errored);
813    }
814
815    #[test]
816    fn unknown_broker_code_falls_through_to_broker_variant() {
817        let mut client = TxnClient::new(0);
818        let _ = client.new_txn(1, 0);
819        let err = client
820            .handle_new_txn_response(pb::CommandNewTxnResponse {
821                request_id: 1,
822                txnid_most_bits: None,
823                txnid_least_bits: None,
824                error: Some(pb::ServerError::PersistenceError as i32),
825                message: Some("bookie down".to_owned()),
826            })
827            .expect_err("broker");
828        match err {
829            TxnError::Broker(code, msg) => {
830                assert_eq!(code, pb::ServerError::PersistenceError as i32);
831                assert_eq!(msg, "bookie down");
832            }
833            other => panic!("expected Broker variant, got {other:?}"),
834        }
835    }
836
837    #[test]
838    fn forget_drops_metadata() {
839        let mut client = TxnClient::new(0);
840        let _ = client.new_txn(1, 0);
841        let id = client
842            .handle_new_txn_response(ok_new_txn_response(1, 0, 1))
843            .unwrap()
844            .unwrap();
845        assert!(client.transaction(id).is_some());
846        client.forget(id);
847        assert!(client.transaction(id).is_none());
848    }
849
850    /// Stale response (unknown request id) returns `Ok(None)` rather than producing a fresh
851    /// `TxnId` — mirrors Java `TransactionImpl#handleResponse` which drops unknown ids on
852    /// the floor instead of spuriously committing. Pinned because the driver dispatcher
853    /// relies on this distinguishing "stale" from "broker said no".
854    #[test]
855    fn handle_new_txn_response_drops_unknown_request_id() {
856        let mut client = TxnClient::new(0);
857        // No `new_txn` was issued; an unsolicited response with request_id=42 should be
858        // silently dropped — `Ok(None)` means "stale, ignore".
859        let result = client.handle_new_txn_response(ok_new_txn_response(42, 0, 1));
860        assert!(matches!(result, Ok(None)));
861        // No metadata leaked.
862        assert!(client.is_empty());
863    }
864
865    /// `TxnError::from_broker` must map `InvalidTxnStatus` to the `Aborted` variant so the
866    /// user future surfaces a recoverable "transaction has been ended" error rather than
867    /// the generic broker fall-through. Mirrors Java
868    /// `TransactionCoordinatorClientException.translateException`.
869    #[test]
870    fn txn_error_invalid_status_maps_to_aborted() {
871        let err = TxnError::from_broker(pb::ServerError::InvalidTxnStatus as i32, "ended".into());
872        assert!(matches!(err, TxnError::Aborted));
873    }
874
875    /// `TxnError::from_broker` must map `TransactionCoordinatorNotFound` to `NotFound` —
876    /// alongside the more obvious `TransactionNotFound` — so callers can use a single arm
877    /// for the "TC has forgotten about this txn" failure mode. Mirrors the Java mapping in
878    /// `TransactionCoordinatorClientException`.
879    #[test]
880    fn txn_error_tc_not_found_maps_to_not_found() {
881        let err = TxnError::from_broker(
882            pb::ServerError::TransactionCoordinatorNotFound as i32,
883            "gc'd".into(),
884        );
885        assert!(matches!(err, TxnError::NotFound));
886        // Plain TransactionNotFound also maps the same way.
887        let err2 =
888            TxnError::from_broker(pb::ServerError::TransactionNotFound as i32, "gc'd".into());
889        assert!(matches!(err2, TxnError::NotFound));
890    }
891
892    /// `TxnId` derives `Display` formatting that mirrors Java
893    /// `TxnID#toString` ("`mostSigBits:leastSigBits`"). Pinned because it appears in log
894    /// lines + error messages and callers may parse it.
895    #[test]
896    fn txn_id_display_uses_colon_separator() {
897        let id = TxnId::new(7, 42);
898        assert_eq!(format!("{id}"), "7:42");
899        // Sorted/Hashed consistently.
900        assert_eq!(id, TxnId::new(7, 42));
901    }
902
903    /// After a broker error on `add_partition`, the transaction must transition to
904    /// `Errored` and the topic must NOT be recorded in `produced_topics`. Pinned because
905    /// the runtime relies on `Errored` to refuse subsequent `end_txn(Commit)` calls and
906    /// surfaces the rollback path. Mirrors Java
907    /// `TransactionImpl#registerProducedTopic` failure handling.
908    #[test]
909    fn add_partition_broker_error_marks_errored_and_skips_topic() {
910        let mut client = TxnClient::new(0);
911        let _ = client.new_txn(1, 0);
912        let id = client
913            .handle_new_txn_response(ok_new_txn_response(1, 0, 5))
914            .unwrap()
915            .unwrap();
916        let _ = client.add_partition(2, id, "persistent://p/n/t".to_owned());
917
918        let err = client
919            .handle_add_partition_response(pb::CommandAddPartitionToTxnResponse {
920                request_id: 2,
921                txnid_least_bits: Some(5),
922                txnid_most_bits: Some(0),
923                error: Some(pb::ServerError::PersistenceError as i32),
924                message: Some("bookie down".to_owned()),
925            })
926            .expect_err("broker error");
927        assert!(matches!(err, TxnError::Broker(..)));
928
929        let meta = client.transaction(id).expect("txn still tracked");
930        assert_eq!(meta.state, TxnState::Errored);
931        assert!(
932            meta.produced_topics.is_empty(),
933            "topic must NOT be recorded on broker error"
934        );
935    }
936
937    /// Same as above but for `add_subscription`. The subscription must not be recorded and
938    /// the txn must transition to `Errored`. Mirrors Java
939    /// `TransactionImpl#registerAckedTopic` failure handling.
940    #[test]
941    fn add_subscription_broker_error_marks_errored_and_skips_subscription() {
942        let mut client = TxnClient::new(0);
943        let _ = client.new_txn(1, 0);
944        let id = client
945            .handle_new_txn_response(ok_new_txn_response(1, 0, 6))
946            .unwrap()
947            .unwrap();
948        let _ = client.add_subscription(2, id, "sub-x".to_owned(), "persistent://p/n/t".to_owned());
949
950        let err = client
951            .handle_add_subscription_response(pb::CommandAddSubscriptionToTxnResponse {
952                request_id: 2,
953                txnid_least_bits: Some(6),
954                txnid_most_bits: Some(0),
955                error: Some(pb::ServerError::TransactionConflict as i32),
956                message: Some("conflict".to_owned()),
957            })
958            .expect_err("broker error");
959        assert!(matches!(err, TxnError::Conflict));
960
961        let meta = client.transaction(id).expect("txn still tracked");
962        assert_eq!(meta.state, TxnState::Errored);
963        assert!(
964            meta.acked_subscriptions.is_empty(),
965            "subscription must NOT be recorded on broker error"
966        );
967    }
968}