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, PluginError,
20 ProcessCommand, 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 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 owner_id: String,
46 lease_counter: AtomicU64,
47 replay_mode: AtomicBool,
48 lease_timings: LeaseTimings,
49}
50
51#[derive(Clone)]
57pub struct SqliteEffectHost {
58 inner: Arc<SqliteEffectReplayInner>,
59}
60
61#[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 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 fn requires_durable_attachment_store(&self) -> bool {
127 true
128 }
129}
130
131impl EffectHost for SqliteEffectHost {
132 fn scoped<'run>(
133 &'run self,
134 scope: ExecutionScope,
135 ) -> Result<ScopedEffectController<'run>, RuntimeError> {
136 let controller = SqliteRuntimeEffectController {
137 inner: Arc::clone(&self.inner),
138 scope: scope.clone(),
139 };
140 ScopedEffectController::shared(Arc::new(controller), scope)
141 }
142
143 fn scoped_static(
144 &self,
145 scope: ExecutionScope,
146 ) -> Result<Option<ScopedEffectController<'static>>, RuntimeError> {
147 let controller = SqliteRuntimeEffectController {
148 inner: Arc::clone(&self.inner),
149 scope: scope.clone(),
150 };
151 Ok(Some(ScopedEffectController::shared(
152 Arc::new(controller),
153 scope,
154 )?))
155 }
156}
157
158impl SqliteRuntimeEffectController {
159 pub async fn open(path: &Path, scope: ExecutionScope) -> tokio_rusqlite::Result<Self> {
160 Self::open_with_options(path, scope, SqliteEffectReplayOptions::default()).await
161 }
162
163 pub async fn open_with_options(
164 path: &Path,
165 scope: ExecutionScope,
166 options: SqliteEffectReplayOptions,
167 ) -> tokio_rusqlite::Result<Self> {
168 Ok(Self {
169 inner: open_effect_replay_inner(path, StoreBacking::File, options).await?,
170 scope,
171 })
172 }
173
174 pub async fn memory(scope: ExecutionScope) -> tokio_rusqlite::Result<Self> {
175 Self::memory_with_options(scope, SqliteEffectReplayOptions::default()).await
176 }
177
178 pub async fn memory_with_options(
179 scope: ExecutionScope,
180 options: SqliteEffectReplayOptions,
181 ) -> tokio_rusqlite::Result<Self> {
182 Ok(Self {
183 inner: open_effect_replay_memory_inner(options).await?,
184 scope,
185 })
186 }
187
188 pub fn start_replay(&self) {
191 self.inner.replay_mode.store(true, Ordering::SeqCst);
192 }
193
194 async fn prepare_effect(
195 &self,
196 envelope: &RuntimeEffectEnvelope,
197 ) -> Result<PreparedEffect, RuntimeEffectControllerError> {
198 let replay_key = envelope
199 .invocation
200 .replay_key()
201 .ok_or_else(|| {
202 RuntimeEffectControllerError::new(
203 "sqlite_effect_replay_key_missing",
204 "runtime effect envelope requires replay.key",
205 )
206 })?
207 .to_string();
208 let envelope_hash = envelope.stable_hash()?;
209 let scope_id = self.scope.id().to_string();
210 let now = current_epoch_ms();
211 let lease_token = self.inner.next_lease_token();
212 let due_at_ms = sleep_due_at_ms(envelope, now);
213 let lease_ttl_ms = self.inner.lease_timings.ttl_ms();
214 let lease_expires_at_ms = now.saturating_add(lease_ttl_ms);
215 let replay_mode = self.inner.replay_mode.load(Ordering::SeqCst);
216 let owner_id = self.inner.owner_id.clone();
217
218 let outcome: Result<PreparedEffect, RuntimeEffectControllerError> = self
226 .inner
227 .conn
228 .write(move |tx| {
229 let row = tx
230 .query_row(
231 "SELECT envelope_hash, status, outcome_json, error_json,
232 lease_owner_id, lease_token, lease_expires_at_ms, due_at_ms
233 FROM runtime_effect_replay
234 WHERE scope_id = ?1 AND replay_key = ?2",
235 params![scope_id.as_str(), replay_key.as_str()],
236 |row| {
237 Ok((
238 row.get::<_, String>(0)?,
239 row.get::<_, String>(1)?,
240 row.get::<_, Option<String>>(2)?,
241 row.get::<_, Option<String>>(3)?,
242 row.get::<_, i64>(6)?,
243 row.get::<_, Option<i64>>(7)?,
244 ))
245 },
246 )
247 .optional()?;
248
249 let Some((
250 existing_hash,
251 status,
252 outcome_json,
253 error_json,
254 lease_expires_row,
255 existing_due_row,
256 )) = row
257 else {
258 if replay_mode {
259 return Ok(Err(RuntimeEffectControllerError::new(
260 "sqlite_effect_replay_missing",
261 format!(
262 "no recorded runtime effect for scope `{scope_id}` and replay key `{replay_key}`"
263 ),
264 )));
265 }
266 let due_at_param = due_at_ms.map(|value| value as i64);
267 tx.execute(
268 "INSERT INTO runtime_effect_replay (
269 scope_id, replay_key, envelope_hash, status, outcome_json,
270 error_json, lease_owner_id, lease_token, lease_expires_at_ms,
271 due_at_ms, created_at_ms, updated_at_ms
272 )
273 VALUES (?1, ?2, ?3, ?4, NULL, NULL, ?5, ?6, ?7, ?8, ?9, ?10)",
274 params![
275 scope_id.as_str(),
276 replay_key.as_str(),
277 envelope_hash.as_str(),
278 STATUS_IN_PROGRESS,
279 owner_id.as_str(),
280 lease_token.as_str(),
281 lease_expires_at_ms as i64,
282 due_at_param,
283 now as i64,
284 now as i64,
285 ],
286 )?;
287 return Ok(Ok(PreparedEffect::Claimed(ClaimedEffect {
288 scope_id,
289 replay_key,
290 envelope_hash,
291 lease_token,
292 due_at_ms,
293 })));
294 };
295
296 if existing_hash != envelope_hash {
297 return Ok(Err(RuntimeEffectControllerError::new(
298 "sqlite_effect_replay_hash_conflict",
299 format!(
300 "runtime effect replay key `{replay_key}` in scope `{scope_id}` was reused with a different envelope hash"
301 ),
302 )));
303 }
304
305 let lease_expires_at_ms = lease_expires_row as u64;
306 let existing_due_at_ms = existing_due_row.map(|value| value as u64);
307
308 match status.as_str() {
309 STATUS_COMPLETED => {
310 let Some(json) = outcome_json else {
311 return Ok(Err(RuntimeEffectControllerError::new(
312 "sqlite_effect_replay_corrupt_row",
313 "completed runtime effect row is missing outcome_json",
314 )));
315 };
316 let outcome = match serde_json::from_str(&json) {
317 Ok(outcome) => outcome,
318 Err(err) => return Ok(Err(effect_decode_error(err))),
319 };
320 Ok(Ok(PreparedEffect::ReplayOutcome {
321 outcome: Box::new(outcome),
322 due_at_ms: existing_due_at_ms,
323 }))
324 }
325 STATUS_FAILED => {
326 let Some(json) = error_json else {
327 return Ok(Err(RuntimeEffectControllerError::new(
328 "sqlite_effect_replay_corrupt_row",
329 "failed runtime effect row is missing error_json",
330 )));
331 };
332 let err = match serde_json::from_str(&json) {
333 Ok(err) => err,
334 Err(err) => return Ok(Err(effect_decode_error(err))),
335 };
336 Ok(Ok(PreparedEffect::ReplayError(err)))
337 }
338 STATUS_IN_PROGRESS if lease_expires_at_ms > now => Ok(Ok(PreparedEffect::Busy {
339 retry_at_ms: lease_expires_at_ms,
340 })),
341 STATUS_IN_PROGRESS => {
342 let due_at_ms = existing_due_at_ms.or(due_at_ms);
343 let due_at_param = due_at_ms.map(|value| value as i64);
344 tx.execute(
345 "UPDATE runtime_effect_replay
346 SET lease_owner_id = ?3,
347 lease_token = ?4,
348 lease_expires_at_ms = ?5,
349 due_at_ms = ?6,
350 updated_at_ms = ?7
351 WHERE scope_id = ?1 AND replay_key = ?2",
352 params![
353 scope_id.as_str(),
354 replay_key.as_str(),
355 owner_id.as_str(),
356 lease_token.as_str(),
357 current_epoch_ms().saturating_add(lease_ttl_ms) as i64,
358 due_at_param,
359 current_epoch_ms() as i64,
360 ],
361 )?;
362 Ok(Ok(PreparedEffect::Claimed(ClaimedEffect {
363 scope_id,
364 replay_key,
365 envelope_hash,
366 lease_token,
367 due_at_ms,
368 })))
369 }
370 other => Ok(Err(RuntimeEffectControllerError::new(
371 "sqlite_effect_replay_corrupt_row",
372 format!("unknown runtime effect replay status `{other}`"),
373 ))),
374 }
375 })
376 .await
377 .map_err(effect_sqlite_error)?;
378 outcome
379 }
380
381 async fn finalize_effect(
382 &self,
383 claim: &ClaimedEffect,
384 outcome: &Result<RuntimeEffectOutcome, RuntimeEffectControllerError>,
385 ) -> Result<(), RuntimeEffectControllerError> {
386 let (status, outcome_json, error_json) = match outcome {
387 Ok(outcome) => (
388 STATUS_COMPLETED,
389 Some(serde_json::to_string(outcome).map_err(effect_encode_error)?),
390 None,
391 ),
392 Err(err) => (
393 STATUS_FAILED,
394 None,
395 Some(serde_json::to_string(err).map_err(effect_encode_error)?),
396 ),
397 };
398 let now = current_epoch_ms();
399 let scope_id = claim.scope_id.clone();
400 let replay_key = claim.replay_key.clone();
401 let envelope_hash = claim.envelope_hash.clone();
402 let owner_id = self.inner.owner_id.clone();
403 let lease_token = claim.lease_token.clone();
404 let status = status.to_string();
405
406 let result: Result<(), RuntimeEffectControllerError> = self
407 .inner
408 .conn
409 .write(move |tx| {
410 let changed = tx.execute(
411 "UPDATE runtime_effect_replay
412 SET status = ?6,
413 outcome_json = ?7,
414 error_json = ?8,
415 lease_owner_id = NULL,
416 lease_token = NULL,
417 lease_expires_at_ms = 0,
418 updated_at_ms = ?9
419 WHERE scope_id = ?1
420 AND replay_key = ?2
421 AND envelope_hash = ?3
422 AND lease_owner_id = ?4
423 AND lease_token = ?5
424 AND status = 'in_progress'
425 AND lease_expires_at_ms > ?10",
426 params![
427 scope_id.as_str(),
428 replay_key.as_str(),
429 envelope_hash.as_str(),
430 owner_id.as_str(),
431 lease_token.as_str(),
432 status.as_str(),
433 outcome_json,
434 error_json,
435 now as i64,
436 now as i64,
437 ],
438 )?;
439 if changed != 1 {
440 return Ok(Err(RuntimeEffectControllerError::new(
441 "sqlite_effect_replay_lease_lost",
442 format!(
443 "runtime effect replay lease was lost before finalizing scope `{scope_id}` replay key `{replay_key}`"
444 ),
445 )));
446 }
447 Ok(Ok(()))
448 })
449 .await
450 .map_err(effect_sqlite_error)?;
451 result
452 }
453
454 async fn renew_effect_lease(
455 &self,
456 claim: &ClaimedEffect,
457 ) -> Result<(), RuntimeEffectControllerError> {
458 let now = current_epoch_ms();
459 let renewed_expires_at = now.saturating_add(self.inner.lease_timings.ttl_ms());
460 let scope_id = claim.scope_id.clone();
461 let replay_key = claim.replay_key.clone();
462 let envelope_hash = claim.envelope_hash.clone();
463 let owner_id = self.inner.owner_id.clone();
464 let lease_token = claim.lease_token.clone();
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 lease_expires_at_ms = ?6,
473 updated_at_ms = ?7
474 WHERE scope_id = ?1
475 AND replay_key = ?2
476 AND envelope_hash = ?3
477 AND lease_owner_id = ?4
478 AND lease_token = ?5
479 AND status = 'in_progress'
480 AND lease_expires_at_ms > ?8",
481 params![
482 scope_id.as_str(),
483 replay_key.as_str(),
484 envelope_hash.as_str(),
485 owner_id.as_str(),
486 lease_token.as_str(),
487 renewed_expires_at as i64,
488 now as i64,
489 now as i64,
490 ],
491 )?;
492 if changed != 1 {
493 return Ok(Err(RuntimeEffectControllerError::new(
494 "sqlite_effect_replay_lease_lost",
495 format!(
496 "runtime effect replay lease was lost while executing scope `{scope_id}` replay key `{replay_key}`"
497 ),
498 )));
499 }
500 Ok(Ok(()))
501 })
502 .await
503 .map_err(effect_sqlite_error)?;
504 result
505 }
506
507 async fn execute_claimed_effect_with_renewal(
508 &self,
509 claim: &ClaimedEffect,
510 envelope: RuntimeEffectEnvelope,
511 local_executor: RuntimeEffectLocalExecutor<'_>,
512 ) -> Result<RuntimeEffectOutcome, RuntimeEffectControllerError> {
513 let renew_every = self.inner.lease_timings.renew_interval();
514 let effect = self.execute_claimed_effect(claim, envelope, local_executor);
515 tokio::pin!(effect);
516 let renew_sleep = tokio::time::sleep(renew_every);
517 tokio::pin!(renew_sleep);
518
519 loop {
520 tokio::select! {
521 result = &mut effect => return result,
522 _ = &mut renew_sleep => {
523 self.renew_effect_lease(claim).await?;
524 renew_sleep.as_mut().reset(tokio::time::Instant::now() + renew_every);
525 }
526 }
527 }
528 }
529
530 async fn execute_claimed_effect(
531 &self,
532 claim: &ClaimedEffect,
533 envelope: RuntimeEffectEnvelope,
534 local_executor: RuntimeEffectLocalExecutor<'_>,
535 ) -> Result<RuntimeEffectOutcome, RuntimeEffectControllerError> {
536 if matches!(envelope.command, RuntimeEffectCommand::Sleep { .. }) {
537 sleep_until_due(claim.due_at_ms).await;
538 return Ok(RuntimeEffectOutcome::Sleep);
539 }
540 match envelope.command {
541 RuntimeEffectCommand::Process { command } => {
542 let result = self
543 .execute_process_command(*command, local_executor)
544 .await?;
545 Ok(RuntimeEffectOutcome::Process { result })
546 }
547 _ => local_executor.execute(envelope).await,
548 }
549 }
550
551 async fn execute_process_command(
552 &self,
553 command: ProcessCommand,
554 local_executor: RuntimeEffectLocalExecutor<'_>,
555 ) -> Result<ProcessEffectOutcome, RuntimeEffectControllerError> {
556 let execution = local_executor.into_process()?;
557 let registry = execution.registry;
558 match command {
559 ProcessCommand::Start {
560 registration,
561 grant,
562 execution_context: _,
563 } => {
564 let registration_id = registration.id.clone();
565 let record = registry.register_process(registration).await?;
566 if let Some(grant) = grant {
567 registry
568 .grant_handle(&grant.session_scope, ®istration_id, grant.descriptor)
569 .await?;
570 }
571 Ok(ProcessEffectOutcome::Start { record })
572 }
573 ProcessCommand::List {
574 session_scope,
575 mode,
576 } => {
577 let entries = match mode {
578 lash_core::ProcessListMode::Live => {
579 registry.list_live_handle_grants(&session_scope).await?
580 }
581 lash_core::ProcessListMode::All => {
582 registry.list_handle_grants(&session_scope).await?
583 }
584 };
585 Ok(ProcessEffectOutcome::List { entries })
586 }
587 ProcessCommand::Transfer {
588 from_scope,
589 to_scope,
590 process_ids,
591 } => {
592 registry
593 .transfer_handle_grants(&from_scope, &to_scope, &process_ids)
594 .await?;
595 Ok(ProcessEffectOutcome::Transfer)
596 }
597 ProcessCommand::DeleteSession { session_id } => {
598 let report = registry.delete_session_process_state(&session_id).await?;
599 Ok(ProcessEffectOutcome::DeleteSession { report })
600 }
601 ProcessCommand::Await { process_id } => {
602 let output = registry.await_process(&process_id).await?;
603 Ok(ProcessEffectOutcome::Await { output })
604 }
605 ProcessCommand::Cancel { process_id, reason } => {
606 registry
607 .append_event(
608 &process_id,
609 lash_core::ProcessEventAppendRequest::cancel_requested(&process_id, reason),
610 )
611 .await?;
612 let record = registry.get_process(&process_id).await.ok_or_else(|| {
613 PluginError::Session(format!("unknown process `{process_id}`"))
614 })?;
615 Ok(ProcessEffectOutcome::Cancel { record })
616 }
617 ProcessCommand::Signal {
618 process_id,
619 request,
620 ..
621 } => {
622 let result = registry.append_event(&process_id, request).await?;
623 Ok(ProcessEffectOutcome::Signal {
624 event: result.event,
625 })
626 }
627 }
628 }
629}
630
631impl AwaitEventResolver for SqliteRuntimeEffectController {
632 fn durability_tier(&self) -> DurabilityTier {
633 DurabilityTier::Durable
634 }
635
636 fn requires_durable_attachment_store(&self) -> bool {
637 true
638 }
639
640 fn supports_durable_effects(&self) -> bool {
641 true
642 }
643}
644
645#[async_trait::async_trait]
646impl RuntimeEffectController for SqliteRuntimeEffectController {
647 async fn execute_effect(
648 &self,
649 envelope: RuntimeEffectEnvelope,
650 local_executor: RuntimeEffectLocalExecutor<'_>,
651 ) -> Result<RuntimeEffectOutcome, RuntimeEffectControllerError> {
652 loop {
653 match self.prepare_effect(&envelope).await? {
654 PreparedEffect::ReplayOutcome { outcome, due_at_ms } => {
655 sleep_until_due(due_at_ms).await;
656 return Ok(*outcome);
657 }
658 PreparedEffect::ReplayError(err) => return Err(err),
659 PreparedEffect::Claimed(claim) => {
660 let result = self
661 .execute_claimed_effect_with_renewal(&claim, envelope, local_executor)
662 .await;
663 let finalize = self.finalize_effect(&claim, &result).await;
664 return match (result, finalize) {
665 (Ok(outcome), Ok(())) => Ok(outcome),
666 (Err(err), Ok(())) => Err(err),
667 (_, Err(err)) => Err(err),
668 };
669 }
670 PreparedEffect::Busy { retry_at_ms } => {
671 sleep_until_retry(retry_at_ms).await;
672 }
673 }
674 }
675 }
676}
677
678async fn open_effect_replay_inner(
679 path: &Path,
680 backing: StoreBacking,
681 options: SqliteEffectReplayOptions,
682) -> tokio_rusqlite::Result<Arc<SqliteEffectReplayInner>> {
683 let conn = SqliteConnection::open(path).await?;
684 ensure_effect_schema(&conn).await?;
685 apply_pragmas(&conn, backing).await?;
686 Ok(Arc::new(SqliteEffectReplayInner::new(conn, options)))
687}
688
689async fn open_effect_replay_memory_inner(
690 options: SqliteEffectReplayOptions,
691) -> tokio_rusqlite::Result<Arc<SqliteEffectReplayInner>> {
692 let conn = SqliteConnection::open_in_memory().await?;
693 ensure_effect_schema(&conn).await?;
694 apply_pragmas(&conn, StoreBacking::Memory).await?;
695 Ok(Arc::new(SqliteEffectReplayInner::new(conn, options)))
696}
697
698impl SqliteEffectReplayInner {
699 fn new(conn: SqliteConnection, options: SqliteEffectReplayOptions) -> Self {
700 let sequence = EFFECT_OWNER_COUNTER.fetch_add(1, Ordering::SeqCst);
701 Self {
702 conn,
703 owner_id: format!(
704 "pid{}-{sequence}-{}",
705 std::process::id(),
706 current_epoch_ms()
707 ),
708 lease_counter: AtomicU64::new(1),
709 replay_mode: AtomicBool::new(false),
710 lease_timings: options.lease_timings,
711 }
712 }
713
714 fn next_lease_token(&self) -> String {
715 let sequence = self.lease_counter.fetch_add(1, Ordering::SeqCst);
716 format!("{}:{sequence}", self.owner_id)
717 }
718}
719
720fn sleep_due_at_ms(envelope: &RuntimeEffectEnvelope, now: u64) -> Option<u64> {
721 match envelope.command {
722 RuntimeEffectCommand::Sleep { duration_ms } => Some(now.saturating_add(duration_ms)),
723 _ => None,
724 }
725}
726
727async fn sleep_until_due(due_at_ms: Option<u64>) {
728 let Some(due_at_ms) = due_at_ms else {
729 return;
730 };
731 let now = current_epoch_ms();
732 if due_at_ms > now {
733 tokio::time::sleep(Duration::from_millis(due_at_ms - now)).await;
734 }
735}
736
737async fn sleep_until_retry(retry_at_ms: u64) {
738 let now = current_epoch_ms();
739 let delay = if retry_at_ms > now {
740 Duration::from_millis(retry_at_ms - now).min(BUSY_POLL)
741 } else {
742 BUSY_POLL
743 };
744 tokio::time::sleep(delay).await;
745}
746
747fn effect_sqlite_error(err: rusqlite::Error) -> RuntimeEffectControllerError {
748 RuntimeEffectControllerError::new("sqlite_effect_replay_store", err.to_string())
749}
750
751fn effect_encode_error(err: serde_json::Error) -> RuntimeEffectControllerError {
752 RuntimeEffectControllerError::new(
753 "sqlite_effect_replay_encode",
754 format!("failed to encode runtime effect replay row: {err}"),
755 )
756}
757
758fn effect_decode_error(err: serde_json::Error) -> RuntimeEffectControllerError {
759 RuntimeEffectControllerError::new(
760 "sqlite_effect_replay_decode",
761 format!("failed to decode runtime effect replay row: {err}"),
762 )
763}