photon_runtime/photon.rs
1//! Main Photon runtime handle — publish, subscribe, and executor control.
2//!
3//! See the crate [Getting started](https://docs.rs/uf-photon/latest/photon/#getting-started)
4//! for Embedded and Brokered (publisher / worker) walkthroughs.
5
6use std::sync::Arc;
7
8use futures::stream::Stream;
9use photon_core::IdentityFactory;
10
11use photon_backend::{
12 BackendCapabilities, Event, ExecutorServices, PhotonBackend, ReclaimReport, Result,
13 StoragePort, TopicRegistry,
14};
15
16use crate::admin::collect_admin_snapshot;
17use crate::admin::AdminSnapshot;
18
19use crate::executor::ExecutorController;
20
21/// Shared storage port, executor services, and handler dispatch controller.
22#[derive(Clone)]
23pub struct PhotonRuntimeState {
24 /// Storage port used by executor checkpoint/retention services.
25 pub storage_port: Arc<dyn StoragePort>,
26 /// Services used by durable handler executors.
27 pub executor_services: Arc<ExecutorServices>,
28 /// Handler dispatch controller.
29 pub executor: Arc<ExecutorController>,
30}
31
32/// Main Photon runtime handle.
33///
34/// Keep this value alive for the lifetime of the process that publishes or runs handlers.
35/// Build it once with [`Photon::builder`], pass it to `publish_on` / `subscribe_on`, and call
36/// [`start_executor`](Self::start_executor) on Embedded hosts and Brokered **worker** binaries.
37///
38/// | Role | What to call |
39/// |------|----------------|
40/// | Publisher (Brokered) | `publish_on(&photon)` — usually **no** executor |
41/// | Worker (Brokered) | [`start_executor`](Self::start_executor) + `#[subscribe]` |
42/// | Embedded | both publish and [`start_executor`](Self::start_executor) |
43///
44/// Getting started: [Embedded](https://docs.rs/uf-photon/latest/photon/#embedded-one-binary),
45/// [Brokered](https://docs.rs/uf-photon/latest/photon/#brokered-publisher--worker-binaries).
46///
47/// # Example
48///
49/// ```rust,no_run
50/// use std::sync::Arc;
51///
52/// use photon_core::JsonIdentityFactory;
53/// use photon_runtime::Photon;
54///
55/// # fn main() -> photon_backend::Result<()> {
56/// let photon = Photon::builder().auto_registry().build()?;
57/// photon.start_executor(Arc::new(JsonIdentityFactory))?;
58/// # let _ = photon;
59/// # Ok(())
60/// # }
61/// ```
62#[derive(Clone)]
63pub struct Photon {
64 backend: Arc<dyn PhotonBackend>,
65 runtime: PhotonRuntimeState,
66}
67
68static DEFAULT_PHOTON: std::sync::RwLock<Option<Photon>> = std::sync::RwLock::new(None);
69
70/// Configure the default Photon instance used by macro-generated convenience helpers
71/// (`Type::publish()` / `Type::subscribe()`).
72///
73/// Prefer passing an explicit [`Photon`] handle via `publish_on` / `subscribe_on` or
74/// [`Photon::publish`]. This process-wide shim is optional sugar for simple hosts.
75///
76/// # Example
77///
78/// ```rust,no_run
79/// use std::sync::Arc;
80///
81/// use photon_core::JsonIdentityFactory;
82/// use photon_runtime::{configure, Photon};
83///
84/// # fn main() -> photon_backend::Result<()> {
85/// let photon = Photon::builder().auto_registry().build()?;
86/// photon.start_executor(Arc::new(JsonIdentityFactory))?;
87/// configure(photon);
88/// # Ok(())
89/// # }
90/// ```
91///
92/// Recovers from a poisoned lock so a prior panicking holder cannot brick configure.
93pub fn configure(photon: Photon) {
94 let mut guard = DEFAULT_PHOTON
95 .write()
96 .unwrap_or_else(std::sync::PoisonError::into_inner);
97 *guard = Some(photon);
98}
99
100/// Clone of the process-wide Photon set by [`configure`], if any.
101///
102/// Prefer an explicit [`Photon`] handle. This exists for macro convenience helpers.
103///
104/// Recovers from a poisoned lock so a prior panicking holder cannot brick lookup.
105pub fn default() -> Option<Photon> {
106 let guard = DEFAULT_PHOTON
107 .read()
108 .unwrap_or_else(std::sync::PoisonError::into_inner);
109 guard.clone()
110}
111
112impl Photon {
113 pub(crate) fn new(backend: Arc<dyn PhotonBackend>, runtime: PhotonRuntimeState) -> Self {
114 Self { backend, runtime }
115 }
116
117 /// Start building a Photon runtime instance.
118 ///
119 /// See [`crate::builder::PhotonBuilder`] for Embedded / Brokered wiring.
120 #[must_use]
121 pub fn builder() -> crate::builder::PhotonBuilder {
122 crate::builder::PhotonBuilder::default()
123 }
124
125 /// Telemetry label for the installed backend.
126 #[must_use]
127 pub fn backend_label(&self) -> &'static str {
128 self.backend.telemetry_label()
129 }
130
131 pub(crate) fn backend_capabilities(&self) -> BackendCapabilities {
132 PhotonBackend::capabilities(self.backend.as_ref())
133 }
134
135 /// Compose a read-only ops introspection snapshot for host admin UIs.
136 ///
137 /// Aggregates the topic catalog, handler inventory, backend capabilities, and checkpoint
138 /// cursors for inventory-registered handlers. Does not touch publish/subscribe hot paths.
139 ///
140 /// # Errors
141 ///
142 /// Returns an error if a checkpoint load fails.
143 pub async fn admin_snapshot(&self) -> Result<AdminSnapshot> {
144 collect_admin_snapshot(self).await
145 }
146
147 /// Publish a single event to a topic by name (low-level).
148 ///
149 /// Prefer the typed API generated by [`topic`](https://docs.rs/uf-photon/latest/photon/attr.topic.html):
150 /// `EventType { … }.publish_on(&photon).await`.
151 ///
152 /// # Example
153 ///
154 /// ```rust,ignore
155 /// // After #[topic(name = "orders.created")] on OrderCreated:
156 /// OrderCreated {
157 /// order_id: "ord-1".into(),
158 /// amount_cents: 9900,
159 /// }
160 /// .publish_on(&photon)
161 /// .await?;
162 /// ```
163 ///
164 /// # Errors
165 ///
166 /// Returns an error if the storage adapter rejects the append.
167 pub async fn publish(
168 &self,
169 topic_name: &str,
170 topic_key: Option<&str>,
171 actor_json: serde_json::Value,
172 payload_json: serde_json::Value,
173 ) -> Result<String> {
174 PhotonBackend::publish(
175 self.backend.as_ref(),
176 topic_name,
177 topic_key,
178 actor_json,
179 payload_json,
180 )
181 .await
182 }
183
184 /// Subscribe to topic events as a raw JSON stream (low-level).
185 ///
186 /// Prefer the typed API from [`topic`](https://docs.rs/uf-photon/latest/photon/attr.topic.html):
187 /// `EventType::subscribe_on(&photon, opts)`, or inventory handlers via `#[subscribe]` +
188 /// [`start_executor`](Self::start_executor).
189 ///
190 /// Runnable typed stream: `cargo run -p uf-photon --example keyed_topic --features runtime,mem`.
191 /// Runnable raw stream: `cargo run -p uf-photon --example manual_subscribe --features runtime,mem`.
192 ///
193 /// # Example (typed — preferred)
194 ///
195 /// ```rust,ignore
196 /// use futures::StreamExt;
197 /// use photon::{SubscribeOpts, topic};
198 ///
199 /// #[topic(name = "orders.created")]
200 /// struct OrderCreated { order_id: String }
201 ///
202 /// # async fn demo(photon: &photon::Photon) -> photon::Result<()> {
203 /// let mut stream = OrderCreated::subscribe_on(
204 /// photon,
205 /// SubscribeOpts::default_ephemeral(),
206 /// )
207 /// .await?;
208 /// if let Some(Ok(envelope)) = stream.next().await {
209 /// let _ = envelope.payload.order_id;
210 /// }
211 /// # Ok(())
212 /// # }
213 /// ```
214 #[must_use]
215 pub fn subscribe(
216 &self,
217 topic_name: &str,
218 topic_key_filter: Option<&str>,
219 after_seq: Option<i64>,
220 ) -> std::pin::Pin<Box<dyn Stream<Item = Result<Event>> + Send>> {
221 PhotonBackend::subscribe(
222 self.backend.as_ref(),
223 topic_name.to_string(),
224 topic_key_filter.map(std::string::ToString::to_string),
225 after_seq,
226 )
227 }
228
229 /// Subscribe to assigned virtual shards for a consumer group (multiplexed stream).
230 #[must_use]
231 pub fn subscribe_consumer_group(
232 &self,
233 topic_name: &str,
234 shard_ids: &[u32],
235 after_seq_by_shard: std::collections::HashMap<u32, Option<i64>>,
236 ) -> std::pin::Pin<Box<dyn Stream<Item = Result<Event>> + Send>> {
237 photon_backend::merge_shard_streams(
238 Arc::clone(&self.backend),
239 topic_name.to_string(),
240 shard_ids,
241 after_seq_by_shard,
242 )
243 }
244
245 /// Load a specific event by ID.
246 ///
247 /// # Errors
248 ///
249 /// Returns an error if the operation fails.
250 pub async fn get_event(&self, event_id: &str) -> Result<Option<Event>> {
251 PhotonBackend::get_event(self.backend.as_ref(), event_id).await
252 }
253
254 /// Bounded page of events for one topic (ops browse).
255 ///
256 /// Returns an empty vec when the storage adapter does not support listing
257 /// (`supports_list_events` is false).
258 ///
259 /// # Errors
260 ///
261 /// Returns an error if the topic name is invalid or storage fails.
262 pub async fn list_events_by_topic(
263 &self,
264 topic_name: &str,
265 topic_key: Option<&str>,
266 after_seq: Option<i64>,
267 limit: usize,
268 ) -> Result<Vec<Event>> {
269 PhotonBackend::list_by_topic(
270 self.backend.as_ref(),
271 topic_name,
272 topic_key,
273 after_seq,
274 limit,
275 )
276 .await
277 }
278
279 /// Bounded cross-topic page of newest events (ops browse).
280 ///
281 /// Returns an empty vec when the storage adapter does not support listing.
282 ///
283 /// # Errors
284 ///
285 /// Returns an error if storage fails.
286 pub async fn list_recent_events(&self, limit: usize) -> Result<Vec<Event>> {
287 PhotonBackend::list_recent(self.backend.as_ref(), limit).await
288 }
289
290 /// Return the registered topic catalog.
291 #[must_use]
292 pub fn registry(&self) -> &TopicRegistry {
293 PhotonBackend::registry(self.backend.as_ref())
294 }
295
296 /// Read the last checkpoint sequence for a subscription/topic pair.
297 ///
298 /// # Errors
299 ///
300 /// Returns an error if the operation fails.
301 pub async fn get_checkpoint_seq(
302 &self,
303 subscription_name: &str,
304 topic_name: &str,
305 topic_key: Option<&str>,
306 ) -> Result<Option<i64>> {
307 PhotonBackend::get_checkpoint_seq(
308 self.backend.as_ref(),
309 subscription_name,
310 topic_name,
311 topic_key,
312 )
313 .await
314 }
315
316 /// Persist an updated checkpoint sequence for a subscription/topic pair.
317 ///
318 /// # Errors
319 ///
320 /// Returns an error if the operation fails.
321 pub async fn set_checkpoint(
322 &self,
323 subscription_name: &str,
324 topic_name: &str,
325 topic_key: Option<&str>,
326 last_seq: i64,
327 ) -> Result<()> {
328 PhotonBackend::set_checkpoint(
329 self.backend.as_ref(),
330 subscription_name,
331 topic_name,
332 topic_key,
333 last_seq,
334 )
335 .await
336 }
337
338 /// Shared tailer / executor services.
339 #[must_use]
340 pub const fn runtime(&self) -> &PhotonRuntimeState {
341 &self.runtime
342 }
343
344 /// Reclaim transport log rows past the safe watermark (ops / retention entry point).
345 ///
346 /// Call periodically (or from a headless ops job) after durable subscribers have advanced
347 /// checkpoints. Retention knobs: crate [`config`](https://docs.rs/uf-photon/latest/photon/config/)
348 /// (`PHOTON_TRANSPORT_*` / builder [`retention_policy`](crate::builder::PhotonBuilder::retention_policy)).
349 ///
350 /// # Errors
351 ///
352 /// Returns an error if a storage reclaim operation fails.
353 pub async fn reclaim_transport(&self) -> Result<Vec<ReclaimReport>> {
354 self.runtime
355 .executor_services
356 .retention_reclaimer
357 .sweep_all()
358 .await
359 }
360
361 /// Start inventory-registered `#[photon::subscribe]` handlers.
362 ///
363 /// Required on **Embedded** hosts and **Brokered worker** binaries. Publisher-only Brokered
364 /// processes typically skip this. Requires an [`IdentityFactory`] (e.g.
365 /// [`photon_core::JsonIdentityFactory`] for examples/tests) for actor reconstruction.
366 ///
367 /// See [Getting started → Brokered](https://docs.rs/uf-photon/latest/photon/#brokered-publisher--worker-binaries).
368 ///
369 /// # Example
370 ///
371 /// ```rust,no_run
372 /// use std::sync::Arc;
373 ///
374 /// use photon_core::JsonIdentityFactory;
375 /// use photon_runtime::Photon;
376 ///
377 /// # async fn boot() -> photon_backend::Result<()> {
378 /// let photon = Photon::builder().auto_registry().build()?;
379 /// photon.start_executor(Arc::new(JsonIdentityFactory))?;
380 /// photon.shutdown_executor();
381 /// photon.join_executor().await;
382 /// # Ok(())
383 /// # }
384 /// ```
385 ///
386 /// # Errors
387 ///
388 /// Returns an error if the executor was already started on this runtime.
389 #[allow(clippy::needless_pass_by_value)] // Arc-by-value is the public ownership API
390 pub fn start_executor(&self, identity: Arc<dyn IdentityFactory>) -> Result<()> {
391 self.runtime.executor.start(self, &identity)
392 }
393
394 /// Signal handler loops to stop accepting new events.
395 ///
396 /// # Contract
397 ///
398 /// Idempotent. Pair with [`Self::join_executor`] to await in-flight work.
399 pub fn shutdown_executor(&self) {
400 self.runtime.executor.shutdown();
401 }
402
403 /// Await handler loops and in-flight dispatches after [`Self::shutdown_executor`].
404 ///
405 /// # Contract
406 ///
407 /// Safe when the executor was never started. Restart requires a new [`Photon`] build.
408 pub async fn join_executor(&self) {
409 self.runtime.executor.join().await;
410 }
411}