Skip to content

Commit b6191cd

Browse files
authored
Merge pull request #359 from obeli-sk/persist-sources
feat(sqlite,pg): Store sources and frame source mappings in db
2 parents 46e3e52 + 5ff25c9 commit b6191cd

11 files changed

Lines changed: 351 additions & 179 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

DEVELOPMENT.md

Lines changed: 10 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,10 @@ Or manually install dependencies (see [dev-deps.txt](dev-deps.txt)).
4343
Postgres must be running. See `.envrc-example` for environment variables.
4444
```sh
4545
# All tests
46-
./scripts/test.sh
46+
scripts/test.sh
47+
48+
# Tests with locally built activity, workflow and webhook JavaScript runtimes
49+
scripts/test-js-local.sh
4750

4851
# Specific test crate
4952
cargo test --package obeli-db-tests --test deployment_pagination
@@ -52,35 +55,17 @@ cargo test --package obeli-db-tests --test deployment_pagination
5255
cargo test --package obelisk grpc_server::tests
5356
```
5457

55-
56-
## Code Patterns
57-
58-
### WASM Component Error Variants
59-
60-
WASM components returning `result<T, E>` where `E` is a variant type must include an
61-
`execution-failed` case with no payload. `E` can also be a `string` or an empty type
62-
(see `enum ReturnType`).
63-
64-
### Adding Workflow Host Functions
65-
66-
WIT definitions:
67-
- Types: `wit/obelisk_types@X.Y.Z/types.wit` (symlinked as `@latest`)
68-
- Workflow support: `wit/obelisk_workflow@X.Y.Z/workflow-support.wit` (symlinked as `@latest`)
69-
70-
Implementation pattern:
71-
1. Define function signature in WIT with `@since` annotation
72-
2. Implement in `crates/wasm-workers/src/workflow/workflow_ctx.rs`
73-
3. Link via `add_to_linker_workflow_support()` using `func_wrap` or `func_wrap_async`
74-
4. Type conversions in `host_exports.rs` (WIT ↔ Rust types via wasmtime bindgen)
75-
5. Test with programs in `crates/testing/test-programs/`
76-
7758
## Key Files
7859

7960
| Task | Files |
8061
|------|-------|
62+
| TOML | `src/config/toml.rs`, `obelisk-help.toml` |
8163
| Database schema/queries | `crates/db-sqlite/src/sqlite_dao.rs`, `crates/db-postgres/src/postgres_dao.rs` |
8264
| Storage traits | `crates/concepts/src/storage.rs` |
83-
| gRPC API | `proto/obelisk.proto`, `src/server/grpc_server.rs` |
65+
| gRPC API | `proto/obelisk.proto`, `src/server/grpc_server.rs`,`crates/grpc/src/grpc_mapping.rs` |
8466
| REST API | `src/server/web_api_server.rs` |
85-
| Type conversions (gRPC) | `crates/grpc/src/grpc_mapping.rs` |
67+
| Server | `src/command/server.rs`, `crates/wasm-workers/src/registry.rs` |
68+
| Activities | `crates/wasm-workers/src/activity/` |
69+
| Workflows | `crates/wasm-workers/src/workflow/` |
70+
| Webhooks | `crates/wasm-workers/src/webhook/` |
8671
| Test utilities | `crates/testing/test-utils/src/` |

crates/concepts/src/storage.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1176,6 +1176,26 @@ pub trait DbExternalApi: DbConnection {
11761176
filter: BacktraceFilter,
11771177
) -> Result<BacktraceInfo, DbErrorRead>;
11781178

1179+
/// Store a source file associated with a component digest.
1180+
/// `frame_key` is either an exact frame symbol path or a suffix (with leading `/`)
1181+
/// when `is_suffix` is true. Idempotent — repeated calls for the same key are ignored.
1182+
async fn upsert_source_file(
1183+
&self,
1184+
component_digest: &ComponentDigest,
1185+
frame_key: &str,
1186+
is_suffix: bool,
1187+
content: &str,
1188+
) -> Result<(), DbErrorWrite>;
1189+
1190+
/// Look up a source file by component digest and a frame symbol path.
1191+
/// Matches either exact keys or suffix keys (where the frame path ends with the stored key).
1192+
/// Returns `None` if not found or if multiple suffix entries match (ambiguous).
1193+
async fn get_source_file(
1194+
&self,
1195+
component_digest: &ComponentDigest,
1196+
file: &str,
1197+
) -> Result<Option<String>, DbErrorRead>;
1198+
11791199
/// Returns executions sorted in descending order.
11801200
async fn list_executions(
11811201
&self,

crates/db-postgres/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ secrecy.workspace = true
2626
strum.workspace = true
2727
tokio-postgres.workspace = true
2828
rand.workspace = true
29+
sha2.workspace = true
2930
thiserror.workspace = true
3031
tokio.workspace = true
3132
tracing.workspace = true

crates/db-postgres/src/postgres_dao.rs

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ use db_common::{
3030
use deadpool_postgres::{Client, ManagerConfig, Pool, RecyclingMethod};
3131
use hashbrown::HashMap;
3232
use secrecy::{ExposeSecret as _, SecretString};
33+
use sha2::{Digest as _, Sha256};
3334
use std::{collections::VecDeque, pin::Pin, str::FromStr as _, sync::Arc, time::Duration};
3435
use std::{fmt::Write as _, panic::Location};
3536
use strum::IntoEnumIterator as _;
@@ -239,6 +240,24 @@ CREATE TABLE IF NOT EXISTS t_execution_backtrace (
239240

240241
pub const IDX_T_EXECUTION_BACKTRACE_EXECUTION_ID_VERSION: &str = r"
241242
CREATE INDEX IF NOT EXISTS idx_t_execution_backtrace_execution_id_version ON t_execution_backtrace (execution_id, version_min_including, version_max_excluding);
243+
";
244+
245+
// Source files
246+
pub const CREATE_TABLE_T_SOURCE_FILE: &str = r"
247+
CREATE TABLE IF NOT EXISTS t_source_file (
248+
content_hash BYTEA PRIMARY KEY,
249+
content TEXT NOT NULL
250+
);
251+
";
252+
pub const CREATE_TABLE_T_COMPONENT_SOURCE: &str = r"
253+
CREATE TABLE IF NOT EXISTS t_component_source (
254+
component_digest BYTEA NOT NULL,
255+
frame_key TEXT NOT NULL,
256+
is_suffix BOOLEAN NOT NULL,
257+
content_hash BYTEA NOT NULL,
258+
PRIMARY KEY (component_digest, frame_key, is_suffix),
259+
FOREIGN KEY (content_hash) REFERENCES t_source_file(content_hash)
260+
);
242261
";
243262

244263
// Logs & Std sterams
@@ -487,6 +506,8 @@ impl PostgresPool {
487506
ddl::CREATE_TABLE_T_WASM_BACKTRACE,
488507
ddl::CREATE_TABLE_T_EXECUTION_BACKTRACE,
489508
ddl::IDX_T_EXECUTION_BACKTRACE_EXECUTION_ID_VERSION,
509+
ddl::CREATE_TABLE_T_SOURCE_FILE,
510+
ddl::CREATE_TABLE_T_COMPONENT_SOURCE,
490511
ddl::CREATE_TABLE_T_LOG,
491512
ddl::IDX_T_LOG_EXECUTION_ID_RUN_ID_CREATED_AT,
492513
ddl::IDX_T_LOG_EXECUTION_ID_CREATED_AT,
@@ -4062,6 +4083,73 @@ impl DbExternalApi for PostgresConnection {
40624083
})
40634084
}
40644085

4086+
#[instrument(skip_all)]
4087+
async fn upsert_source_file(
4088+
&self,
4089+
component_digest: &ComponentDigest,
4090+
frame_key: &str,
4091+
is_suffix: bool,
4092+
content: &str,
4093+
) -> Result<(), DbErrorWrite> {
4094+
let content_hash: [u8; 32] = Sha256::digest(content.as_bytes()).into();
4095+
let mut client_guard = self.client.lock().await;
4096+
let tx = client_guard.transaction().await?;
4097+
tx.execute(
4098+
"INSERT INTO t_source_file (content_hash, content) \
4099+
VALUES ($1, $2) \
4100+
ON CONFLICT (content_hash) DO NOTHING",
4101+
&[&content_hash.as_slice(), &content],
4102+
)
4103+
.await?;
4104+
tx.execute(
4105+
"INSERT INTO t_component_source \
4106+
(component_digest, frame_key, is_suffix, content_hash) \
4107+
VALUES ($1, $2, $3, $4) \
4108+
ON CONFLICT (component_digest, frame_key, is_suffix) DO NOTHING",
4109+
&[
4110+
&component_digest.as_slice(),
4111+
&frame_key,
4112+
&is_suffix,
4113+
&content_hash.as_slice(),
4114+
],
4115+
)
4116+
.await?;
4117+
tx.commit().await?;
4118+
Ok(())
4119+
}
4120+
4121+
#[instrument(skip_all)]
4122+
async fn get_source_file(
4123+
&self,
4124+
component_digest: &ComponentDigest,
4125+
file: &str,
4126+
) -> Result<Option<String>, DbErrorRead> {
4127+
let mut client_guard = self.client.lock().await;
4128+
let tx = client_guard.transaction().await?;
4129+
let rows = tx
4130+
.query(
4131+
"SELECT s.content \
4132+
FROM t_component_source cs \
4133+
JOIN t_source_file s ON cs.content_hash = s.content_hash \
4134+
WHERE cs.component_digest = $1 \
4135+
AND ( \
4136+
(NOT cs.is_suffix AND cs.frame_key = $2) \
4137+
OR (cs.is_suffix AND right($2, length(cs.frame_key)) = cs.frame_key) \
4138+
)",
4139+
&[&component_digest.as_slice(), &file],
4140+
)
4141+
.await?;
4142+
tx.commit().await?;
4143+
match rows.len() {
4144+
0 => Ok(None),
4145+
1 => Ok(Some(get::<String, _>(&rows[0], "content")?)),
4146+
_ => {
4147+
warn!("Multiple suffix matches for '{file}', returning None");
4148+
Ok(None)
4149+
}
4150+
}
4151+
}
4152+
40654153
#[instrument(skip(self))]
40664154
async fn list_executions(
40674155
&self,

crates/db-sqlite/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ itertools.workspace = true
2525
rand.workspace = true
2626
rusqlite.workspace = true
2727
serde_json.workspace = true
28+
sha2.workspace = true
2829
serde.workspace = true
2930
strum.workspace = true
3031
tempfile = { workspace = true, optional = true }

crates/db-sqlite/src/sqlite_dao.rs

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ use rusqlite::{
3434
CachedStatement, Connection, OpenFlags, OptionalExtension, Params, Row, ToSql, Transaction,
3535
TransactionBehavior, named_params, types::ToSqlOutput,
3636
};
37+
use sha2::{Digest as _, Sha256};
3738
use std::{
3839
cmp::max,
3940
collections::VecDeque,
@@ -303,6 +304,30 @@ CREATE TABLE IF NOT EXISTS t_wasm_backtrace (
303304
) STRICT
304305
";
305306

307+
// Content-addressed store for source file text. Content hash is SHA-256 of the UTF-8 content.
308+
const CREATE_TABLE_T_SOURCE_FILE: &str = r"
309+
CREATE TABLE IF NOT EXISTS t_source_file (
310+
content_hash BLOB NOT NULL,
311+
content TEXT NOT NULL,
312+
313+
PRIMARY KEY (content_hash)
314+
) STRICT
315+
";
316+
// Maps (component_digest, frame_key, is_suffix) to a source file.
317+
// frame_key is the exact frame symbol path (is_suffix=0) or a '/'-prefixed suffix (is_suffix=1).
318+
const CREATE_TABLE_T_COMPONENT_SOURCE: &str = r"
319+
CREATE TABLE IF NOT EXISTS t_component_source (
320+
component_digest BLOB NOT NULL,
321+
frame_key TEXT NOT NULL,
322+
is_suffix INTEGER NOT NULL,
323+
content_hash BLOB NOT NULL,
324+
325+
PRIMARY KEY (component_digest, frame_key, is_suffix),
326+
FOREIGN KEY (content_hash)
327+
REFERENCES t_source_file(content_hash)
328+
) STRICT
329+
";
330+
306331
/// Stores logs and std stream output of execution runs. Append only.
307332
/// Logs have `level` and `message` null.
308333
/// Std streams have `stream_type`, `payload` not null.
@@ -779,6 +804,9 @@ impl SqlitePool {
779804
conn_execute(&conn, CREATE_TABLE_T_EXECUTION_BACKTRACE, [])?;
780805
conn_execute(&conn, IDX_T_EXECUTION_BACKTRACE_EXECUTION_ID_VERSION, [])?;
781806
conn_execute(&conn, CREATE_TABLE_T_WASM_BACKTRACE, [])?;
807+
// source files
808+
conn_execute(&conn, CREATE_TABLE_T_SOURCE_FILE, [])?;
809+
conn_execute(&conn, CREATE_TABLE_T_COMPONENT_SOURCE, [])?;
782810
// t_log
783811
conn_execute(&conn, CREATE_TABLE_T_LOG, [])?;
784812
conn_execute(&conn, IDX_T_LOG_EXECUTION_ID_RUN_ID_CREATED_AT, [])?;
@@ -3862,6 +3890,92 @@ impl DbExternalApi for SqlitePool {
38623890
).await
38633891
}
38643892

3893+
#[instrument(skip_all)]
3894+
async fn upsert_source_file(
3895+
&self,
3896+
component_digest: &ComponentDigest,
3897+
frame_key: &str,
3898+
is_suffix: bool,
3899+
content: &str,
3900+
) -> Result<(), DbErrorWrite> {
3901+
let content_hash: [u8; 32] = Sha256::digest(content.as_bytes()).into();
3902+
let component_digest = component_digest.clone();
3903+
let frame_key = frame_key.to_owned();
3904+
let content = content.to_owned();
3905+
self.transaction(
3906+
move |tx| {
3907+
tx.prepare(
3908+
"INSERT OR IGNORE INTO t_source_file (content_hash, content) \
3909+
VALUES (:content_hash, :content)",
3910+
)?
3911+
.execute(named_params! {
3912+
":content_hash": content_hash,
3913+
":content": content,
3914+
})?;
3915+
tx.prepare(
3916+
"INSERT OR IGNORE INTO t_component_source \
3917+
(component_digest, frame_key, is_suffix, content_hash) \
3918+
VALUES (:component_digest, :frame_key, :is_suffix, :content_hash)",
3919+
)?
3920+
.execute(named_params! {
3921+
":component_digest": component_digest,
3922+
":frame_key": frame_key,
3923+
":is_suffix": is_suffix,
3924+
":content_hash": content_hash,
3925+
})?;
3926+
Ok(())
3927+
},
3928+
TxType::Other,
3929+
"upsert_source_file",
3930+
)
3931+
.await
3932+
}
3933+
3934+
#[instrument(skip_all)]
3935+
async fn get_source_file(
3936+
&self,
3937+
component_digest: &ComponentDigest,
3938+
file: &str,
3939+
) -> Result<Option<String>, DbErrorRead> {
3940+
let component_digest = component_digest.clone();
3941+
let file = file.to_owned();
3942+
self.transaction(
3943+
move |tx| {
3944+
let mut stmt = tx.prepare(
3945+
"SELECT s.content \
3946+
FROM t_component_source cs \
3947+
JOIN t_source_file s ON cs.content_hash = s.content_hash \
3948+
WHERE cs.component_digest = :component_digest \
3949+
AND ( \
3950+
(cs.is_suffix = 0 AND cs.frame_key = :file) \
3951+
OR (cs.is_suffix = 1 AND \
3952+
substr(:file, length(:file) - length(cs.frame_key) + 1) = cs.frame_key) \
3953+
)",
3954+
)?;
3955+
let rows: Vec<String> = stmt
3956+
.query_map(
3957+
named_params! {
3958+
":component_digest": component_digest,
3959+
":file": file,
3960+
},
3961+
|row| row.get(0),
3962+
)?
3963+
.collect::<Result<_, _>>()?;
3964+
match rows.len() {
3965+
0 => Ok(None),
3966+
1 => Ok(Some(rows.into_iter().next().unwrap())),
3967+
_ => {
3968+
warn!("Multiple suffix matches for '{file}', returning None");
3969+
Ok(None)
3970+
}
3971+
}
3972+
},
3973+
TxType::Other,
3974+
"get_source_file",
3975+
)
3976+
.await
3977+
}
3978+
38653979
#[instrument(skip(self))]
38663980
async fn list_executions(
38673981
&self,

crates/wasm-workers/src/activity/activity_stub_inline.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ pub fn compile_activity_stub_inline(
3535
workflow_or_activity_config: Some(component_config_importable),
3636
wit: wit_text_with_extensions,
3737
workflow_replay_info: None,
38-
source: None,
3938
})
4039
}
4140

0 commit comments

Comments
 (0)