Skip to main content

lash_sqlite_store/
attachments.rs

1//! The lashlang module-artifact store and the attachment write-ahead manifest.
2//!
3//! Both traits in this module have synchronous-looking call sites in their
4//! consumers but bridge to the async [`SqliteConnection`] underneath:
5//!
6//! * [`lashlang::LashlangArtifactStore`] is itself an `#[async_trait]`, so its
7//!   methods `.await` the connection wrapper directly (matching the the prior store
8//!   store's async surface byte-for-byte).
9//! * [`AttachmentManifest`] is a *synchronous* trait. Its bodies therefore wrap
10//!   the async store work in [`block_on_store`], exactly as the prior store did.
11//!
12//! Every DB body is a synchronous rusqlite closure handed to `conn.call`
13//! (reads) or `conn.write` (read-then-write); only the wrapper call is awaited.
14
15use super::*;
16
17/// Logical keyspaces multiplexed onto the `artifact_refs` pointer table. Each
18/// namespace owns its own half of the `(namespace, artifact_ref)` composite
19/// primary key. The `blobs` table is content-addressed, but the `artifact_refs`
20/// pointer is *not*: without the namespace column, a module ref that collides
21/// with a process-execution-env ref would rewrite the same pointer row under
22/// `INSERT OR REPLACE`, so content-addressing alone does not keep the namespaces
23/// disjoint. The composite key does.
24pub(crate) const MODULE_ARTIFACT_NAMESPACE: &str = "lashlang_module";
25pub(crate) const RAW_ARTIFACT_NAMESPACE: &str = "lashlang_artifact";
26pub(crate) const PROCESS_ENV_NAMESPACE: &str = "process_execution_env";
27pub(crate) const CURRENT_TRIGGER_MANIFEST_NAMESPACE: &str = "lashlang_trigger_manifest";
28
29impl Store {
30    async fn put_artifact_ref_blob(
31        &self,
32        namespace: &'static str,
33        artifact_ref: String,
34        descriptor: BlobArtifactDescriptor,
35        bytes: Vec<u8>,
36    ) -> Result<(), StoreError> {
37        let blob_profile = self.options.blob_profile;
38        self.conn
39            .write(move |tx| {
40                let blob_ref =
41                    Self::insert_artifact_blob_conn(tx, descriptor, &bytes, blob_profile)?;
42                tx.execute(
43                    "INSERT OR REPLACE INTO artifact_refs (namespace, artifact_ref, blob_ref)
44                     VALUES (?1, ?2, ?3)",
45                    params![namespace, artifact_ref, blob_ref.as_str()],
46                )?;
47                Ok(())
48            })
49            .await
50            .map_err(sqlite_error)
51    }
52
53    async fn get_artifact_ref_blob(
54        &self,
55        namespace: &'static str,
56        artifact_ref: String,
57        missing_diagnostic: String,
58    ) -> Result<Option<Vec<u8>>, StoreError> {
59        let resolved = self
60            .conn
61            .call(move |conn| {
62                let blob_ref: Option<String> = conn
63                    .query_row(
64                        "SELECT blob_ref FROM artifact_refs
65                         WHERE namespace = ?1 AND artifact_ref = ?2",
66                        params![namespace, artifact_ref],
67                        |row| row.get::<_, String>(0),
68                    )
69                    .optional()?;
70                let Some(blob_ref) = blob_ref else {
71                    return Ok(None);
72                };
73                Ok(Some(Self::get_blob_conn(conn, &BlobRef(blob_ref))))
74            })
75            .await
76            .map_err(sqlite_error)?;
77        let Some(blob) = resolved else {
78            return Ok(None);
79        };
80        blob.ok_or_else(|| {
81            StoreError::Backend(format!("{missing_diagnostic} points at a missing blob"))
82        })
83        .map(Some)
84    }
85}
86
87#[async_trait::async_trait]
88impl lashlang::LashlangArtifactStore for Store {
89    fn durability_tier(&self) -> lashlang::DurabilityTier {
90        lashlang::DurabilityTier::Durable
91    }
92
93    async fn put_module_artifact(
94        &self,
95        artifact: &lashlang::ModuleArtifact,
96    ) -> Result<(), lashlang::ArtifactStoreError> {
97        let bytes = artifact
98            .to_store_bytes()
99            .map_err(|err| lashlang::ArtifactStoreError::Encode(err.to_string()))?;
100        let artifact_ref = artifact.module_ref.as_str().to_string();
101        self.put_artifact_ref_blob(
102            MODULE_ARTIFACT_NAMESPACE,
103            artifact_ref,
104            BlobArtifactDescriptor::lashlang_module(),
105            bytes,
106        )
107        .await
108        .map_err(|err| lashlang::ArtifactStoreError::Backend(err.to_string()))?;
109        self.artifact_cache
110            .lock()
111            .map_err(|_| {
112                lashlang::ArtifactStoreError::Backend("artifact cache lock poisoned".to_string())
113            })?
114            .insert(artifact.module_ref.clone(), Arc::new(artifact.clone()));
115        Ok(())
116    }
117
118    async fn get_module_artifact(
119        &self,
120        module_ref: &lashlang::ModuleRef,
121    ) -> Result<Option<Arc<lashlang::ModuleArtifact>>, lashlang::ArtifactStoreError> {
122        if let Some(artifact) = self
123            .artifact_cache
124            .lock()
125            .map_err(|_| {
126                lashlang::ArtifactStoreError::Backend("artifact cache lock poisoned".to_string())
127            })?
128            .get(module_ref)
129            .cloned()
130        {
131            return Ok(Some(artifact));
132        }
133
134        let artifact_ref = module_ref.as_str().to_string();
135        let Some(bytes) = self
136            .get_artifact_ref_blob(
137                MODULE_ARTIFACT_NAMESPACE,
138                artifact_ref,
139                format!("lashlang module artifact `{module_ref}`"),
140            )
141            .await
142            .map_err(|err| lashlang::ArtifactStoreError::Backend(err.to_string()))?
143        else {
144            return Ok(None);
145        };
146        let artifact = Arc::new(
147            lashlang::ModuleArtifact::from_store_bytes(&bytes)
148                .map_err(lashlang::ArtifactStoreError::from)?,
149        );
150        self.artifact_cache
151            .lock()
152            .map_err(|_| {
153                lashlang::ArtifactStoreError::Backend("artifact cache lock poisoned".to_string())
154            })?
155            .insert(module_ref.clone(), artifact.clone());
156        Ok(Some(artifact))
157    }
158
159    async fn replace_current_trigger_manifest(
160        &self,
161        owner_namespace: &str,
162        artifact: &lashlang::ModuleArtifact,
163    ) -> Result<lashlang::TriggerManifestReplacement, lashlang::ArtifactStoreError> {
164        let current = lashlang::CurrentTriggerKeyManifest {
165            module_ref: artifact.module_ref.clone(),
166            manifest: artifact.trigger_key_manifest.clone(),
167        };
168        let bytes = serde_json::to_vec(&current)
169            .map_err(|err| lashlang::ArtifactStoreError::Encode(err.to_string()))?;
170        let owner_namespace = owner_namespace.to_string();
171        let blob_profile = self.options.blob_profile;
172        let previous_bytes = self
173            .conn
174            .write(move |tx| {
175                let previous_blob_ref: Option<String> = tx
176                    .query_row(
177                        "SELECT blob_ref FROM artifact_refs
178                         WHERE namespace = ?1 AND artifact_ref = ?2",
179                        params![CURRENT_TRIGGER_MANIFEST_NAMESPACE, owner_namespace.as_str()],
180                        |row| row.get(0),
181                    )
182                    .optional()?;
183                let previous_bytes = previous_blob_ref
184                    .and_then(|blob_ref| Self::get_blob_conn(tx, &BlobRef(blob_ref)));
185                let blob_ref = Self::insert_artifact_blob_conn(
186                    tx,
187                    BlobArtifactDescriptor::lashlang_module(),
188                    &bytes,
189                    blob_profile,
190                )?;
191                tx.execute(
192                    "INSERT OR REPLACE INTO artifact_refs (namespace, artifact_ref, blob_ref)
193                     VALUES (?1, ?2, ?3)",
194                    params![
195                        CURRENT_TRIGGER_MANIFEST_NAMESPACE,
196                        owner_namespace.as_str(),
197                        blob_ref.as_str()
198                    ],
199                )?;
200                Ok(previous_bytes)
201            })
202            .await
203            .map_err(|err| lashlang::ArtifactStoreError::Backend(err.to_string()))?;
204        let previous = previous_bytes
205            .map(|bytes| {
206                serde_json::from_slice::<lashlang::CurrentTriggerKeyManifest>(&bytes)
207                    .map_err(|err| lashlang::ArtifactStoreError::Decode(err.to_string()))
208            })
209            .transpose()?;
210        Ok(lashlang::TriggerManifestReplacement {
211            previous_module_ref: previous.as_ref().map(|entry| entry.module_ref.clone()),
212            current_module_ref: artifact.module_ref.clone(),
213            diff: previous
214                .map(|entry| entry.manifest.diff(&artifact.trigger_key_manifest))
215                .unwrap_or_default(),
216        })
217    }
218
219    async fn get_current_trigger_manifest(
220        &self,
221        owner_namespace: &str,
222    ) -> Result<Option<lashlang::CurrentTriggerKeyManifest>, lashlang::ArtifactStoreError> {
223        let bytes = self
224            .get_artifact_ref_blob(
225                CURRENT_TRIGGER_MANIFEST_NAMESPACE,
226                owner_namespace.to_string(),
227                format!("current trigger manifest `{owner_namespace}`"),
228            )
229            .await
230            .map_err(|err| lashlang::ArtifactStoreError::Backend(err.to_string()))?;
231        bytes
232            .map(|bytes| {
233                serde_json::from_slice(&bytes)
234                    .map_err(|err| lashlang::ArtifactStoreError::Decode(err.to_string()))
235            })
236            .transpose()
237    }
238
239    async fn put_artifact_bytes(
240        &self,
241        artifact_ref: &str,
242        descriptor: &str,
243        bytes: &[u8],
244    ) -> Result<(), lashlang::ArtifactStoreError> {
245        let artifact_ref = artifact_ref.to_string();
246        let descriptor = match descriptor {
247            "process_execution_env" => BlobArtifactDescriptor::process_execution_env(),
248            _ => BlobArtifactDescriptor::new(PersistedArtifactKind::GenericBlob, Vec::new()),
249        };
250        self.put_artifact_ref_blob(
251            RAW_ARTIFACT_NAMESPACE,
252            artifact_ref,
253            descriptor,
254            bytes.to_vec(),
255        )
256        .await
257        .map_err(|err| lashlang::ArtifactStoreError::Backend(err.to_string()))
258    }
259
260    async fn get_artifact_bytes(
261        &self,
262        artifact_ref: &str,
263    ) -> Result<Option<Vec<u8>>, lashlang::ArtifactStoreError> {
264        let artifact_ref = artifact_ref.to_string();
265        self.get_artifact_ref_blob(
266            RAW_ARTIFACT_NAMESPACE,
267            artifact_ref.clone(),
268            format!("artifact `{artifact_ref}`"),
269        )
270        .await
271        .map_err(|err| lashlang::ArtifactStoreError::Backend(err.to_string()))
272    }
273}
274
275#[async_trait::async_trait]
276impl lash_core::ProcessExecutionEnvStore for Store {
277    fn durability_tier(&self) -> DurabilityTier {
278        DurabilityTier::Durable
279    }
280
281    async fn put_process_execution_env(
282        &self,
283        env_ref: &lash_core::ProcessExecutionEnvRef,
284        bytes: &[u8],
285    ) -> Result<(), lash_core::PluginError> {
286        let artifact_ref = env_ref.as_str().to_string();
287        self.put_artifact_ref_blob(
288            PROCESS_ENV_NAMESPACE,
289            artifact_ref,
290            BlobArtifactDescriptor::process_execution_env(),
291            bytes.to_vec(),
292        )
293        .await
294        .map_err(|err| lash_core::PluginError::Session(err.to_string()))
295    }
296
297    async fn get_process_execution_env(
298        &self,
299        env_ref: &lash_core::ProcessExecutionEnvRef,
300    ) -> Result<Option<Vec<u8>>, lash_core::PluginError> {
301        let artifact_ref = env_ref.as_str().to_string();
302        self.get_artifact_ref_blob(
303            PROCESS_ENV_NAMESPACE,
304            artifact_ref.clone(),
305            format!("process execution env `{artifact_ref}`"),
306        )
307        .await
308        .map_err(|err| lash_core::PluginError::Session(err.to_string()))
309    }
310}
311
312impl AttachmentManifest for Store {
313    fn record_intent(&self, intent: AttachmentIntent) -> Result<(), StoreError> {
314        block_on_store(async {
315            let attachment_id = intent.attachment_id.as_str().to_string();
316            let session_id = intent.session_id.as_str().to_string();
317            let canonical_uri = intent.canonical_uri.as_str().to_string();
318            let intent_at_ms = intent.intent_at_epoch_ms as i64;
319            let owner_kind = intent.owner_kind.map(AttachmentOwnerKind::as_str);
320            let owner_id = intent.owner_id;
321            self.conn
322                .call(move |conn| {
323                    // Re-recording refreshes the timestamp and durable owner
324                    // together. GC later composes this age with owner-death proof.
325                    conn.execute(
326                        "INSERT INTO attachment_manifest
327                            (attachment_id, session_id, canonical_uri, intent_at_ms,
328                             committed_at_ms, owner_kind, owner_id)
329                         VALUES (?1, ?2, ?3, ?4, NULL, ?5, ?6)
330                         ON CONFLICT(session_id, attachment_id) DO UPDATE SET
331                            canonical_uri = excluded.canonical_uri,
332                            intent_at_ms = excluded.intent_at_ms,
333                            owner_kind = excluded.owner_kind,
334                            owner_id = excluded.owner_id",
335                        params![
336                            attachment_id,
337                            session_id,
338                            canonical_uri,
339                            intent_at_ms,
340                            owner_kind,
341                            owner_id
342                        ],
343                    )
344                })
345                .await
346                .map_err(sqlite_error)?;
347            Ok(())
348        })
349    }
350
351    fn commit_refs(
352        &self,
353        session_id: &str,
354        attachment_ids: &[AttachmentId],
355    ) -> Result<(), StoreError> {
356        if attachment_ids.is_empty() {
357            return Ok(());
358        }
359        block_on_store(async {
360            let session_id = session_id.to_string();
361            let attachment_ids: Vec<String> = attachment_ids
362                .iter()
363                .map(|id| id.as_str().to_string())
364                .collect();
365            let now = self.clock.timestamp_ms() as i64;
366            self.conn
367                .write(move |tx| {
368                    let mut stmt = tx.prepare(
369                        "UPDATE attachment_manifest
370                         SET committed_at_ms = COALESCE(committed_at_ms, ?1)
371                         WHERE attachment_id = ?2 AND session_id = ?3",
372                    )?;
373                    for id in &attachment_ids {
374                        stmt.execute(params![now, id, session_id])?;
375                    }
376                    Ok(())
377                })
378                .await
379                .map_err(sqlite_error)?;
380            Ok(())
381        })
382    }
383
384    fn list_uncommitted(
385        &self,
386        older_than_epoch_ms: u64,
387    ) -> Result<Vec<AttachmentManifestEntry>, StoreError> {
388        block_on_store(async {
389            let older_than = older_than_epoch_ms as i64;
390            self.conn
391                .call(move |conn| {
392                    let mut stmt = conn.prepare(
393                        "SELECT attachment_id, session_id, canonical_uri, intent_at_ms,
394                                committed_at_ms, owner_kind, owner_id
395                         FROM attachment_manifest
396                         WHERE committed_at_ms IS NULL AND intent_at_ms <= ?1
397                         ORDER BY intent_at_ms ASC",
398                    )?;
399                    let rows = stmt.query_map(params![older_than], |row| {
400                        let id: String = row.get(0)?;
401                        let session_id: String = row.get(1)?;
402                        let canonical_uri: String = row.get(2)?;
403                        let intent_at_ms: i64 = row.get(3)?;
404                        let committed_at_ms: Option<i64> = row.get(4)?;
405                        let owner_kind: Option<String> = row.get(5)?;
406                        let owner_id: Option<String> = row.get(6)?;
407                        Ok(AttachmentManifestEntry {
408                            attachment_id: AttachmentId::new(id),
409                            session_id,
410                            canonical_uri,
411                            intent_at_epoch_ms: intent_at_ms as u64,
412                            committed_at_epoch_ms: committed_at_ms.map(|v| v as u64),
413                            owner_kind: match owner_kind.as_deref() {
414                                Some("turn") => Some(AttachmentOwnerKind::Turn),
415                                Some("process") => Some(AttachmentOwnerKind::Process),
416                                _ => None,
417                            },
418                            owner_id,
419                        })
420                    })?;
421                    Ok(rows.filter_map(Result::ok).collect())
422                })
423                .await
424                .map_err(sqlite_error)
425        })
426    }
427
428    fn forget_aged_uncommitted_intents(
429        &self,
430        intent_grace_cutoff_epoch_ms: u64,
431    ) -> Result<(), StoreError> {
432        block_on_store(async {
433            let cutoff = intent_grace_cutoff_epoch_ms as i64;
434            let process_registry_attached = self.process_registry_attached;
435            self.conn
436                .write(move |tx| {
437                    // One conditional DELETE composes age with owner-death proof.
438                    // The attached process DB makes the NOT EXISTS predicate part
439                    // of this same SQLite statement/transaction, avoiding a
440                    // read-process-then-forget race across the per-session topology.
441                    let process_dead = if process_registry_attached {
442                        "OR (
443                            manifest.owner_kind = 'process'
444                            AND NOT EXISTS (
445                                SELECT 1 FROM process_registry.processes AS process
446                                WHERE process.process_id = manifest.owner_id
447                            )
448                        )"
449                    } else {
450                        // Without a configured process registry, conservatively
451                        // retain process-owned rows rather than guess liveness.
452                        ""
453                    };
454                    let sql = format!(
455                        "DELETE FROM attachment_manifest AS manifest
456                         WHERE manifest.committed_at_ms IS NULL
457                           AND manifest.intent_at_ms <= ?1
458                           AND (
459                                manifest.owner_kind IS NULL
460                                OR (
461                                    manifest.owner_kind = 'turn'
462                                    AND EXISTS (
463                                        SELECT 1 FROM runtime_turn_commits AS turn_commit
464                                        WHERE turn_commit.session_id = manifest.session_id
465                                          AND turn_commit.turn_id <> manifest.owner_id
466                                          AND turn_commit.committed_at_ms > manifest.intent_at_ms
467                                    )
468                                )
469                                {process_dead}
470                           )"
471                    );
472                    tx.execute(&sql, params![cutoff])?;
473                    Ok(())
474                })
475                .await
476                .map_err(sqlite_error)?;
477            Ok(())
478        })
479    }
480
481    fn has_live_ref_for_id(
482        &self,
483        attachment_id: &AttachmentId,
484        intent_grace_cutoff_epoch_ms: u64,
485    ) -> Result<bool, StoreError> {
486        block_on_store(async {
487            let attachment_id = attachment_id.as_str().to_string();
488            let cutoff = intent_grace_cutoff_epoch_ms as i64;
489            let process_registry_attached = self.process_registry_attached;
490            self.conn
491                .call(move |conn| {
492                    let process_dead = if process_registry_attached {
493                        "OR (
494                            manifest.owner_kind = 'process'
495                            AND NOT EXISTS (
496                                SELECT 1 FROM process_registry.processes AS process
497                                WHERE process.process_id = manifest.owner_id
498                            )
499                        )"
500                    } else {
501                        ""
502                    };
503                    let sql = format!(
504                        "SELECT 1 FROM attachment_manifest AS manifest
505                         WHERE manifest.attachment_id = ?1
506                           AND NOT (
507                                manifest.committed_at_ms IS NULL
508                                AND manifest.intent_at_ms <= ?2
509                                AND (
510                                    manifest.owner_kind IS NULL
511                                    OR (
512                                        manifest.owner_kind = 'turn'
513                                        AND EXISTS (
514                                            SELECT 1 FROM runtime_turn_commits AS turn_commit
515                                            WHERE turn_commit.session_id = manifest.session_id
516                                              AND turn_commit.turn_id <> manifest.owner_id
517                                              AND turn_commit.committed_at_ms > manifest.intent_at_ms
518                                        )
519                                    )
520                                    {process_dead}
521                                )
522                           )
523                         LIMIT 1"
524                    );
525                    conn.query_row(
526                        &sql,
527                        params![attachment_id, cutoff],
528                        |_| Ok(()),
529                    )
530                    .optional()
531                    .map(|found| found.is_some())
532                })
533                .await
534                .map_err(sqlite_error)
535        })
536    }
537
538    fn forget(&self, session_id: &str, attachment_id: &AttachmentId) -> Result<(), StoreError> {
539        block_on_store(async {
540            let session_id = session_id.to_string();
541            let attachment_id = attachment_id.as_str().to_string();
542            self.conn
543                .call(move |conn| {
544                    conn.execute(
545                        "DELETE FROM attachment_manifest
546                         WHERE session_id = ?1 AND attachment_id = ?2",
547                        params![session_id, attachment_id],
548                    )
549                })
550                .await
551                .map_err(sqlite_error)?;
552            Ok(())
553        })
554    }
555
556    fn holds_ref(
557        &self,
558        session_id: &str,
559        attachment_id: &AttachmentId,
560    ) -> Result<bool, StoreError> {
561        block_on_store(async {
562            let session_id = session_id.to_string();
563            let attachment_id = attachment_id.as_str().to_string();
564            self.conn
565                .call(move |conn| {
566                    conn.query_row(
567                        "SELECT 1 FROM attachment_manifest
568                         WHERE session_id = ?1 AND attachment_id = ?2",
569                        params![session_id, attachment_id],
570                        |_| Ok(()),
571                    )
572                    .optional()
573                    .map(|found| found.is_some())
574                })
575                .await
576                .map_err(sqlite_error)
577        })
578    }
579
580    fn list_all_refs(&self) -> Result<Vec<AttachmentId>, StoreError> {
581        block_on_store(async {
582            self.conn
583                .call(move |conn| {
584                    let mut stmt =
585                        conn.prepare("SELECT DISTINCT attachment_id FROM attachment_manifest")?;
586                    let rows = stmt.query_map([], |row| {
587                        let id: String = row.get(0)?;
588                        Ok(AttachmentId::new(id))
589                    })?;
590                    Ok(rows.filter_map(Result::ok).collect())
591                })
592                .await
593                .map_err(sqlite_error)
594        })
595    }
596}