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