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