1use 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#[derive(Clone, Debug, Default)]
36pub struct SqliteEffectReplayOptions {
37 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#[derive(Clone)]
58pub struct SqliteEffectHost {
59 inner: Arc<SqliteEffectReplayInner>,
60}
61
62#[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 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 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 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}