Skip to main content

reifydb_sub_server/subscription/
handler.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4use std::{collections::HashMap, sync::Arc};
5
6use reifydb_client::{HydrationConfig as ClientHydrationConfig, SubscriptionConfig as ClientSubscriptionConfig};
7use reifydb_core::{
8	common::CommitVersion,
9	interface::catalog::{id::SubscriptionId, subscription::HydrationConfig},
10	metric::ExecutionMetrics,
11	value::column::columns::Columns,
12};
13use reifydb_engine::subscription::{HydrateError, SubscriptionServiceRef};
14use reifydb_remote_proxy::{RemoteSubscription, connect_remote, proxy_remote_to_sink};
15use reifydb_subscription::{batch::BatchId, delivery::DeliveryResult};
16use reifydb_transaction::multi::lease::VersionLeaseGuard;
17use reifydb_value::{
18	reifydb_assertions,
19	value::{duration::Duration, frame::frame::Frame, identity::IdentityId},
20};
21use tokio::{spawn, sync::watch::Receiver as WatchReceiver, task::JoinHandle};
22use tracing::{debug, warn};
23
24use crate::{
25	interceptor::RequestMetadata,
26	state::AppState,
27	subscription::{
28		cleanup::cleanup_subscription,
29		create::{CreateSubscriptionResult, create_subscription},
30		errors::CreateSubscriptionError,
31		hydrate::run_hydrate,
32		registry::{ConnectionId, PromoteResult, SubscriptionRegistry},
33		wire_sink::{BatchSubscribedMember, WireSink},
34	},
35};
36
37#[derive(Debug, Clone)]
38pub struct BatchMemberInfo {
39	pub index: usize,
40	pub subscription_id: SubscriptionId,
41}
42
43pub struct BatchAck {
44	pub batch_id: BatchId,
45	pub members: Vec<BatchMemberInfo>,
46	pub remote_handles: Vec<JoinHandle<()>>,
47}
48
49pub struct SubscribeAck {
50	pub subscription_id: SubscriptionId,
51	pub remote_handle: Option<JoinHandle<()>>,
52}
53
54#[derive(Debug)]
55pub enum SubscribeError {
56	Create(CreateSubscriptionError),
57	RemoteConnect(String),
58	InvalidRemoteId,
59	LeaseFailed {
60		code: &'static str,
61		message: String,
62	},
63	HydrationBackpressure,
64	HydrationFailed {
65		error: HydrateError,
66		rql: String,
67		max_rows: u64,
68	},
69	HydrationServiceUnavailable(String),
70}
71
72#[derive(Debug)]
73pub enum BatchSubscribeError {
74	Empty,
75	Create(CreateSubscriptionError),
76	RemoteConnect(String),
77	InvalidRemoteId,
78	LeaseFailed {
79		code: &'static str,
80		message: String,
81	},
82	HydrationBackpressure,
83	HydrationFailed {
84		error: HydrateError,
85		rql: String,
86		max_rows: u64,
87	},
88	HydrationServiceUnavailable(String),
89}
90
91enum ResolvedBatchMember {
92	Local {
93		index: usize,
94		subscription_id: SubscriptionId,
95		query: String,
96	},
97	Remote {
98		index: usize,
99		subscription_id: SubscriptionId,
100		remote_sub: Box<RemoteSubscription>,
101	},
102}
103
104impl ResolvedBatchMember {
105	fn index(&self) -> usize {
106		match self {
107			Self::Local {
108				index,
109				..
110			}
111			| Self::Remote {
112				index,
113				..
114			} => *index,
115		}
116	}
117
118	fn subscription_id(&self) -> SubscriptionId {
119		match self {
120			Self::Local {
121				subscription_id,
122				..
123			}
124			| Self::Remote {
125				subscription_id,
126				..
127			} => *subscription_id,
128		}
129	}
130}
131
132#[allow(clippy::too_many_arguments)]
133pub async fn handle_subscribe<S: WireSink>(
134	state: &AppState,
135	connection_id: ConnectionId,
136	identity: IdentityId,
137	rql: String,
138	sink: S,
139	registry: &Arc<SubscriptionRegistry<S>>,
140	format: S::Format,
141	shutdown: WatchReceiver<bool>,
142	metadata: RequestMetadata,
143) -> Result<SubscribeAck, SubscribeError> {
144	match create_subscription(state, identity, &rql, metadata).await {
145		Ok(CreateSubscriptionResult::Local {
146			id: subscription_id,
147			hydration,
148			throttle,
149			linger: _,
150		}) => {
151			handle_subscribe_local(
152				state,
153				connection_id,
154				identity,
155				rql,
156				sink,
157				registry,
158				format,
159				subscription_id,
160				hydration,
161				throttle,
162			)
163			.await
164		}
165		Ok(CreateSubscriptionResult::Remote {
166			address,
167			body,
168			token: ns_token,
169			hydration,
170			throttle,
171			linger,
172		}) => {
173			handle_subscribe_remote(
174				connection_id,
175				sink,
176				format,
177				shutdown,
178				address,
179				body,
180				ns_token,
181				hydration,
182				throttle,
183				linger,
184			)
185			.await
186		}
187		Err(e) => Err(SubscribeError::Create(e)),
188	}
189}
190
191#[inline]
192#[allow(clippy::too_many_arguments)]
193async fn handle_subscribe_local<S: WireSink>(
194	state: &AppState,
195	connection_id: ConnectionId,
196	identity: IdentityId,
197	rql: String,
198	sink: S,
199	registry: &Arc<SubscriptionRegistry<S>>,
200	format: S::Format,
201	subscription_id: SubscriptionId,
202	hydration: HydrationConfig,
203	throttle: Option<Duration>,
204) -> Result<SubscribeAck, SubscribeError> {
205	let server_cap = state.subscribe_max_hydration_rows();
206	let throttle = state.clamp_throttle(throttle);
207	let max_rows = match hydration.max_rows {
208		Some(n) if n > server_cap => {
209			warn!("clamping hydration.max_rows from {} to server cap {}", n, server_cap);
210			server_cap
211		}
212		Some(n) => n,
213		None => server_cap,
214	};
215	let warming_cap = if hydration.enabled {
216		Some(max_rows as usize)
217	} else {
218		None
219	};
220
221	reifydb_assertions! {
222		let warming = warming_cap.is_some();
223		assert!(
224			warming == hydration.enabled,
225			"warming cap must be Some iff hydration is enabled (warming={warming}, enabled={}); \
226			 a mismatch leaves a warming subscription that the hydrate-vs-promote branch never \
227			 promotes to live, stranding the client",
228			hydration.enabled
229		);
230	}
231
232	registry.subscribe(subscription_id, connection_id, rql.clone(), sink.clone(), format, warming_cap, throttle);
233
234	if !matches!(sink.send_subscribed(subscription_id), DeliveryResult::Delivered) {
235		abort_warming(state, registry, subscription_id).await;
236		return Err(SubscribeError::LeaseFailed {
237			code: "STREAM_CLOSED",
238			message: "Client stream closed before Subscribed could be delivered".to_string(),
239		});
240	}
241
242	if hydration.enabled {
243		let lease = match state.engine().acquire_current_snapshot_lease() {
244			Ok((_, lease)) => lease,
245			Err(e) => {
246				let code = if e.0.code == "TXN_012" {
247					"HYDRATION_VERSION_EVICTED"
248				} else {
249					"PIN_VERSION_FAILED"
250				};
251				abort_warming(state, registry, subscription_id).await;
252				return Err(SubscribeError::LeaseFailed {
253					code,
254					message: e.to_string(),
255				});
256			}
257		};
258
259		if let Err(err) = run_member_hydrate(
260			state,
261			registry,
262			&sink,
263			subscription_id,
264			&rql,
265			identity,
266			lease,
267			max_rows,
268			format,
269		)
270		.await
271		{
272			return Err(err.into());
273		}
274	} else {
275		let _ = registry.promote_to_live(subscription_id);
276	}
277
278	debug!(
279		"Connection {} subscribed: subscription_id={} hydration_enabled={}",
280		connection_id, subscription_id, hydration.enabled
281	);
282
283	Ok(SubscribeAck {
284		subscription_id,
285		remote_handle: None,
286	})
287}
288
289#[inline]
290#[allow(clippy::too_many_arguments)]
291async fn handle_subscribe_remote<S: WireSink>(
292	connection_id: ConnectionId,
293	sink: S,
294	format: S::Format,
295	shutdown: WatchReceiver<bool>,
296	address: String,
297	body: String,
298	ns_token: Option<String>,
299	hydration: HydrationConfig,
300	throttle: Option<Duration>,
301	linger: Option<Duration>,
302) -> Result<SubscribeAck, SubscribeError> {
303	let client_format = S::client_wire_format(format);
304	let config = ClientSubscriptionConfig {
305		hydration: ClientHydrationConfig {
306			enabled: hydration.enabled,
307			max_rows: hydration.max_rows,
308		},
309		throttle,
310		linger,
311	};
312	let remote_sub = connect_remote(&address, &body, config, ns_token.as_deref(), client_format)
313		.await
314		.map_err(|e| SubscribeError::RemoteConnect(e.to_string()))?;
315	let remote_id = remote_sub.subscription_id().to_string();
316	let subscription_id = SubscriptionId(remote_id.parse::<u64>().map_err(|_| SubscribeError::InvalidRemoteId)?);
317
318	if !matches!(sink.send_subscribed(subscription_id), DeliveryResult::Delivered) {
319		return Err(SubscribeError::LeaseFailed {
320			code: "STREAM_CLOSED",
321			message: "Client stream closed before Subscribed could be delivered".to_string(),
322		});
323	}
324
325	let sink_for_proxy = sink.clone();
326	let sink_for_close = sink.clone();
327	let handle = spawn(async move {
328		proxy_remote_to_sink(remote_sub, shutdown, move |payload| {
329			matches!(
330				sink_for_proxy.send_remote_change(subscription_id, payload, format),
331				DeliveryResult::Delivered
332			)
333		})
334		.await;
335		let _ = sink_for_close.send_closed(subscription_id);
336	});
337
338	debug!("Connection {} subscribed to remote: subscription_id={}", connection_id, subscription_id);
339
340	Ok(SubscribeAck {
341		subscription_id,
342		remote_handle: Some(handle),
343	})
344}
345
346#[allow(clippy::too_many_arguments)]
347pub async fn handle_batch_subscribe<S: WireSink>(
348	state: &AppState,
349	connection_id: ConnectionId,
350	identity: IdentityId,
351	queries: &[String],
352	sink: S,
353	registry: &Arc<SubscriptionRegistry<S>>,
354	format: S::Format,
355	shutdown: WatchReceiver<bool>,
356	metadata: RequestMetadata,
357) -> Result<BatchAck, BatchSubscribeError> {
358	if queries.is_empty() {
359		return Err(BatchSubscribeError::Empty);
360	}
361
362	let (resolved, local_hydrations, member_lingers) =
363		resolve_batch_members::<S>(state, identity, queries, format, &metadata).await?;
364
365	let server_cap = state.subscribe_max_hydration_rows();
366	let effective_max_rows = compute_effective_max_rows(&local_hydrations, server_cap);
367	register_local_members(
368		state,
369		connection_id,
370		&sink,
371		registry,
372		format,
373		&resolved,
374		&local_hydrations,
375		&effective_max_rows,
376		server_cap,
377	);
378
379	let (batch_id, members_for_ack, remote_members_taken) =
380		register_batch_and_ack(state, connection_id, &sink, registry, format, resolved, &member_lingers)
381			.await?;
382
383	hydrate_batch_locals(
384		state,
385		identity,
386		&sink,
387		registry,
388		format,
389		batch_id,
390		&local_hydrations,
391		&effective_max_rows,
392		server_cap,
393	)
394	.await?;
395
396	let remote_handles = spawn_batch_remote_proxies(registry, batch_id, remote_members_taken, &shutdown);
397
398	debug!("Connection {} created batch {} with {} members", connection_id, batch_id, members_for_ack.len());
399
400	Ok(BatchAck {
401		batch_id,
402		members: members_for_ack,
403		remote_handles,
404	})
405}
406
407type LocalHydration = (SubscriptionId, String, HydrationConfig, Option<Duration>);
408type ResolvedBatch = (Vec<ResolvedBatchMember>, Vec<LocalHydration>, HashMap<SubscriptionId, Duration>);
409type BatchAckParts = (BatchId, Vec<BatchMemberInfo>, Vec<(SubscriptionId, RemoteSubscription)>);
410
411#[inline]
412async fn resolve_batch_members<S: WireSink>(
413	state: &AppState,
414	identity: IdentityId,
415	queries: &[String],
416	format: S::Format,
417	metadata: &RequestMetadata,
418) -> Result<ResolvedBatch, BatchSubscribeError> {
419	let mut resolved: Vec<ResolvedBatchMember> = Vec::with_capacity(queries.len());
420	let mut local_hydrations: Vec<LocalHydration> = Vec::new();
421	let mut member_lingers: HashMap<SubscriptionId, Duration> = HashMap::new();
422
423	for (index, user_rql) in queries.iter().enumerate() {
424		match create_subscription(state, identity, user_rql, metadata.clone()).await {
425			Ok(CreateSubscriptionResult::Local {
426				id: subscription_id,
427				hydration,
428				throttle,
429				linger,
430			}) => {
431				member_lingers.insert(subscription_id, state.clamp_linger(linger));
432				local_hydrations.push((subscription_id, user_rql.clone(), hydration, throttle));
433				resolved.push(ResolvedBatchMember::Local {
434					index,
435					subscription_id,
436					query: user_rql.clone(),
437				});
438			}
439			Ok(CreateSubscriptionResult::Remote {
440				address,
441				body,
442				token: ns_token,
443				hydration,
444				throttle,
445				linger,
446			}) => {
447				let client_format = S::client_wire_format(format);
448				let config = ClientSubscriptionConfig {
449					hydration: ClientHydrationConfig {
450						enabled: hydration.enabled,
451						max_rows: hydration.max_rows,
452					},
453					throttle,
454					linger,
455				};
456				let remote_sub = match connect_remote(
457					&address,
458					&body,
459					config,
460					ns_token.as_deref(),
461					client_format,
462				)
463				.await
464				{
465					Ok(s) => s,
466					Err(e) => {
467						rollback_batch_members(state, &resolved).await;
468						return Err(BatchSubscribeError::RemoteConnect(e.to_string()));
469					}
470				};
471				let remote_id = remote_sub.subscription_id().to_string();
472				let subscription_id = match remote_id.parse::<u64>() {
473					Ok(n) => SubscriptionId(n),
474					Err(_) => {
475						rollback_batch_members(state, &resolved).await;
476						return Err(BatchSubscribeError::InvalidRemoteId);
477					}
478				};
479				member_lingers.insert(subscription_id, state.clamp_linger(linger));
480				resolved.push(ResolvedBatchMember::Remote {
481					index,
482					subscription_id,
483					remote_sub: Box::new(remote_sub),
484				});
485			}
486			Err(e) => {
487				rollback_batch_members(state, &resolved).await;
488				return Err(BatchSubscribeError::Create(e));
489			}
490		}
491	}
492
493	reifydb_assertions! {
494		let resolved_len = resolved.len();
495		let query_len = queries.len();
496		assert!(
497			resolved_len == query_len,
498			"every query must resolve to exactly one batch member (resolved={resolved_len}, \
499			 queries={query_len}); a count mismatch desyncs member indices from the \
500			 BatchSubscribed ack and misroutes change frames to the wrong subscription"
501		);
502	}
503
504	Ok((resolved, local_hydrations, member_lingers))
505}
506
507#[inline]
508fn compute_effective_max_rows(local_hydrations: &[LocalHydration], server_cap: u64) -> HashMap<SubscriptionId, u64> {
509	let mut effective_max_rows: HashMap<SubscriptionId, u64> = HashMap::new();
510	for (sub_id, _, hydration, _) in local_hydrations {
511		let max_rows = match hydration.max_rows {
512			Some(n) if n > server_cap => {
513				warn!("clamping hydration.max_rows from {} to server cap {}", n, server_cap);
514				server_cap
515			}
516			Some(n) => n,
517			None => server_cap,
518		};
519		effective_max_rows.insert(*sub_id, max_rows);
520	}
521	effective_max_rows
522}
523
524#[inline]
525#[allow(clippy::too_many_arguments)]
526fn register_local_members<S: WireSink>(
527	state: &AppState,
528	connection_id: ConnectionId,
529	sink: &S,
530	registry: &Arc<SubscriptionRegistry<S>>,
531	format: S::Format,
532	resolved: &[ResolvedBatchMember],
533	local_hydrations: &[LocalHydration],
534	effective_max_rows: &HashMap<SubscriptionId, u64>,
535	server_cap: u64,
536) {
537	for member in resolved {
538		if let ResolvedBatchMember::Local {
539			subscription_id,
540			query,
541			..
542		} = member
543		{
544			let warming_cap = local_hydrations
545				.iter()
546				.find(|(sid, _, _, _)| sid == subscription_id)
547				.and_then(|(_, _, h, _)| {
548					if h.enabled {
549						Some(*effective_max_rows.get(subscription_id).unwrap_or(&server_cap)
550							as usize)
551					} else {
552						None
553					}
554				});
555			let throttle = state.clamp_throttle(
556				local_hydrations
557					.iter()
558					.find(|(sid, _, _, _)| sid == subscription_id)
559					.and_then(|(_, _, _, t)| *t),
560			);
561			registry.subscribe(
562				*subscription_id,
563				connection_id,
564				query.clone(),
565				sink.clone(),
566				format,
567				warming_cap,
568				throttle,
569			);
570		}
571	}
572}
573
574#[inline]
575#[allow(clippy::too_many_arguments)]
576async fn register_batch_and_ack<S: WireSink>(
577	state: &AppState,
578	connection_id: ConnectionId,
579	sink: &S,
580	registry: &Arc<SubscriptionRegistry<S>>,
581	format: S::Format,
582	resolved: Vec<ResolvedBatchMember>,
583	member_lingers: &HashMap<SubscriptionId, Duration>,
584) -> Result<BatchAckParts, BatchSubscribeError> {
585	let members: Vec<(SubscriptionId, Duration)> = resolved
586		.iter()
587		.map(|m| {
588			let id = m.subscription_id();
589			(id, member_lingers.get(&id).copied().unwrap_or(Duration::zero()))
590		})
591		.collect();
592	let batch_id =
593		registry.register_batch(connection_id, members, sink.clone(), format, state.clock(), state.rng());
594
595	let mut remote_members_taken: Vec<(SubscriptionId, RemoteSubscription)> = Vec::new();
596	let mut members_for_ack: Vec<BatchMemberInfo> = Vec::with_capacity(resolved.len());
597	for member in resolved {
598		members_for_ack.push(BatchMemberInfo {
599			index: member.index(),
600			subscription_id: member.subscription_id(),
601		});
602		if let ResolvedBatchMember::Remote {
603			subscription_id,
604			remote_sub,
605			..
606		} = member
607		{
608			remote_members_taken.push((subscription_id, *remote_sub));
609		}
610	}
611
612	let subscribed_members: Vec<BatchSubscribedMember> = members_for_ack
613		.iter()
614		.map(|m| BatchSubscribedMember {
615			index: m.index,
616			subscription_id: m.subscription_id,
617		})
618		.collect();
619	if !matches!(sink.send_batch_subscribed(batch_id, &subscribed_members), DeliveryResult::Delivered) {
620		registry.unsubscribe_batch(batch_id);
621		rollback_batch_members(state, &[]).await;
622		return Err(BatchSubscribeError::LeaseFailed {
623			code: "STREAM_CLOSED",
624			message: "Client stream closed before BatchSubscribed could be delivered".to_string(),
625		});
626	}
627
628	Ok((batch_id, members_for_ack, remote_members_taken))
629}
630
631#[inline]
632#[allow(clippy::too_many_arguments)]
633async fn hydrate_batch_locals<S: WireSink>(
634	state: &AppState,
635	identity: IdentityId,
636	sink: &S,
637	registry: &Arc<SubscriptionRegistry<S>>,
638	format: S::Format,
639	batch_id: BatchId,
640	local_hydrations: &[LocalHydration],
641	effective_max_rows: &HashMap<SubscriptionId, u64>,
642	server_cap: u64,
643) -> Result<(), BatchSubscribeError> {
644	let any_hydration = local_hydrations.iter().any(|(_, _, h, _)| h.enabled);
645	if any_hydration {
646		let lease = match state.engine().acquire_current_snapshot_lease() {
647			Ok((_, lease)) => lease,
648			Err(e) => {
649				let code = if e.0.code == "TXN_012" {
650					"HYDRATION_VERSION_EVICTED"
651				} else {
652					"PIN_VERSION_FAILED"
653				};
654				registry.unsubscribe_batch(batch_id);
655				return Err(BatchSubscribeError::LeaseFailed {
656					code,
657					message: e.to_string(),
658				});
659			}
660		};
661		for (sub_id, rql, hydration, _) in local_hydrations {
662			if !hydration.enabled {
663				continue;
664			}
665			let max_rows = *effective_max_rows.get(sub_id).unwrap_or(&server_cap);
666			if let Err(err) = run_member_hydrate(
667				state,
668				registry,
669				sink,
670				*sub_id,
671				rql,
672				identity,
673				lease.clone(),
674				max_rows,
675				format,
676			)
677			.await
678			{
679				registry.unsubscribe_batch(batch_id);
680				return Err(err.into_batch());
681			}
682		}
683	} else {
684		for (sub_id, _, _, _) in local_hydrations {
685			let _ = registry.promote_to_live(*sub_id);
686		}
687	}
688	Ok(())
689}
690
691#[inline]
692fn spawn_batch_remote_proxies<S: WireSink>(
693	registry: &Arc<SubscriptionRegistry<S>>,
694	batch_id: BatchId,
695	remote_members_taken: Vec<(SubscriptionId, RemoteSubscription)>,
696	shutdown: &WatchReceiver<bool>,
697) -> Vec<JoinHandle<()>> {
698	let mut remote_handles: Vec<JoinHandle<()>> = Vec::with_capacity(remote_members_taken.len());
699	for (subscription_id, remote_sub) in remote_members_taken {
700		let registry_clone = Arc::clone(registry);
701		let proxy_shutdown = shutdown.clone();
702		let handle = spawn(async move {
703			run_batch_remote_proxy(registry_clone, batch_id, subscription_id, remote_sub, proxy_shutdown)
704				.await;
705		});
706		remote_handles.push(handle);
707	}
708	remote_handles
709}
710
711pub async fn handle_batch_unsubscribe<S: WireSink>(
712	state: &AppState,
713	registry: &Arc<SubscriptionRegistry<S>>,
714	batch_id: BatchId,
715) -> Option<Vec<SubscriptionId>> {
716	let members = registry.unsubscribe_batch(batch_id)?;
717	for subscription_id in &members {
718		if let Err(e) = cleanup_subscription(state, *subscription_id).await {
719			warn!("Failed to cleanup batch member subscription {} from database: {:?}", subscription_id, e);
720		}
721	}
722	Some(members)
723}
724
725async fn run_batch_remote_proxy<S: WireSink>(
726	registry: Arc<SubscriptionRegistry<S>>,
727	batch_id: BatchId,
728	subscription_id: SubscriptionId,
729	remote_sub: RemoteSubscription,
730	shutdown: WatchReceiver<bool>,
731) {
732	let registry_push = Arc::clone(&registry);
733	proxy_remote_to_sink(remote_sub, shutdown, move |payload| {
734		let frames = payload.into_frames();
735		registry_push.push_batch_frames(batch_id, subscription_id, frames)
736	})
737	.await;
738	let _ = registry.emit_batch_member_closed(batch_id, subscription_id);
739}
740
741#[allow(clippy::too_many_arguments)]
742async fn run_member_hydrate<S: WireSink>(
743	state: &AppState,
744	registry: &Arc<SubscriptionRegistry<S>>,
745	sink: &S,
746	subscription_id: SubscriptionId,
747	rql: &str,
748	identity: IdentityId,
749	lease: VersionLeaseGuard,
750	max_rows: u64,
751	format: S::Format,
752) -> Result<(), MemberHydrateError> {
753	let service: SubscriptionServiceRef = match state.engine().services().ioc.resolve() {
754		Ok(s) => s,
755		Err(e) => {
756			abort_warming(state, registry, subscription_id).await;
757			return Err(MemberHydrateError {
758				rql: rql.to_string(),
759				max_rows,
760				kind: MemberHydrateErrorKind::ServiceUnavailable(e.to_string()),
761			});
762		}
763	};
764
765	let engine = state.engine_clone();
766
767	let (version, outcome): (CommitVersion, Option<(Columns, ExecutionMetrics)>) =
768		match run_hydrate(service, engine, subscription_id, identity, lease, max_rows).await {
769			Ok(t) => t,
770			Err(err) => {
771				abort_warming(state, registry, subscription_id).await;
772				return Err(MemberHydrateError {
773					rql: rql.to_string(),
774					max_rows,
775					kind: MemberHydrateErrorKind::Failed(err),
776				});
777			}
778		};
779
780	if let Some((cols, metrics)) = outcome {
781		let row_count = cols.row_count();
782		debug!(
783			subscription_id = subscription_id.0,
784			version = version.0,
785			total_us = metrics.total.microseconds().unwrap_or(0),
786			compute_us = metrics.compute.microseconds().unwrap_or(0),
787			statement_count = metrics.statements.len(),
788			row_count = row_count,
789			fingerprint = %metrics.fingerprint.to_hex(),
790			"hydrate completed"
791		);
792		if let Some(batch_id) = registry.batch_for(&subscription_id) {
793			let _ = sink.send_batch_envelope(
794				batch_id,
795				format,
796				vec![(subscription_id, vec![Frame::from(cols)])],
797			);
798		} else {
799			let _ = sink.send_change(subscription_id, cols, format);
800		}
801	}
802
803	match registry.promote_to_live(subscription_id) {
804		PromoteResult::Promoted(_)
805		| PromoteResult::NotWarming
806		| PromoteResult::NotFound
807		| PromoteResult::Disconnected => Ok(()),
808		PromoteResult::Overflowed => Err(MemberHydrateError {
809			rql: rql.to_string(),
810			max_rows,
811			kind: MemberHydrateErrorKind::Backpressure,
812		}),
813	}
814}
815
816struct MemberHydrateError {
817	rql: String,
818	max_rows: u64,
819	kind: MemberHydrateErrorKind,
820}
821
822enum MemberHydrateErrorKind {
823	ServiceUnavailable(String),
824	Failed(HydrateError),
825	Backpressure,
826}
827
828impl MemberHydrateError {
829	fn into_batch(self) -> BatchSubscribeError {
830		match self.kind {
831			MemberHydrateErrorKind::ServiceUnavailable(msg) => {
832				BatchSubscribeError::HydrationServiceUnavailable(msg)
833			}
834			MemberHydrateErrorKind::Failed(err) => BatchSubscribeError::HydrationFailed {
835				error: err,
836				rql: self.rql,
837				max_rows: self.max_rows,
838			},
839			MemberHydrateErrorKind::Backpressure => BatchSubscribeError::HydrationBackpressure,
840		}
841	}
842}
843
844impl From<MemberHydrateError> for SubscribeError {
845	fn from(e: MemberHydrateError) -> Self {
846		match e.kind {
847			MemberHydrateErrorKind::ServiceUnavailable(msg) => {
848				SubscribeError::HydrationServiceUnavailable(msg)
849			}
850			MemberHydrateErrorKind::Failed(err) => SubscribeError::HydrationFailed {
851				error: err,
852				rql: e.rql,
853				max_rows: e.max_rows,
854			},
855			MemberHydrateErrorKind::Backpressure => SubscribeError::HydrationBackpressure,
856		}
857	}
858}
859
860async fn rollback_batch_members(state: &AppState, resolved: &[ResolvedBatchMember]) {
861	for member in resolved {
862		if let ResolvedBatchMember::Local {
863			subscription_id,
864			..
865		} = member && let Err(e) = cleanup_subscription(state, *subscription_id).await
866		{
867			warn!("Failed to cleanup partial batch member {} during rollback: {:?}", subscription_id, e);
868		}
869	}
870}
871
872async fn abort_warming<S: WireSink>(
873	state: &AppState,
874	registry: &Arc<SubscriptionRegistry<S>>,
875	subscription_id: SubscriptionId,
876) {
877	registry.unsubscribe(subscription_id);
878	if let Err(e) = cleanup_subscription(state, subscription_id).await {
879		warn!("Failed to cleanup subscription {} after hydrate failure: {:?}", subscription_id, e);
880	}
881}