Skip to main content

photon_runtime/
executor.rs

1//! Handler executor: inventory dispatch loop for `#[photon::subscribe]` handlers.
2//!
3//! [`ExecutorController::start`] is invoked by [`Photon::start_executor`]. It auto-discovers
4//! [`photon_backend::HandlerDescriptor`] entries submitted by the proc macro, subscribes to each
5//! topic with durable checkpoint replay, and dispatches through [`photon_core::IdentityFactory`].
6//!
7//! Call [`ExecutorController::shutdown`] then [`ExecutorController::join`] for graceful teardown.
8
9use std::collections::HashMap;
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::sync::Arc;
12
13use futures::StreamExt;
14use photon_backend::{
15    consumer_group::instance_id_from_env,
16    consumer_group::static_assigned_shards,
17    consumer_group::{ConsumerGroupCoordinator, GroupMember, StaticGroupCoordinator},
18    delivery::DlqRecordParams,
19    delivery_mode::ShardConfig,
20    instrumentation::FailureReason,
21    HandlerDescriptor, HandlerRegistry, PhotonError, Result,
22};
23use photon_core::IdentityFactory;
24use tokio::task::{JoinHandle, JoinSet};
25use tokio_util::sync::CancellationToken;
26
27use crate::Photon;
28
29/// Controls background handler dispatch tasks started by [`Photon::start_executor`].
30pub struct ExecutorController {
31    started: AtomicBool,
32    cancel: CancellationToken,
33    tasks: std::sync::Mutex<Vec<JoinHandle<()>>>,
34}
35
36impl Default for ExecutorController {
37    fn default() -> Self {
38        Self {
39            started: AtomicBool::new(false),
40            cancel: CancellationToken::new(),
41            tasks: std::sync::Mutex::new(Vec::new()),
42        }
43    }
44}
45
46impl ExecutorController {
47    /// Spawn subscription loops for every inventory-registered handler.
48    ///
49    /// # Panics
50    ///
51    /// Panics if an internal lock is poisoned.
52    ///
53    /// # Errors
54    ///
55    /// Returns an error if the executor was already started on this controller.
56    ///
57    /// # Contract
58    ///
59    /// - Start is one-shot per controller; restart requires a new [`Photon`] build.
60    /// - Outer loops respect [`Self::shutdown`]; in-flight handler tasks are awaited in
61    ///   [`Self::join`].
62    pub fn start(
63        &self,
64        photon: &Photon,
65        identity: &Arc<dyn IdentityFactory>,
66    ) -> Result<()> {
67        if self
68            .started
69            .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
70            .is_err()
71        {
72            return Err(PhotonError::Internal(
73                "Photon executor already started".into(),
74            ));
75        }
76
77        let registry = HandlerRegistry::auto_discover();
78        if registry.is_empty() {
79            return Ok(());
80        }
81
82        let mut tasks = Vec::new();
83        for handler in registry.iter() {
84            let cancel = self.cancel.child_token();
85            let task = if handler.is_consumer_group() {
86                spawn_group_handler_loop(photon.clone(), Arc::clone(identity), handler, cancel)
87            } else {
88                spawn_handler_loop(photon.clone(), Arc::clone(identity), handler, cancel)
89            };
90            tasks.push(task);
91        }
92
93        *self.tasks.lock().unwrap() = tasks;
94        Ok(())
95    }
96
97    /// Signal all handler loops to stop accepting new events.
98    ///
99    /// # Contract
100    ///
101    /// - Idempotent: repeated calls are no-ops.
102    /// - Does not wait for in-flight handlers; call [`Self::join`] afterward.
103    pub fn shutdown(&self) {
104        self.cancel.cancel();
105    }
106
107    /// Await outer subscription loops (and their in-flight handler tasks).
108    ///
109    /// # Contract
110    ///
111    /// - Call [`Self::shutdown`] first for prompt exit from stream loops.
112    /// - Drains stored outer [`JoinHandle`]s; each loop drains its own in-flight [`JoinSet`]
113    ///   before exiting.
114    /// - Safe to call when no tasks were started (no-op).
115    ///
116    /// # Panics
117    ///
118    /// Panics if an internal lock is poisoned.
119    pub async fn join(&self) {
120        let tasks = std::mem::take(&mut *self.tasks.lock().unwrap());
121        for task in tasks {
122            let _ = task.await;
123        }
124    }
125}
126
127const fn failure_reason(err: &PhotonError) -> FailureReason {
128    match err {
129        PhotonError::Identity(_) => FailureReason::IdentityBuild,
130        _ => FailureReason::HandlerError,
131    }
132}
133
134fn shard_count_for_handler(photon: &Photon, handler: &'static HandlerDescriptor) -> u32 {
135    handler
136        .group_shard_count
137        .or_else(|| {
138            photon
139                .registry()
140                .get(handler.topic_name)
141                .and_then(|d| d.shard_config)
142                .map(|c| c.shard_count)
143        })
144        .unwrap_or(ShardConfig::DEFAULT_SHARD_COUNT)
145}
146
147fn spawn_group_handler_loop(
148    photon: Photon,
149    identity: Arc<dyn IdentityFactory>,
150    handler: &'static HandlerDescriptor,
151    cancel: CancellationToken,
152) -> JoinHandle<()> {
153    let topic = handler.topic_name;
154    let group_id = handler.consumer_group.unwrap_or("");
155    let invoke = handler.invoke;
156    let services = Arc::clone(&photon.runtime().executor_services);
157    let coordinator = StaticGroupCoordinator;
158
159    tokio::spawn(async move {
160        let shard_count = shard_count_for_handler(&photon, handler);
161        let instance_id = instance_id_from_env().unwrap_or_else(|| "0".into());
162        let assigned = coordinator
163            .register(GroupMember {
164                group_id: group_id.to_string(),
165                instance_id: instance_id.clone(),
166                topic_name: topic.to_string(),
167                shard_count,
168            })
169            .await
170            .unwrap_or_else(|_| static_assigned_shards(shard_count));
171
172        let mut after_seq_by_shard = HashMap::new();
173        for shard_id in &assigned {
174            let shard_key = photon_backend::shard_storage_key(*shard_id);
175            let seq = photon
176                .get_checkpoint_seq(group_id, topic, Some(&shard_key))
177                .await
178                .ok()
179                .flatten()
180                .unwrap_or(0);
181            after_seq_by_shard.insert(*shard_id, Some(seq));
182        }
183
184        let mut stream =
185            photon.subscribe_consumer_group(topic, &assigned, after_seq_by_shard);
186        let mut inflight = JoinSet::new();
187
188        loop {
189            tokio::select! {
190                biased;
191                () = cancel.cancelled() => break,
192                event_result = stream.next() => {
193                    let Some(event_result) = event_result else { break };
194                    match event_result {
195                        Ok(event) => {
196                            let permit = services.worker_pool.acquire().await;
197                            let identity = Arc::clone(&identity);
198                            let services = Arc::clone(&services);
199                            let group = group_id.to_string();
200
201                            inflight.spawn(async move {
202                                let _permit = permit;
203                                let event_id = event.event_id.clone();
204                                let topic_name = event.topic_name.clone();
205                                let topic_key = event.topic_key.clone();
206                                let seq = event.seq;
207                                let dispatch = invoke(identity.as_ref(), &event);
208                                match dispatch.await {
209                                    Ok(()) => {
210                                        if let Err(e) = services
211                                            .checkpoint_coalescer
212                                            .record(&group, &topic_name, topic_key.as_deref(), seq)
213                                            .await
214                                        {
215                                            let _ = services.dlq.record(&DlqRecordParams {
216                                                event_id: &event_id,
217                                                topic_name: &topic_name,
218                                                topic_key: topic_key.as_deref(),
219                                                seq,
220                                                subscription_name: Some(&group),
221                                                reason: FailureReason::CheckpointError,
222                                                error: e.to_string(),
223                                            });
224                                        }
225                                    }
226                                    Err(e) => {
227                                        let _ = services.dlq.record(&DlqRecordParams {
228                                            event_id: &event_id,
229                                            topic_name: &topic_name,
230                                            topic_key: topic_key.as_deref(),
231                                            seq,
232                                            subscription_name: Some(&group),
233                                            reason: failure_reason(&e),
234                                            error: e.to_string(),
235                                        });
236                                    }
237                                }
238                            });
239                        }
240                        Err(e) => {
241                            tracing::warn!(
242                                topic = topic,
243                                group = group_id,
244                                error = %e,
245                                "consumer group subscription stream error"
246                            );
247                        }
248                    }
249                }
250            }
251        }
252
253        while inflight.join_next().await.is_some() {}
254    })
255}
256
257fn spawn_handler_loop(
258    photon: Photon,
259    identity: Arc<dyn IdentityFactory>,
260    handler: &'static HandlerDescriptor,
261    cancel: CancellationToken,
262) -> JoinHandle<()> {
263    let topic = handler.topic_name;
264    let subscription_name = handler.subscription_name;
265    let invoke = handler.invoke;
266    let services = Arc::clone(&photon.runtime().executor_services);
267
268    tokio::spawn(async move {
269        // Durable handlers always replay from a cursor. Missing checkpoint → start at 0
270        // (not live-only `None`), so publishes that land before the subscribe handshake
271        // are still delivered from storage.
272        let after_seq = Some(
273            photon
274                .get_checkpoint_seq(subscription_name, topic, None)
275                .await
276                .ok()
277                .flatten()
278                .unwrap_or(0),
279        );
280
281        let mut stream = photon.subscribe(topic, None, after_seq);
282        let mut inflight = JoinSet::new();
283
284        loop {
285            tokio::select! {
286                biased;
287                () = cancel.cancelled() => break,
288                event_result = stream.next() => {
289                    let Some(event_result) = event_result else { break };
290                    match event_result {
291                        Ok(event) => {
292                            let permit = services.worker_pool.acquire().await;
293                            let identity = Arc::clone(&identity);
294                            let services = Arc::clone(&services);
295                            let sub_name = subscription_name.to_string();
296
297                            inflight.spawn(async move {
298                                let _permit = permit;
299                                let event_id = event.event_id.clone();
300                                let topic_name = event.topic_name.clone();
301                                let topic_key = event.topic_key.clone();
302                                let seq = event.seq;
303                                let dispatch = invoke(identity.as_ref(), &event);
304                                match dispatch.await {
305                                    Ok(()) => {
306                                        if let Err(e) = services
307                                            .checkpoint_coalescer
308                                            .record(&sub_name, &topic_name, topic_key.as_deref(), seq)
309                                            .await
310                                        {
311                                            let _ = services.dlq.record(&DlqRecordParams {
312                                                event_id: &event_id,
313                                                topic_name: &topic_name,
314                                                topic_key: topic_key.as_deref(),
315                                                seq,
316                                                subscription_name: Some(&sub_name),
317                                                reason: FailureReason::CheckpointError,
318                                                error: e.to_string(),
319                                            });
320                                        }
321                                    }
322                                    Err(e) => {
323                                        let _ = services.dlq.record(&DlqRecordParams {
324                                            event_id: &event_id,
325                                            topic_name: &topic_name,
326                                            topic_key: topic_key.as_deref(),
327                                            seq,
328                                            subscription_name: Some(&sub_name),
329                                            reason: failure_reason(&e),
330                                            error: e.to_string(),
331                                        });
332                                    }
333                                }
334                            });
335                        }
336                        Err(e) => {
337                            tracing::warn!(
338                                topic = topic,
339                                subscription = subscription_name,
340                                error = %e,
341                                "handler subscription stream error"
342                            );
343                        }
344                    }
345                }
346            }
347        }
348
349        while inflight.join_next().await.is_some() {}
350    })
351}