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