···77# Authentication service base URL
88AUTH_BASE_URL=http://localhost:8081
991010-# AT Protocol relay endpoint for syncing data
1010+# AT Protocol relay endpoint for backfill
1111RELAY_ENDPOINT=https://relay1.us-west.bsky.network
1212+1313+# AT Protocol Jetstream hostname
1414+JETSTREAM_HOSTNAME=jetstream2.us-west.bsky.network
12151316# System slice URI
1417SYSTEM_SLICE_URI=at://did:plc:bcgltzqazw5tb6k2g3ttenbj/network.slices.slice/3lymhd4jhrd2z
···11+-- Add jetstream cursor table for tracking event processing position
22+CREATE TABLE IF NOT EXISTS jetstream_cursor (
33+ id TEXT PRIMARY KEY DEFAULT 'default',
44+ time_us BIGINT NOT NULL,
55+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
66+);
77+88+-- Index for tracking cursor freshness
99+CREATE INDEX idx_jetstream_cursor_updated_at ON jetstream_cursor(updated_at);
1010+1111+-- Insert default cursor starting at 0 (will be updated when events are processed)
1212+INSERT INTO jetstream_cursor (id, time_us) VALUES ('default', 0)
1313+ON CONFLICT (id) DO NOTHING;
+3-3
api/src/api/xrpc_dynamic.rs
···603603 validate: false,
604604 };
605605606606- let result = create_record(&http_client, &dpop_auth, &pds_url, create_request)
606606+ let result = create_record(&http_client, &atproto_client::client::Auth::DPoP(dpop_auth), &pds_url, create_request)
607607 .await
608608 .map_err(|_e| status_to_error_response(StatusCode::INTERNAL_SERVER_ERROR))?;
609609···721721 validate: false,
722722 };
723723724724- let result = put_record(&http_client, &dpop_auth, &pds_url, put_request)
724724+ let result = put_record(&http_client, &atproto_client::client::Auth::DPoP(dpop_auth), &pds_url, put_request)
725725 .await
726726 .map_err(|_| status_to_error_response(StatusCode::INTERNAL_SERVER_ERROR))?;
727727···790790 swap_commit: None,
791791 };
792792793793- delete_record(&http_client, &dpop_auth, &pds_url, delete_request)
793793+ delete_record(&http_client, &atproto_client::client::Auth::DPoP(dpop_auth), &pds_url, delete_request)
794794 .await
795795 .map_err(|_| status_to_error_response(StatusCode::INTERNAL_SERVER_ERROR))?;
796796
+9-18
api/src/atproto_extensions.rs
···3344use serde::{Deserialize, Serialize};
55use atproto_client::client::DPoPAuth;
66-use atproto_client::url::URLBuilder;
76use thiserror::Error;
87use atproto_oauth::dpop::{DpopRetry, request_dpop};
98use reqwest_middleware::ClientBuilder;
···2423 UploadFailed { status: u16, message: String },
2524}
26252727-/// Request for uploading a blob
2828-#[derive(Serialize, Deserialize, Debug)]
2929-pub struct UploadBlobRequest {
3030- // Note: For blob uploads, the data is sent as the request body, not JSON
3131- // So this struct is mainly for reference - we'll handle the actual upload differently
3232-}
33263427/// Response from blob upload
3528#[cfg_attr(debug_assertions, derive(Debug))]
···6861 blob_data: Vec<u8>,
6962 mime_type: &str,
7063) -> Result<UploadBlobResponse, BlobUploadError> {
7171- // Build the URL
7272- let mut url_builder = URLBuilder::new(base_url);
7373- url_builder.path("/xrpc/com.atproto.repo.uploadBlob");
7474- let url = url_builder.build();
7575-6464+ // Build the URL using standard string formatting
6565+ let url = format!("{}/xrpc/com.atproto.repo.uploadBlob", base_url.trim_end_matches('/'));
6666+7667 // For blob uploads, we need to use a different approach than post_dpop_json
7768 // since we're sending binary data, not JSON
7869 // We need to use the same DPoP mechanism but with binary body
7979-7070+8071 // Use the internal post_dpop function but for binary data
8172 post_dpop_binary(http_client, dpop_auth, &url, blob_data, mime_type)
8273 .await
···127118 if !http_response.status().is_success() {
128119 let status = http_response.status();
129120 let error_text = http_response.text().await.unwrap_or_else(|_| "unknown".to_string());
130130- return Err(BlobUploadError::UploadFailed {
131131- status: status.as_u16(),
132132- message: error_text
121121+ return Err(BlobUploadError::UploadFailed {
122122+ status: status.as_u16(),
123123+ message: error_text
133124 });
134125 }
135126···137128 .json::<serde_json::Value>()
138129 .await
139130 .map_err(|e| BlobUploadError::HttpRequest(e.into()))?;
140140-131131+141132 Ok(value)
142142-}133133+}
+55-20
api/src/jetstream.rs
···10101111use crate::actor_resolver::resolve_actor_data;
1212use crate::database::Database;
1313+use crate::jetstream_cursor::PostgresCursorHandler;
1314use crate::models::{Record, Actor};
1415use crate::errors::SliceError;
1516use crate::logging::{Logger, LogLevel};
···1819 consumer: Consumer,
1920 database: Database,
2021 http_client: Client,
2121- // Track which collections we should index for each slice
2222 slice_collections: Arc<RwLock<HashMap<String, HashSet<String>>>>,
2323- // Track domains for each slice (slice_uri -> domain)
2423 slice_domains: Arc<RwLock<HashMap<String, String>>>,
2525- // Cache for actor lookups
2624 actor_cache: Arc<RwLock<HashMap<(String, String), bool>>>,
2727- // Lexicon cache for each slice
2825 slice_lexicons: Arc<RwLock<HashMap<String, Vec<serde_json::Value>>>>,
2929- // Event counter for health monitoring
3026 pub event_count: Arc<std::sync::atomic::AtomicU64>,
2727+ cursor_handler: Option<Arc<PostgresCursorHandler>>,
3128}
32293330// Event handler that implements the EventHandler trait
···3734 slice_collections: Arc<RwLock<HashMap<String, HashSet<String>>>>,
3835 slice_domains: Arc<RwLock<HashMap<String, String>>>,
3936 event_count: Arc<std::sync::atomic::AtomicU64>,
4040- // Cache for (did, slice_uri) -> is_actor lookups
4137 actor_cache: Arc<RwLock<HashMap<(String, String), bool>>>,
4242- // Lexicon cache for each slice
4338 slice_lexicons: Arc<RwLock<HashMap<String, Vec<serde_json::Value>>>>,
3939+ cursor_handler: Option<Arc<PostgresCursorHandler>>,
4440}
45414642#[async_trait]
4743impl EventHandler for SliceEventHandler {
4844 async fn handle_event(&self, event: JetstreamEvent) -> Result<()> {
4949- // Increment event counter
5045 let count = self.event_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
5151-5252- // Log every 10000 events to show activity (console only, not in DB)
5353- if count % 10000 == 0 {
4646+4747+ if count.is_multiple_of(10000) {
5448 info!("Jetstream consumer has processed {} events", count);
5549 }
5656-5050+5151+ // Extract and update cursor position from event
5252+ let time_us = match &event {
5353+ JetstreamEvent::Commit { time_us, .. } => *time_us,
5454+ JetstreamEvent::Delete { time_us, .. } => *time_us,
5555+ JetstreamEvent::Identity { time_us, .. } => *time_us,
5656+ JetstreamEvent::Account { time_us, .. } => *time_us,
5757+ };
5858+5959+ if let Some(cursor_handler) = &self.cursor_handler {
6060+ cursor_handler.update_position(time_us);
6161+6262+ // Periodically write cursor to DB (debounced by handler)
6363+ if let Err(e) = cursor_handler.maybe_write_cursor().await {
6464+ error!("Failed to write cursor: {}", e);
6565+ }
6666+ }
6767+5768 match event {
5869 JetstreamEvent::Commit { did, commit, .. } => {
5970 if let Err(e) = self.handle_commit_event(&did, commit).await {
···501512}
502513503514impl JetstreamConsumer {
504504- pub async fn new(database: Database, jetstream_hostname: Option<String>) -> Result<Self, SliceError> {
515515+ /// Create a new Jetstream consumer with optional cursor support
516516+ ///
517517+ /// # Arguments
518518+ /// * `database` - Database connection for slice configurations and record storage
519519+ /// * `jetstream_hostname` - Optional custom jetstream hostname
520520+ /// * `cursor_handler` - Optional cursor handler for resumable event processing
521521+ /// * `initial_cursor` - Optional starting cursor position (time_us) to resume from
522522+ pub async fn new(
523523+ database: Database,
524524+ jetstream_hostname: Option<String>,
525525+ cursor_handler: Option<Arc<PostgresCursorHandler>>,
526526+ initial_cursor: Option<i64>,
527527+ ) -> Result<Self, SliceError> {
505528 let config = ConsumerTaskConfig {
506529 user_agent: "slice-server/1.0".to_string(),
507530 compression: false,
508531 zstd_dictionary_location: String::new(),
509532 jetstream_hostname: jetstream_hostname
510533 .unwrap_or_else(|| "jetstream1.us-east.bsky.network".to_string()),
511511- collections: Vec::new(), // We'll update this dynamically based on slice configs
512512- dids: Vec::new(), // Subscribe to all DIDs
534534+ collections: Vec::new(),
535535+ dids: Vec::new(),
513536 max_message_size_bytes: None,
514514- cursor: None,
515515- require_hello: true, // Match official example - enables proper handshake
537537+ cursor: initial_cursor,
538538+ require_hello: true,
516539 };
517540518541 let consumer = Consumer::new(config);
···527550 actor_cache: Arc::new(RwLock::new(HashMap::new())),
528551 slice_lexicons: Arc::new(RwLock::new(HashMap::new())),
529552 event_count: Arc::new(std::sync::atomic::AtomicU64::new(0)),
553553+ cursor_handler,
530554 })
531555 }
532556···651675 event_count: self.event_count.clone(),
652676 actor_cache: self.actor_cache.clone(),
653677 slice_lexicons: self.slice_lexicons.clone(),
678678+ cursor_handler: self.cursor_handler.clone(),
654679 });
655680656681 self.consumer.register_handler(handler).await
···671696672697 // Start the consumer
673698 info!("Starting Jetstream background consumer...");
674674- self.consumer.run_background(cancellation_token).await
699699+ let result = self.consumer.run_background(cancellation_token).await
675700 .map_err(|e| SliceError::JetstreamError {
676701 message: format!("Consumer failed: {}", e),
677677- })?;
678678-702702+ });
703703+704704+ // Force write cursor on shutdown to ensure latest position is persisted
705705+ if let Some(cursor_handler) = &self.cursor_handler {
706706+ if let Err(e) = cursor_handler.force_write_cursor().await {
707707+ error!("Failed to write final cursor position: {}", e);
708708+ } else {
709709+ info!("Final cursor position written to database");
710710+ }
711711+ }
712712+713713+ result?;
679714 Ok(())
680715 }
681716
+143
api/src/jetstream_cursor.rs
···11+use sqlx::PgPool;
22+use std::sync::Arc;
33+use std::sync::atomic::{AtomicU64, Ordering};
44+use std::time::{Duration, Instant};
55+use tokio::sync::Mutex;
66+use tracing::{debug, warn};
77+88+/// Handles persistence of Jetstream cursor position to Postgres
99+///
1010+/// The cursor tracks the last processed event's time_us to enable resumption
1111+/// after disconnections or restarts. Writes are debounced to reduce DB load.
1212+pub struct PostgresCursorHandler {
1313+ pool: PgPool,
1414+ cursor_id: String,
1515+ last_time_us: Arc<AtomicU64>,
1616+ last_write: Arc<Mutex<Instant>>,
1717+ write_interval: Duration,
1818+}
1919+2020+impl PostgresCursorHandler {
2121+ /// Create a new cursor handler
2222+ ///
2323+ /// # Arguments
2424+ /// * `pool` - Database connection pool
2525+ /// * `cursor_id` - Unique identifier for this cursor (e.g., "default", "instance-1")
2626+ /// * `write_interval_secs` - Minimum seconds between cursor writes to reduce DB load
2727+ pub fn new(pool: PgPool, cursor_id: String, write_interval_secs: u64) -> Self {
2828+ Self {
2929+ pool,
3030+ cursor_id,
3131+ last_time_us: Arc::new(AtomicU64::new(0)),
3232+ last_write: Arc::new(Mutex::new(Instant::now())),
3333+ write_interval: Duration::from_secs(write_interval_secs),
3434+ }
3535+ }
3636+3737+ /// Update the in-memory cursor position from an event's time_us
3838+ ///
3939+ /// This is called for every event but only writes to DB at intervals
4040+ pub fn update_position(&self, time_us: u64) {
4141+ self.last_time_us.store(time_us, Ordering::Relaxed);
4242+ }
4343+4444+ /// Conditionally write cursor to Postgres if interval has elapsed
4545+ ///
4646+ /// This implements debouncing to avoid excessive DB writes while ensuring
4747+ /// we don't lose more than write_interval seconds of progress on restart
4848+ pub async fn maybe_write_cursor(&self) -> anyhow::Result<()> {
4949+ let current_time_us = self.last_time_us.load(Ordering::Relaxed);
5050+ if current_time_us == 0 {
5151+ return Ok(());
5252+ }
5353+5454+ let mut last_write = self.last_write.lock().await;
5555+ if last_write.elapsed() >= self.write_interval {
5656+ sqlx::query!(
5757+ r#"
5858+ INSERT INTO jetstream_cursor (id, time_us, updated_at)
5959+ VALUES ($1, $2, NOW())
6060+ ON CONFLICT (id)
6161+ DO UPDATE SET time_us = $2, updated_at = NOW()
6262+ "#,
6363+ self.cursor_id,
6464+ current_time_us as i64
6565+ )
6666+ .execute(&self.pool)
6767+ .await?;
6868+6969+ *last_write = Instant::now();
7070+ debug!(
7171+ cursor = current_time_us,
7272+ cursor_id = %self.cursor_id,
7373+ "Updated jetstream cursor in Postgres"
7474+ );
7575+ }
7676+ Ok(())
7777+ }
7878+7979+ /// Force immediate write of cursor to Postgres, bypassing interval check
8080+ ///
8181+ /// Used during graceful shutdown to ensure latest position is persisted
8282+ pub async fn force_write_cursor(&self) -> anyhow::Result<()> {
8383+ let current_time_us = self.last_time_us.load(Ordering::Relaxed);
8484+ if current_time_us == 0 {
8585+ return Ok(());
8686+ }
8787+8888+ sqlx::query!(
8989+ r#"
9090+ INSERT INTO jetstream_cursor (id, time_us, updated_at)
9191+ VALUES ($1, $2, NOW())
9292+ ON CONFLICT (id)
9393+ DO UPDATE SET time_us = $2, updated_at = NOW()
9494+ "#,
9595+ self.cursor_id,
9696+ current_time_us as i64
9797+ )
9898+ .execute(&self.pool)
9999+ .await?;
100100+101101+ let mut last_write = self.last_write.lock().await;
102102+ *last_write = Instant::now();
103103+104104+ debug!(
105105+ cursor = current_time_us,
106106+ cursor_id = %self.cursor_id,
107107+ "Force wrote jetstream cursor to Postgres"
108108+ );
109109+ Ok(())
110110+ }
111111+112112+ /// Read the last persisted cursor position from Postgres
113113+ ///
114114+ /// Returns None if no cursor exists or cursor is 0 (indicating fresh start)
115115+ /// This should be called on startup to resume from last position
116116+ pub async fn read_cursor(pool: &PgPool, cursor_id: &str) -> Option<i64> {
117117+ match sqlx::query!(
118118+ r#"
119119+ SELECT time_us
120120+ FROM jetstream_cursor
121121+ WHERE id = $1
122122+ "#,
123123+ cursor_id
124124+ )
125125+ .fetch_optional(pool)
126126+ .await
127127+ {
128128+ Ok(Some(row)) => {
129129+ let time_us = row.time_us;
130130+ if time_us > 0 {
131131+ Some(time_us)
132132+ } else {
133133+ None
134134+ }
135135+ }
136136+ Ok(None) => None,
137137+ Err(e) => {
138138+ warn!(error = ?e, "Failed to read cursor from Postgres");
139139+ None
140140+ }
141141+ }
142142+ }
143143+}
+86-106
api/src/main.rs
···55mod database;
66mod errors;
77mod jetstream;
88+mod jetstream_cursor;
89mod jobs;
910mod logging;
1011mod models;
···2526use crate::database::Database;
2627use crate::errors::AppError;
2728use crate::jetstream::JetstreamConsumer;
2828-use crate::logging::{LogLevel, Logger, start_log_cleanup_task};
2929+use crate::jetstream_cursor::PostgresCursorHandler;
3030+use crate::logging::{Logger, start_log_cleanup_task};
29313032#[derive(Clone)]
3133pub struct Config {
···118120 // Create shared jetstream connection status
119121 let jetstream_connected = Arc::new(AtomicBool::new(false));
120122121121- // Start Jetstream consumer for real-time indexing with automatic recovery
123123+ // Start Jetstream consumer with cursor persistence and improved reconnection logic
122124 let database_for_jetstream = database.clone();
125125+ let pool_for_jetstream = pool.clone();
123126 let jetstream_connected_clone = jetstream_connected.clone();
124127 tokio::spawn(async move {
125125- let jetstream_hostname = env::var("JETSTREAM_HOSTNAME").ok(); // Optional, will use default if not set
128128+ let jetstream_hostname = env::var("JETSTREAM_HOSTNAME").ok();
129129+ let cursor_write_interval = env::var("JETSTREAM_CURSOR_WRITE_INTERVAL_SECS")
130130+ .unwrap_or_else(|_| "5".to_string())
131131+ .parse::<u64>()
132132+ .unwrap_or(5);
126133127127- // Create initial consumer to start configuration reloader (only once)
128128- let initial_consumer = match JetstreamConsumer::new(
129129- database_for_jetstream.clone(),
130130- jetstream_hostname.clone(),
131131- )
132132- .await
133133- {
134134- Ok(consumer) => {
135135- let consumer_arc = std::sync::Arc::new(consumer);
136136- // Start configuration reloader ONCE - it will run independently
137137- JetstreamConsumer::start_configuration_reloader(consumer_arc.clone());
138138- Some(consumer_arc)
134134+ // Reconnection rate limiting (5 retries per minute max)
135135+ const MAX_RECONNECTS_PER_MINUTE: u32 = 5;
136136+ const RECONNECT_WINDOW: tokio::time::Duration = tokio::time::Duration::from_secs(60);
137137+ let mut reconnect_count = 0u32;
138138+ let mut window_start = std::time::Instant::now();
139139+140140+ let mut retry_delay = tokio::time::Duration::from_secs(5);
141141+ const MAX_RETRY_DELAY: tokio::time::Duration = tokio::time::Duration::from_secs(300);
142142+143143+ // Configuration reloader setup (run once)
144144+ let mut config_reloader_started = false;
145145+146146+ loop {
147147+ // Rate limiting: reset counter if window has passed
148148+ let now = std::time::Instant::now();
149149+ if now.duration_since(window_start) >= RECONNECT_WINDOW {
150150+ reconnect_count = 0;
151151+ window_start = now;
139152 }
140140- Err(e) => {
141141- tracing::error!("Failed to create initial Jetstream consumer: {}", e);
142142- None
153153+154154+ // Check rate limit
155155+ if reconnect_count >= MAX_RECONNECTS_PER_MINUTE {
156156+ let wait_time = RECONNECT_WINDOW - now.duration_since(window_start);
157157+ tracing::warn!(
158158+ "Rate limit exceeded: {} reconnects in last minute, waiting {:?}",
159159+ reconnect_count, wait_time
160160+ );
161161+ tokio::time::sleep(wait_time).await;
162162+ continue;
143163 }
144144- };
145164146146- // Retry loop for Jetstream consumer with exponential backoff
147147- let mut retry_delay = tokio::time::Duration::from_secs(5); // Start with 5 seconds
148148- const MAX_RETRY_DELAY: tokio::time::Duration = tokio::time::Duration::from_secs(300); // Cap at 5 minutes
149149- let mut current_consumer = initial_consumer;
165165+ reconnect_count += 1;
150166151151- loop {
152152- tracing::info!("Starting Jetstream consumer...");
153153- Logger::global().log_jetstream(
154154- LogLevel::Info,
155155- "Starting Jetstream consumer",
156156- Some(serde_json::json!({"action": "starting_consumer"})),
157157- );
167167+ // Read cursor position from database
168168+ let initial_cursor = PostgresCursorHandler::read_cursor(&pool_for_jetstream, "default").await;
169169+ if let Some(cursor) = initial_cursor {
170170+ tracing::info!("Resuming from cursor position: {}", cursor);
171171+ } else {
172172+ tracing::info!("No cursor found, starting from latest events");
173173+ }
158174159159- // Use existing consumer or create new one
160160- let consumer_arc = match current_consumer.take() {
161161- Some(existing) => existing,
162162- None => {
163163- match JetstreamConsumer::new(
164164- database_for_jetstream.clone(),
165165- jetstream_hostname.clone(),
166166- )
167167- .await
168168- {
169169- Ok(consumer) => std::sync::Arc::new(consumer),
170170- Err(e) => {
171171- let message = format!(
172172- "Failed to create Jetstream consumer: {} - will retry in {:?}",
173173- e, retry_delay
174174- );
175175- tracing::error!("{}", message);
176176- Logger::global().log_jetstream(
177177- LogLevel::Error,
178178- &message,
179179- Some(serde_json::json!({
180180- "error": e.to_string(),
181181- "retry_delay_secs": retry_delay.as_secs(),
182182- "action": "consumer_creation_failed"
183183- })),
184184- );
185185- jetstream_connected_clone
186186- .store(false, std::sync::atomic::Ordering::Relaxed);
175175+ // Create cursor handler
176176+ let cursor_handler = Arc::new(PostgresCursorHandler::new(
177177+ pool_for_jetstream.clone(),
178178+ "default".to_string(),
179179+ cursor_write_interval,
180180+ ));
187181188188- // Wait before retrying
189189- tokio::time::sleep(retry_delay).await;
190190- retry_delay = std::cmp::min(retry_delay * 2, MAX_RETRY_DELAY);
191191- continue;
192192- }
182182+ // Create consumer with cursor support
183183+ let consumer_result = JetstreamConsumer::new(
184184+ database_for_jetstream.clone(),
185185+ jetstream_hostname.clone(),
186186+ Some(cursor_handler.clone()),
187187+ initial_cursor,
188188+ ).await;
189189+190190+ let consumer_arc = match consumer_result {
191191+ Ok(consumer) => {
192192+ let arc = Arc::new(consumer);
193193+194194+ // Start configuration reloader only once
195195+ if !config_reloader_started {
196196+ JetstreamConsumer::start_configuration_reloader(arc.clone());
197197+ config_reloader_started = true;
193198 }
199199+200200+ arc
201201+ }
202202+ Err(e) => {
203203+ tracing::error!("Failed to create Jetstream consumer: {} - retry in {:?}", e, retry_delay);
204204+ jetstream_connected_clone.store(false, std::sync::atomic::Ordering::Relaxed);
205205+ tokio::time::sleep(retry_delay).await;
206206+ retry_delay = std::cmp::min(retry_delay * 2, MAX_RETRY_DELAY);
207207+ continue;
194208 }
195209 };
196210197197- // Reset retry delay on successful connection
211211+ // Reset retry delay on successful creation
198212 retry_delay = tokio::time::Duration::from_secs(5);
199213200200- // Mark as connected when consumer starts successfully
214214+ tracing::info!("Starting Jetstream consumer with cursor support...");
201215 jetstream_connected_clone.store(true, std::sync::atomic::Ordering::Relaxed);
202202- tracing::info!("Jetstream consumer connected successfully");
203203- Logger::global().log_jetstream(
204204- LogLevel::Info,
205205- "Jetstream consumer connected",
206206- Some(serde_json::json!({
207207- "action": "consumer_connected"
208208- })),
209209- );
210216211211- // Start consuming events
217217+ // Start consuming with cancellation token
212218 let cancellation_token = atproto_jetstream::CancellationToken::new();
213219 match consumer_arc.start_consuming(cancellation_token).await {
214214- Err(e) => {
215215- tracing::error!(
216216- "Jetstream consumer disconnected: {} - will retry in {:?}",
217217- e,
218218- retry_delay
219219- );
220220- Logger::global().log_jetstream(
221221- LogLevel::Error,
222222- &format!("Jetstream consumer disconnected: {}", e),
223223- Some(serde_json::json!({
224224- "error": e.to_string(),
225225- "retry_delay_secs": retry_delay.as_secs(),
226226- "action": "consumer_disconnected"
227227- })),
228228- );
229229- // Mark as disconnected on failure
220220+ Ok(_) => {
221221+ tracing::info!("Jetstream consumer shut down normally");
230222 jetstream_connected_clone.store(false, std::sync::atomic::Ordering::Relaxed);
231223 }
232232- Ok(_) => {
233233- tracing::info!("Jetstream consumer closed normally");
234234- Logger::global().log_jetstream(
235235- LogLevel::Info,
236236- "Jetstream consumer closed normally",
237237- Some(serde_json::json!({
238238- "action": "consumer_closed"
239239- })),
240240- );
241241- // This shouldn't happen in normal operation since start_consuming should run indefinitely
224224+ Err(e) => {
225225+ tracing::error!("Jetstream consumer failed: {} - will reconnect", e);
242226 jetstream_connected_clone.store(false, std::sync::atomic::Ordering::Relaxed);
227227+ tokio::time::sleep(retry_delay).await;
228228+ retry_delay = std::cmp::min(retry_delay * 2, MAX_RETRY_DELAY);
243229 }
244230 }
245245-246246- // Wait before retrying with exponential backoff
247247- tokio::time::sleep(retry_delay).await;
248248-249249- // Increase retry delay, but cap it at MAX_RETRY_DELAY
250250- retry_delay = std::cmp::min(retry_delay * 2, MAX_RETRY_DELAY);
251231 }
252232 });
253233