nats.rs 5.49 KB
Newer Older
Ryan Olson's avatar
Ryan Olson committed
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

//! NATS implementation of the PubSub traits.

use std::sync::Arc;

use anyhow::{Context, Result};
use async_nats::Client;
use bytes::Bytes;
use flume::{Receiver, Sender};
use futures::future::BoxFuture;
use futures::stream::BoxStream;
use futures::{FutureExt, StreamExt};
use tokio::sync::oneshot;
use tracing::error;

use super::{Message, Publisher, Subscriber, Subscription};

/// Configuration for NATS publisher/subscriber.
#[derive(Debug, Clone)]
pub struct NatsConfig {
    /// NATS server URL (e.g., "nats://localhost:4222").
    pub server_url: String,
    /// Optional subject prefix prepended to all subjects.
    pub subject_prefix: Option<String>,
}

impl NatsConfig {
    /// Create a new NATS configuration.
    pub fn new(server_url: impl Into<String>) -> Self {
        Self {
            server_url: server_url.into(),
            subject_prefix: None,
        }
    }

    /// Set an optional subject prefix.
    pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
        self.subject_prefix = Some(prefix.into());
        self
    }

    /// Connect to the NATS server and return a client.
    pub async fn connect(&self) -> Result<Client> {
        async_nats::connect(&self.server_url)
            .await
            .context("failed to connect to NATS server")
    }

    /// Format a subject with the optional prefix.
    fn format_subject(&self, subject: &str) -> String {
        match &self.subject_prefix {
            Some(prefix) => format!("{}.{}", prefix, subject),
            None => subject.to_string(),
        }
    }
}

/// Command sent to the publisher background task.
enum PublishCommand {
    /// Publish a message to a subject.
    Publish { subject: String, payload: Bytes },
    /// Flush pending messages and notify when complete.
    Flush { done: oneshot::Sender<Result<()>> },
}

/// NATS implementation of the [`Publisher`] trait.
///
/// Uses a background task with a flume channel to handle async publishes.
pub struct NatsPublisher {
    tx: Sender<PublishCommand>,
    config: Arc<NatsConfig>,
}

impl NatsPublisher {
    /// Create a new NATS publisher from a client and configuration.
    ///
    /// Spawns a background task to handle async publish operations.
    pub fn new(client: Client, config: NatsConfig) -> Self {
        let (tx, rx) = flume::unbounded();
        let config = Arc::new(config);

        tokio::spawn(Self::run_publish_loop(client, rx));

        Self { tx, config }
    }

    /// Create a new NATS publisher by connecting to the server.
    pub async fn connect(config: NatsConfig) -> Result<Self> {
        let client = config.connect().await?;
        Ok(Self::new(client, config))
    }

    /// Background task that processes publish commands.
    async fn run_publish_loop(client: Client, rx: Receiver<PublishCommand>) {
        while let Ok(cmd) = rx.recv_async().await {
            match cmd {
                PublishCommand::Publish { subject, payload } => {
                    if let Err(e) = client.publish(subject, payload).await {
                        error!("failed to publish message: {e}");
                    }
                }
                PublishCommand::Flush { done } => {
                    let result = client.flush().await.context("failed to flush");
                    // Ignore send error (receiver may have dropped)
                    let _ = done.send(result);
                }
            }
        }
    }
}

impl Publisher for NatsPublisher {
    fn publish(&self, subject: &str, payload: Bytes) -> Result<()> {
        let subject = self.config.format_subject(subject);
        self.tx
            .send(PublishCommand::Publish { subject, payload })
            .map_err(|_| anyhow::anyhow!("publisher task has terminated"))
    }

    fn flush(&self) -> BoxFuture<'static, Result<()>> {
        let (done_tx, done_rx) = oneshot::channel();
        let tx = self.tx.clone();

        async move {
            tx.send(PublishCommand::Flush { done: done_tx })
                .map_err(|_| anyhow::anyhow!("publisher task has terminated"))?;
            done_rx
                .await
                .map_err(|_| anyhow::anyhow!("publisher task has terminated"))?
        }
        .boxed()
    }
}

/// NATS implementation of the [`Subscriber`] trait.
pub struct NatsSubscriber {
    client: Client,
    config: NatsConfig,
}

impl NatsSubscriber {
    /// Create a new NATS subscriber from a client and configuration.
    pub fn new(client: Client, config: NatsConfig) -> Self {
        Self { client, config }
    }

    /// Create a new NATS subscriber by connecting to the server.
    pub async fn connect(config: NatsConfig) -> Result<Self> {
        let client = config.connect().await?;
        Ok(Self::new(client, config))
    }
}

impl Subscriber for NatsSubscriber {
    fn subscribe(&self, subject: &str) -> BoxFuture<'static, Result<Subscription>> {
        let subject = self.config.format_subject(subject);
        let client = self.client.clone();
        async move {
            let subscriber = client
                .subscribe(subject)
                .await
                .context("failed to subscribe")?;

            let stream: BoxStream<'static, Message> = subscriber
                .map(|msg| Message {
                    subject: msg.subject.to_string(),
                    payload: msg.payload,
                })
                .boxed();

            Ok(stream)
        }
        .boxed()
    }
}