Skip to main content

lash_sqlite_store/
effect_replay.rs

1//! SQLite-backed runtime effect replay host (tokio-rusqlite port of the the prior store
2//! store's `effect_replay`).
3//!
4//! The public surface is identical to the prior implementation — same type names
5//! (`SqliteEffectHost`, `SqliteRuntimeEffectController`, `SqliteEffectReplayOptions`),
6//! same async signatures — so consumers swap backends with a path rename only.
7//! The only mechanical change is the database layer: the prior store ran every op directly
8//! on `&Connection` with `.await`; here every database body is a *synchronous*
9//! rusqlite closure handed to the shared [`SqliteConnection`] wrapper. The
10//! claim/finalize paths run inside `conn.write` (`BEGIN IMMEDIATE`) so the
11//! cross-process write lock is taken up front — the lease fence WAL gives us.
12
13use std::path::Path;
14use std::sync::Arc;
15use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
16use std::time::Duration;
17
18use lash_core::{
19    AwaitEventResolver, DurabilityTier, EffectHost, ExecutionScope, PluginError, ProcessCommand,
20    ProcessEffectOutcome, RuntimeEffectCommand, RuntimeEffectController,
21    RuntimeEffectControllerError, RuntimeEffectEnvelope, RuntimeEffectLocalExecutor,
22    RuntimeEffectOutcome, RuntimeError, ScopedEffectController,
23};
24
25use super::*;
26
27const STATUS_IN_PROGRESS: &str = "in_progress";
28const STATUS_COMPLETED: &str = "completed";
29const STATUS_FAILED: &str = "failed";
30const DEFAULT_LEASE_TTL: Duration = Duration::from_secs(30);
31const BUSY_POLL: Duration = Duration::from_millis(25);
32
33static EFFECT_OWNER_COUNTER: AtomicU64 = AtomicU64::new(1);
34
35/// Options for SQLite-backed runtime effect replay.
36#[derive(Clone, Debug)]
37pub struct SqliteEffectReplayOptions {
38    pub lease_ttl: Duration,
39}
40
41impl Default for SqliteEffectReplayOptions {
42    fn default() -> Self {
43        Self {
44            lease_ttl: DEFAULT_LEASE_TTL,
45        }
46    }
47}
48
49struct SqliteEffectReplayInner {
50    conn: SqliteConnection,
51    owner_id: String,
52    lease_counter: AtomicU64,
53    replay_mode: AtomicBool,
54    lease_ttl_ms: u64,
55}
56
57/// Deployment-level SQLite effect host.
58///
59/// This host persists runtime effect history in a local SQLite database and
60/// returns scoped controllers that replay completed outcomes by
61/// `(scope_id, replay_key)`.
62#[derive(Clone)]
63pub struct SqliteEffectHost {
64    inner: Arc<SqliteEffectReplayInner>,
65}
66
67/// Scoped SQLite-backed runtime effect controller.
68#[derive(Clone)]
69pub struct SqliteRuntimeEffectController {
70    inner: Arc<SqliteEffectReplayInner>,
71    scope: ExecutionScope,
72}
73
74struct ClaimedEffect {
75    scope_id: String,
76    replay_key: String,
77    envelope_hash: String,
78    lease_token: String,
79    due_at_ms: Option<u64>,
80}
81
82enum PreparedEffect {
83    ReplayOutcome {
84        outcome: Box<RuntimeEffectOutcome>,
85        due_at_ms: Option<u64>,
86    },
87    ReplayError(RuntimeEffectControllerError),
88    Claimed(ClaimedEffect),
89    Busy {
90        retry_at_ms: u64,
91    },
92}
93
94impl SqliteEffectHost {
95    pub async fn open(path: &Path) -> tokio_rusqlite::Result<Self> {
96        Self::open_with_options(path, SqliteEffectReplayOptions::default()).await
97    }
98
99    pub async fn open_with_options(
100        path: &Path,
101        options: SqliteEffectReplayOptions,
102    ) -> tokio_rusqlite::Result<Self> {
103        Ok(Self {
104            inner: open_effect_replay_inner(path, StoreBacking::File, options).await?,
105        })
106    }
107
108    pub async fn memory() -> tokio_rusqlite::Result<Self> {
109        Self::memory_with_options(SqliteEffectReplayOptions::default()).await
110    }
111
112    pub async fn memory_with_options(
113        options: SqliteEffectReplayOptions,
114    ) -> tokio_rusqlite::Result<Self> {
115        Ok(Self {
116            inner: open_effect_replay_memory_inner(options).await?,
117        })
118    }
119
120    /// Force strict replay mode: missing effect history fails instead of
121    /// executing locally. Normal operation still replays any completed row.
122    pub fn start_replay(&self) {
123        self.inner.replay_mode.store(true, Ordering::SeqCst);
124    }
125}
126
127impl AwaitEventResolver for SqliteEffectHost {
128    fn durability_tier(&self) -> DurabilityTier {
129        DurabilityTier::Durable
130    }
131
132    fn requires_durable_attachment_store(&self) -> bool {
133        true
134    }
135}
136
137impl EffectHost for SqliteEffectHost {
138    fn scoped<'run>(
139        &'run self,
140        scope: ExecutionScope,
141    ) -> Result<ScopedEffectController<'run>, RuntimeError> {
142        let controller = SqliteRuntimeEffectController {
143            inner: Arc::clone(&self.inner),
144            scope: scope.clone(),
145        };
146        ScopedEffectController::shared(Arc::new(controller), scope)
147    }
148
149    fn scoped_static(
150        &self,
151        scope: ExecutionScope,
152    ) -> Result<Option<ScopedEffectController<'static>>, RuntimeError> {
153        let controller = SqliteRuntimeEffectController {
154            inner: Arc::clone(&self.inner),
155            scope: scope.clone(),
156        };
157        Ok(Some(ScopedEffectController::shared(
158            Arc::new(controller),
159            scope,
160        )?))
161    }
162}
163
164impl SqliteRuntimeEffectController {
165    pub async fn open(path: &Path, scope: ExecutionScope) -> tokio_rusqlite::Result<Self> {
166        Self::open_with_options(path, scope, SqliteEffectReplayOptions::default()).await
167    }
168
169    pub async fn open_with_options(
170        path: &Path,
171        scope: ExecutionScope,
172        options: SqliteEffectReplayOptions,
173    ) -> tokio_rusqlite::Result<Self> {
174        Ok(Self {
175            inner: open_effect_replay_inner(path, StoreBacking::File, options).await?,
176            scope,
177        })
178    }
179
180    pub async fn memory(scope: ExecutionScope) -> tokio_rusqlite::Result<Self> {
181        Self::memory_with_options(scope, SqliteEffectReplayOptions::default()).await
182    }
183
184    pub async fn memory_with_options(
185        scope: ExecutionScope,
186        options: SqliteEffectReplayOptions,
187    ) -> tokio_rusqlite::Result<Self> {
188        Ok(Self {
189            inner: open_effect_replay_memory_inner(options).await?,
190            scope,
191        })
192    }
193
194    /// Force strict replay mode: missing effect history fails instead of
195    /// executing locally. Normal operation still replays any completed row.
196    pub fn start_replay(&self) {
197        self.inner.replay_mode.store(true, Ordering::SeqCst);
198    }
199
200    async fn prepare_effect(
201        &self,
202        envelope: &RuntimeEffectEnvelope,
203    ) -> Result<PreparedEffect, RuntimeEffectControllerError> {
204        let replay_key = envelope
205            .invocation
206            .replay_key()
207            .ok_or_else(|| {
208                RuntimeEffectControllerError::new(
209                    "sqlite_effect_replay_key_missing",
210                    "runtime effect envelope requires replay.key",
211                )
212            })?
213            .to_string();
214        let envelope_hash = envelope.stable_hash()?;
215        let scope_id = self.scope.id().to_string();
216        let now = current_epoch_ms();
217        let lease_token = self.inner.next_lease_token();
218        let due_at_ms = sleep_due_at_ms(envelope, now);
219        let lease_expires_at_ms = now.saturating_add(self.inner.lease_ttl_ms);
220        let replay_mode = self.inner.replay_mode.load(Ordering::SeqCst);
221        let owner_id = self.inner.owner_id.clone();
222        let lease_ttl_ms = self.inner.lease_ttl_ms;
223
224        // The `BEGIN IMMEDIATE` transaction is run on the connection thread via
225        // `conn.write`. The closure returns a `rusqlite::Result` carrying our
226        // own outcome `Result<PreparedEffect, RuntimeEffectControllerError>`:
227        // the SQL committing is independent of whether the recorded effect was
228        // a success, a failure, or a fresh claim — exactly the prior behaviour
229        // (commit on `Ok(_)` for any `PreparedEffect` variant). Only a real
230        // SQLite error rolls back.
231        let outcome: Result<PreparedEffect, RuntimeEffectControllerError> = self
232            .inner
233            .conn
234            .write(move |tx| {
235                let row = tx
236                    .query_row(
237                        "SELECT envelope_hash, status, outcome_json, error_json,
238                                lease_owner_id, lease_token, lease_expires_at_ms, due_at_ms
239                         FROM runtime_effect_replay
240                         WHERE scope_id = ?1 AND replay_key = ?2",
241                        params![scope_id.as_str(), replay_key.as_str()],
242                        |row| {
243                            Ok((
244                                row.get::<_, String>(0)?,
245                                row.get::<_, String>(1)?,
246                                row.get::<_, Option<String>>(2)?,
247                                row.get::<_, Option<String>>(3)?,
248                                row.get::<_, i64>(6)?,
249                                row.get::<_, Option<i64>>(7)?,
250                            ))
251                        },
252                    )
253                    .optional()?;
254
255                let Some((
256                    existing_hash,
257                    status,
258                    outcome_json,
259                    error_json,
260                    lease_expires_row,
261                    existing_due_row,
262                )) = row
263                else {
264                    if replay_mode {
265                        return Ok(Err(RuntimeEffectControllerError::new(
266                            "sqlite_effect_replay_missing",
267                            format!(
268                                "no recorded runtime effect for scope `{scope_id}` and replay key `{replay_key}`"
269                            ),
270                        )));
271                    }
272                    let due_at_param = due_at_ms.map(|value| value as i64);
273                    tx.execute(
274                        "INSERT INTO runtime_effect_replay (
275                            scope_id, replay_key, envelope_hash, status, outcome_json,
276                            error_json, lease_owner_id, lease_token, lease_expires_at_ms,
277                            due_at_ms, created_at_ms, updated_at_ms
278                         )
279                         VALUES (?1, ?2, ?3, ?4, NULL, NULL, ?5, ?6, ?7, ?8, ?9, ?10)",
280                        params![
281                            scope_id.as_str(),
282                            replay_key.as_str(),
283                            envelope_hash.as_str(),
284                            STATUS_IN_PROGRESS,
285                            owner_id.as_str(),
286                            lease_token.as_str(),
287                            lease_expires_at_ms as i64,
288                            due_at_param,
289                            now as i64,
290                            now as i64,
291                        ],
292                    )?;
293                    return Ok(Ok(PreparedEffect::Claimed(ClaimedEffect {
294                        scope_id,
295                        replay_key,
296                        envelope_hash,
297                        lease_token,
298                        due_at_ms,
299                    })));
300                };
301
302                if existing_hash != envelope_hash {
303                    return Ok(Err(RuntimeEffectControllerError::new(
304                        "sqlite_effect_replay_hash_conflict",
305                        format!(
306                            "runtime effect replay key `{replay_key}` in scope `{scope_id}` was reused with a different envelope hash"
307                        ),
308                    )));
309                }
310
311                let lease_expires_at_ms = lease_expires_row as u64;
312                let existing_due_at_ms = existing_due_row.map(|value| value as u64);
313
314                match status.as_str() {
315                    STATUS_COMPLETED => {
316                        let Some(json) = outcome_json else {
317                            return Ok(Err(RuntimeEffectControllerError::new(
318                                "sqlite_effect_replay_corrupt_row",
319                                "completed runtime effect row is missing outcome_json",
320                            )));
321                        };
322                        let outcome = match serde_json::from_str(&json) {
323                            Ok(outcome) => outcome,
324                            Err(err) => return Ok(Err(effect_decode_error(err))),
325                        };
326                        Ok(Ok(PreparedEffect::ReplayOutcome {
327                            outcome: Box::new(outcome),
328                            due_at_ms: existing_due_at_ms,
329                        }))
330                    }
331                    STATUS_FAILED => {
332                        let Some(json) = error_json else {
333                            return Ok(Err(RuntimeEffectControllerError::new(
334                                "sqlite_effect_replay_corrupt_row",
335                                "failed runtime effect row is missing error_json",
336                            )));
337                        };
338                        let err = match serde_json::from_str(&json) {
339                            Ok(err) => err,
340                            Err(err) => return Ok(Err(effect_decode_error(err))),
341                        };
342                        Ok(Ok(PreparedEffect::ReplayError(err)))
343                    }
344                    STATUS_IN_PROGRESS if lease_expires_at_ms > now => Ok(Ok(PreparedEffect::Busy {
345                        retry_at_ms: lease_expires_at_ms,
346                    })),
347                    STATUS_IN_PROGRESS => {
348                        let due_at_ms = existing_due_at_ms.or(due_at_ms);
349                        let due_at_param = due_at_ms.map(|value| value as i64);
350                        tx.execute(
351                            "UPDATE runtime_effect_replay
352                             SET lease_owner_id = ?3,
353                                 lease_token = ?4,
354                                 lease_expires_at_ms = ?5,
355                                 due_at_ms = ?6,
356                                 updated_at_ms = ?7
357                             WHERE scope_id = ?1 AND replay_key = ?2",
358                            params![
359                                scope_id.as_str(),
360                                replay_key.as_str(),
361                                owner_id.as_str(),
362                                lease_token.as_str(),
363                                current_epoch_ms().saturating_add(lease_ttl_ms) as i64,
364                                due_at_param,
365                                current_epoch_ms() as i64,
366                            ],
367                        )?;
368                        Ok(Ok(PreparedEffect::Claimed(ClaimedEffect {
369                            scope_id,
370                            replay_key,
371                            envelope_hash,
372                            lease_token,
373                            due_at_ms,
374                        })))
375                    }
376                    other => Ok(Err(RuntimeEffectControllerError::new(
377                        "sqlite_effect_replay_corrupt_row",
378                        format!("unknown runtime effect replay status `{other}`"),
379                    ))),
380                }
381            })
382            .await
383            .map_err(effect_sqlite_error)?;
384        outcome
385    }
386
387    async fn finalize_effect(
388        &self,
389        claim: &ClaimedEffect,
390        outcome: &Result<RuntimeEffectOutcome, RuntimeEffectControllerError>,
391    ) -> Result<(), RuntimeEffectControllerError> {
392        let (status, outcome_json, error_json) = match outcome {
393            Ok(outcome) => (
394                STATUS_COMPLETED,
395                Some(serde_json::to_string(outcome).map_err(effect_encode_error)?),
396                None,
397            ),
398            Err(err) => (
399                STATUS_FAILED,
400                None,
401                Some(serde_json::to_string(err).map_err(effect_encode_error)?),
402            ),
403        };
404        let now = current_epoch_ms();
405        let scope_id = claim.scope_id.clone();
406        let replay_key = claim.replay_key.clone();
407        let envelope_hash = claim.envelope_hash.clone();
408        let owner_id = self.inner.owner_id.clone();
409        let lease_token = claim.lease_token.clone();
410        let status = status.to_string();
411
412        let result: Result<(), RuntimeEffectControllerError> = self
413            .inner
414            .conn
415            .write(move |tx| {
416                let changed = tx.execute(
417                    "UPDATE runtime_effect_replay
418                     SET status = ?6,
419                         outcome_json = ?7,
420                         error_json = ?8,
421                         lease_owner_id = NULL,
422                         lease_token = NULL,
423                         lease_expires_at_ms = 0,
424                         updated_at_ms = ?9
425                     WHERE scope_id = ?1
426                       AND replay_key = ?2
427                       AND envelope_hash = ?3
428                       AND lease_owner_id = ?4
429                       AND lease_token = ?5
430                       AND status = 'in_progress'
431                       AND lease_expires_at_ms > ?10",
432                    params![
433                        scope_id.as_str(),
434                        replay_key.as_str(),
435                        envelope_hash.as_str(),
436                        owner_id.as_str(),
437                        lease_token.as_str(),
438                        status.as_str(),
439                        outcome_json,
440                        error_json,
441                        now as i64,
442                        now as i64,
443                    ],
444                )?;
445                if changed != 1 {
446                    return Ok(Err(RuntimeEffectControllerError::new(
447                        "sqlite_effect_replay_lease_lost",
448                        format!(
449                            "runtime effect replay lease was lost before finalizing scope `{scope_id}` replay key `{replay_key}`"
450                        ),
451                    )));
452                }
453                Ok(Ok(()))
454            })
455            .await
456            .map_err(effect_sqlite_error)?;
457        result
458    }
459
460    async fn renew_effect_lease(
461        &self,
462        claim: &ClaimedEffect,
463    ) -> Result<(), RuntimeEffectControllerError> {
464        let now = current_epoch_ms();
465        let renewed_expires_at = now.saturating_add(self.inner.lease_ttl_ms);
466        let scope_id = claim.scope_id.clone();
467        let replay_key = claim.replay_key.clone();
468        let envelope_hash = claim.envelope_hash.clone();
469        let owner_id = self.inner.owner_id.clone();
470        let lease_token = claim.lease_token.clone();
471
472        let result: Result<(), RuntimeEffectControllerError> = self
473            .inner
474            .conn
475            .write(move |tx| {
476                let changed = tx.execute(
477                    "UPDATE runtime_effect_replay
478                     SET lease_expires_at_ms = ?6,
479                         updated_at_ms = ?7
480                     WHERE scope_id = ?1
481                       AND replay_key = ?2
482                       AND envelope_hash = ?3
483                       AND lease_owner_id = ?4
484                       AND lease_token = ?5
485                       AND status = 'in_progress'
486                       AND lease_expires_at_ms > ?8",
487                    params![
488                        scope_id.as_str(),
489                        replay_key.as_str(),
490                        envelope_hash.as_str(),
491                        owner_id.as_str(),
492                        lease_token.as_str(),
493                        renewed_expires_at as i64,
494                        now as i64,
495                        now as i64,
496                    ],
497                )?;
498                if changed != 1 {
499                    return Ok(Err(RuntimeEffectControllerError::new(
500                        "sqlite_effect_replay_lease_lost",
501                        format!(
502                            "runtime effect replay lease was lost while executing scope `{scope_id}` replay key `{replay_key}`"
503                        ),
504                    )));
505                }
506                Ok(Ok(()))
507            })
508            .await
509            .map_err(effect_sqlite_error)?;
510        result
511    }
512
513    async fn execute_claimed_effect_with_renewal(
514        &self,
515        claim: &ClaimedEffect,
516        envelope: RuntimeEffectEnvelope,
517        local_executor: RuntimeEffectLocalExecutor<'_>,
518    ) -> Result<RuntimeEffectOutcome, RuntimeEffectControllerError> {
519        let renew_every = Duration::from_millis((self.inner.lease_ttl_ms / 3).max(1));
520        let effect = self.execute_claimed_effect(claim, envelope, local_executor);
521        tokio::pin!(effect);
522        let renew_sleep = tokio::time::sleep(renew_every);
523        tokio::pin!(renew_sleep);
524
525        loop {
526            tokio::select! {
527                result = &mut effect => return result,
528                _ = &mut renew_sleep => {
529                    self.renew_effect_lease(claim).await?;
530                    renew_sleep.as_mut().reset(tokio::time::Instant::now() + renew_every);
531                }
532            }
533        }
534    }
535
536    async fn execute_claimed_effect(
537        &self,
538        claim: &ClaimedEffect,
539        envelope: RuntimeEffectEnvelope,
540        local_executor: RuntimeEffectLocalExecutor<'_>,
541    ) -> Result<RuntimeEffectOutcome, RuntimeEffectControllerError> {
542        if matches!(envelope.command, RuntimeEffectCommand::Sleep { .. }) {
543            sleep_until_due(claim.due_at_ms).await;
544            return Ok(RuntimeEffectOutcome::Sleep);
545        }
546        match envelope.command {
547            RuntimeEffectCommand::Process { command } => {
548                let result = self
549                    .execute_process_command(*command, local_executor)
550                    .await?;
551                Ok(RuntimeEffectOutcome::Process { result })
552            }
553            _ => local_executor.execute(envelope).await,
554        }
555    }
556
557    async fn execute_process_command(
558        &self,
559        command: ProcessCommand,
560        local_executor: RuntimeEffectLocalExecutor<'_>,
561    ) -> Result<ProcessEffectOutcome, RuntimeEffectControllerError> {
562        let execution = local_executor.into_process()?;
563        let registry = execution.registry;
564        match command {
565            ProcessCommand::Start {
566                registration,
567                grant,
568                execution_context: _,
569            } => {
570                let registration_id = registration.id.clone();
571                let record = registry.register_process(registration).await?;
572                if let Some(grant) = grant {
573                    registry
574                        .grant_handle(&grant.session_scope, &registration_id, grant.descriptor)
575                        .await?;
576                }
577                Ok(ProcessEffectOutcome::Start { record })
578            }
579            ProcessCommand::List {
580                session_scope,
581                mode,
582            } => {
583                let entries = match mode {
584                    lash_core::ProcessListMode::Live => {
585                        registry.list_live_handle_grants(&session_scope).await?
586                    }
587                    lash_core::ProcessListMode::All => {
588                        registry.list_handle_grants(&session_scope).await?
589                    }
590                };
591                Ok(ProcessEffectOutcome::List { entries })
592            }
593            ProcessCommand::Transfer {
594                from_scope,
595                to_scope,
596                process_ids,
597            } => {
598                registry
599                    .transfer_handle_grants(&from_scope, &to_scope, &process_ids)
600                    .await?;
601                Ok(ProcessEffectOutcome::Transfer)
602            }
603            ProcessCommand::DeleteSession { session_id } => {
604                let report = registry.delete_session_process_state(&session_id).await?;
605                Ok(ProcessEffectOutcome::DeleteSession { report })
606            }
607            ProcessCommand::Await { process_id } => {
608                let output = registry.await_process(&process_id).await?;
609                Ok(ProcessEffectOutcome::Await { output })
610            }
611            ProcessCommand::Cancel { process_id, reason } => {
612                registry
613                    .append_event(
614                        &process_id,
615                        lash_core::ProcessEventAppendRequest::cancel_requested(&process_id, reason),
616                    )
617                    .await?;
618                let record = registry.get_process(&process_id).await.ok_or_else(|| {
619                    PluginError::Session(format!("unknown process `{process_id}`"))
620                })?;
621                Ok(ProcessEffectOutcome::Cancel { record })
622            }
623            ProcessCommand::Signal {
624                process_id,
625                request,
626                ..
627            } => {
628                let result = registry.append_event(&process_id, request).await?;
629                Ok(ProcessEffectOutcome::Signal {
630                    event: result.event,
631                })
632            }
633        }
634    }
635}
636
637impl AwaitEventResolver for SqliteRuntimeEffectController {
638    fn durability_tier(&self) -> DurabilityTier {
639        DurabilityTier::Durable
640    }
641
642    fn requires_durable_attachment_store(&self) -> bool {
643        true
644    }
645
646    fn supports_durable_effects(&self) -> bool {
647        true
648    }
649}
650
651#[async_trait::async_trait]
652impl RuntimeEffectController for SqliteRuntimeEffectController {
653    async fn execute_effect(
654        &self,
655        envelope: RuntimeEffectEnvelope,
656        local_executor: RuntimeEffectLocalExecutor<'_>,
657    ) -> Result<RuntimeEffectOutcome, RuntimeEffectControllerError> {
658        loop {
659            match self.prepare_effect(&envelope).await? {
660                PreparedEffect::ReplayOutcome { outcome, due_at_ms } => {
661                    sleep_until_due(due_at_ms).await;
662                    return Ok(*outcome);
663                }
664                PreparedEffect::ReplayError(err) => return Err(err),
665                PreparedEffect::Claimed(claim) => {
666                    let result = self
667                        .execute_claimed_effect_with_renewal(&claim, envelope, local_executor)
668                        .await;
669                    let finalize = self.finalize_effect(&claim, &result).await;
670                    return match (result, finalize) {
671                        (Ok(outcome), Ok(())) => Ok(outcome),
672                        (Err(err), Ok(())) => Err(err),
673                        (_, Err(err)) => Err(err),
674                    };
675                }
676                PreparedEffect::Busy { retry_at_ms } => {
677                    sleep_until_retry(retry_at_ms).await;
678                }
679            }
680        }
681    }
682}
683
684async fn open_effect_replay_inner(
685    path: &Path,
686    backing: StoreBacking,
687    options: SqliteEffectReplayOptions,
688) -> tokio_rusqlite::Result<Arc<SqliteEffectReplayInner>> {
689    let conn = SqliteConnection::open(path).await?;
690    ensure_effect_schema(&conn).await?;
691    apply_pragmas(&conn, backing).await?;
692    Ok(Arc::new(SqliteEffectReplayInner::new(conn, options)))
693}
694
695async fn open_effect_replay_memory_inner(
696    options: SqliteEffectReplayOptions,
697) -> tokio_rusqlite::Result<Arc<SqliteEffectReplayInner>> {
698    let conn = SqliteConnection::open_in_memory().await?;
699    ensure_effect_schema(&conn).await?;
700    apply_pragmas(&conn, StoreBacking::Memory).await?;
701    Ok(Arc::new(SqliteEffectReplayInner::new(conn, options)))
702}
703
704impl SqliteEffectReplayInner {
705    fn new(conn: SqliteConnection, options: SqliteEffectReplayOptions) -> Self {
706        let sequence = EFFECT_OWNER_COUNTER.fetch_add(1, Ordering::SeqCst);
707        Self {
708            conn,
709            owner_id: format!(
710                "pid{}-{sequence}-{}",
711                std::process::id(),
712                current_epoch_ms()
713            ),
714            lease_counter: AtomicU64::new(1),
715            replay_mode: AtomicBool::new(false),
716            lease_ttl_ms: duration_ms(options.lease_ttl),
717        }
718    }
719
720    fn next_lease_token(&self) -> String {
721        let sequence = self.lease_counter.fetch_add(1, Ordering::SeqCst);
722        format!("{}:{sequence}", self.owner_id)
723    }
724}
725
726fn duration_ms(duration: Duration) -> u64 {
727    let millis = duration.as_millis();
728    if millis == 0 {
729        1
730    } else {
731        millis.min(u128::from(u64::MAX)) as u64
732    }
733}
734
735fn sleep_due_at_ms(envelope: &RuntimeEffectEnvelope, now: u64) -> Option<u64> {
736    match envelope.command {
737        RuntimeEffectCommand::Sleep { duration_ms } => Some(now.saturating_add(duration_ms)),
738        _ => None,
739    }
740}
741
742async fn sleep_until_due(due_at_ms: Option<u64>) {
743    let Some(due_at_ms) = due_at_ms else {
744        return;
745    };
746    let now = current_epoch_ms();
747    if due_at_ms > now {
748        tokio::time::sleep(Duration::from_millis(due_at_ms - now)).await;
749    }
750}
751
752async fn sleep_until_retry(retry_at_ms: u64) {
753    let now = current_epoch_ms();
754    let delay = if retry_at_ms > now {
755        Duration::from_millis(retry_at_ms - now).min(BUSY_POLL)
756    } else {
757        BUSY_POLL
758    };
759    tokio::time::sleep(delay).await;
760}
761
762fn effect_sqlite_error(err: rusqlite::Error) -> RuntimeEffectControllerError {
763    RuntimeEffectControllerError::new("sqlite_effect_replay_store", err.to_string())
764}
765
766fn effect_encode_error(err: serde_json::Error) -> RuntimeEffectControllerError {
767    RuntimeEffectControllerError::new(
768        "sqlite_effect_replay_encode",
769        format!("failed to encode runtime effect replay row: {err}"),
770    )
771}
772
773fn effect_decode_error(err: serde_json::Error) -> RuntimeEffectControllerError {
774    RuntimeEffectControllerError::new(
775        "sqlite_effect_replay_decode",
776        format!("failed to decode runtime effect replay row: {err}"),
777    )
778}