Skip to content

Commit bdd2b5d

Browse files
authored
[2/5] Async IDC: outbox schema. (#6104)
# Description of Changes Based on #6084 PR can be reviewed commit wise, It does 3 things: ### Commit 1: Add IDC outbox in ModuleDef This introduces `OutboxDef` in `ModuleDef` / `RawModuleDefV10`, wires it through schema construction, and validates that outbox definitions reference valid tables, columns, reducers, target identity columns, argument columns, and result handlers. ### Commit 2: Record outbound IDC messages. When an outbox table row is written, the datastore advances the durable outbound stream state in `st_outbound_stream` and records the queued message in `st_outbound_msg`. ### Commit 3: Support IDC outbox schema migration Outbox metadata changes such as `remote_reducer`, `arg_columns`, `on_result_reducer`, and `signature_hash` are handled through `ChangeOutbox`, while unsafe presence/identity changes such as adding, removing outbox rejects. Table migrations such as adding columns change table id, we preserve existing IDC outbound stream state so users can evolve schemas used by dependent reducers without losing stream continuity. # API and ABI breaking changes NA # Rollback safety impact n/a # Expected complexity level and risk 2.5 # Testing unit tests.
1 parent e596513 commit bdd2b5d

27 files changed

Lines changed: 1594 additions & 70 deletions

File tree

‎crates/bindings-csharp/Runtime/Internal/Autogen/RawModuleDefV10Section.g.cs‎

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

‎crates/bindings-csharp/Runtime/Internal/Autogen/RawOutboxDefV10.g.cs‎

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

‎crates/core/src/host/instance_env.rs‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -472,8 +472,13 @@ impl InstanceEnv {
472472
},
473473
)?;
474474

475-
if insert_flags.is_scheduler_table {
476-
self.schedule_row(stdb, tx, table_id, row_ptr)?;
475+
if insert_flags.has_post_write_work() {
476+
if insert_flags.is_scheduler_table {
477+
self.schedule_row(stdb, tx, table_id, row_ptr)?;
478+
}
479+
if insert_flags.is_outbox_table {
480+
tx.record_outbox_insert(table_id, row_ptr).map_err(DBError::from)?;
481+
}
477482
}
478483

479484
// Note, we update the metric for bytes written after the insert.
@@ -554,8 +559,13 @@ impl InstanceEnv {
554559
},
555560
)?;
556561

557-
if update_flags.is_scheduler_table {
558-
self.schedule_row(stdb, tx, table_id, row_ptr)?;
562+
if update_flags.has_post_write_work() {
563+
if update_flags.is_scheduler_table {
564+
self.schedule_row(stdb, tx, table_id, row_ptr)?;
565+
}
566+
if update_flags.is_outbox_table {
567+
tx.record_outbox_insert(table_id, row_ptr).map_err(DBError::from)?;
568+
}
559569
}
560570
tx.metrics.bytes_written += buffer.len();
561571
tx.metrics.rows_updated += 1;

‎crates/core/src/sql/execute.rs‎

Lines changed: 117 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -239,11 +239,12 @@ pub(crate) mod tests {
239239
use spacetimedb_lib::bsatn::ToBsatn;
240240
use spacetimedb_lib::db::auth::{StAccess, StTableType};
241241
use spacetimedb_lib::error::{ResultTest, TestError};
242-
use spacetimedb_lib::{AlgebraicValue, Identity};
242+
use spacetimedb_lib::{AlgebraicValue, Identity, ScheduleAt, TimeDuration};
243243
use spacetimedb_primitives::{col_list, ColId, TableId};
244244
use spacetimedb_sats::{product, AlgebraicType, ArrayValue, ProductType};
245245
use spacetimedb_schema::identifier::Identifier;
246-
use spacetimedb_schema::schema::{ColumnSchema, TableSchema};
246+
use spacetimedb_schema::reducer_name::ReducerName;
247+
use spacetimedb_schema::schema::{ColumnSchema, OutboxSchema, ScheduleSchema, TableSchema};
247248
use spacetimedb_schema::table_name::TableName;
248249

249250
#[test]
@@ -400,6 +401,7 @@ pub(crate) mod tests {
400401
None,
401402
false,
402403
None,
404+
None,
403405
),
404406
)?;
405407
let schema = db.schema_for_table_mut(tx, table_id)?;
@@ -425,6 +427,69 @@ pub(crate) mod tests {
425427
Ok((stdb, TestRows { data: rows }))
426428
}
427429

430+
fn create_outbox_table() -> ResultTest<(TestDB, TableId)> {
431+
let stdb = TestDB::durable()?;
432+
let table_id = with_auto_commit(&stdb, |tx| {
433+
let mut schema = TableSchema::new(
434+
TableId::SENTINEL,
435+
TableName::for_test("outbound"),
436+
None,
437+
vec![
438+
ColumnSchema::for_test(0, "msg_id", AlgebraicType::U64),
439+
ColumnSchema::for_test(1, "target", AlgebraicType::identity()),
440+
],
441+
vec![],
442+
vec![],
443+
vec![],
444+
StTableType::User,
445+
StAccess::Public,
446+
None,
447+
Some(ColId(0)),
448+
false,
449+
None,
450+
None,
451+
);
452+
schema.outbox = Some(OutboxSchema {
453+
remote_reducer: ReducerName::for_test("receive"),
454+
target_column: ColId(1),
455+
arg_columns: vec![],
456+
on_result_reducer: None,
457+
signature_hash: "test-signature".into(),
458+
});
459+
stdb.create_table(tx, schema)
460+
})?;
461+
Ok((stdb, table_id))
462+
}
463+
464+
fn create_scheduled_table() -> ResultTest<(TestDB, TableId)> {
465+
let stdb = TestDB::durable()?;
466+
let table_id = with_auto_commit(&stdb, |tx| {
467+
stdb.create_table(
468+
tx,
469+
TableSchema::new(
470+
TableId::SENTINEL,
471+
TableName::for_test("scheduled"),
472+
None,
473+
vec![
474+
ColumnSchema::for_test(0, "id", AlgebraicType::U64),
475+
ColumnSchema::for_test(1, "at", ScheduleAt::get_type()),
476+
],
477+
vec![],
478+
vec![],
479+
vec![],
480+
StTableType::User,
481+
StAccess::Public,
482+
Some(ScheduleSchema::for_test("schedule", "reducer", 1)),
483+
Some(ColId(0)),
484+
false,
485+
None,
486+
None,
487+
),
488+
)
489+
})?;
490+
Ok((stdb, table_id))
491+
}
492+
428493
fn create_identity_table(table_name: &str) -> ResultTest<(TestDB, TestRows)> {
429494
let stdb = TestDB::durable()?;
430495
let head = ProductType::from([("identity", AlgebraicType::identity())]);
@@ -1451,6 +1516,56 @@ pub(crate) mod tests {
14511516
Ok(())
14521517
}
14531518

1519+
#[test]
1520+
fn test_sql_dml_rejects_outbox_tables() -> ResultTest<()> {
1521+
let (db, table_id) = create_outbox_table()?;
1522+
insert_rows(&db, table_id, [product![1u64, Identity::ZERO]]).unwrap();
1523+
let target = "0".repeat(63) + "1";
1524+
1525+
for sql in [
1526+
format!("INSERT INTO outbound (msg_id, target) VALUES (2, 0x{target})"),
1527+
format!("UPDATE outbound SET target = 0x{target} WHERE msg_id = 1"),
1528+
"DELETE FROM outbound WHERE msg_id = 1".to_string(),
1529+
] {
1530+
let err = run_for_testing(&db, &sql).unwrap_err().to_string();
1531+
assert!(
1532+
err.contains("SQL writes to outbox tables are not supported"),
1533+
"expected `{err}` to reject SQL writes to outbox tables"
1534+
);
1535+
}
1536+
1537+
Ok(())
1538+
}
1539+
1540+
#[test]
1541+
fn test_sql_dml_rejects_scheduled_tables() -> ResultTest<()> {
1542+
let (db, table_id) = create_scheduled_table()?;
1543+
insert_rows(
1544+
&db,
1545+
table_id,
1546+
[product![
1547+
1u64,
1548+
spacetimedb_sats::algebraic_value::ser::value_serialize(&ScheduleAt::Interval(
1549+
TimeDuration::from_micros(42)
1550+
))
1551+
]],
1552+
)
1553+
.unwrap();
1554+
1555+
for sql in [
1556+
"UPDATE scheduled SET id = 2 WHERE id = 1".to_string(),
1557+
"DELETE FROM scheduled WHERE id = 1".to_string(),
1558+
] {
1559+
let err = run_for_testing(&db, &sql).unwrap_err().to_string();
1560+
assert!(
1561+
err.contains("SQL writes to scheduled tables are not supported"),
1562+
"expected `{err}` to reject SQL writes to scheduled tables"
1563+
);
1564+
}
1565+
1566+
Ok(())
1567+
}
1568+
14541569
#[test]
14551570
fn test_multi_column() -> ResultTest<()> {
14561571
let (db, _input) = create_data(1)?;

‎crates/datastore/src/locking_tx_datastore/committed_state.rs‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,9 @@ use crate::{
3333
ST_COLUMN_ACCESSOR_ID, ST_COLUMN_ACCESSOR_IDX, ST_CONNECTION_CREDENTIALS_ID, ST_CONNECTION_CREDENTIALS_IDX,
3434
ST_ENV_ID, ST_ENV_IDX, ST_EVENT_TABLE_ID, ST_EVENT_TABLE_IDX, ST_INBOUND_MSG_ID, ST_INBOUND_MSG_IDX,
3535
ST_INBOUND_STREAM_ID, ST_INBOUND_STREAM_IDX, ST_INDEX_ACCESSOR_ID, ST_INDEX_ACCESSOR_IDX, ST_OUTBOUND_MSG_ID,
36-
ST_OUTBOUND_MSG_IDX, ST_OUTBOUND_STREAM_ID, ST_OUTBOUND_STREAM_IDX, ST_TABLE_ACCESSOR_ID,
37-
ST_TABLE_ACCESSOR_IDX, ST_VIEW_COLUMN_ID, ST_VIEW_COLUMN_IDX, ST_VIEW_ID, ST_VIEW_IDX, ST_VIEW_PARAM_ID,
38-
ST_VIEW_PARAM_IDX, ST_VIEW_SUB_ID, ST_VIEW_SUB_IDX,
36+
ST_OUTBOUND_MSG_IDX, ST_OUTBOUND_STREAM_ID, ST_OUTBOUND_STREAM_IDX, ST_OUTBOX_ID, ST_OUTBOX_IDX,
37+
ST_TABLE_ACCESSOR_ID, ST_TABLE_ACCESSOR_IDX, ST_VIEW_COLUMN_ID, ST_VIEW_COLUMN_IDX, ST_VIEW_ID, ST_VIEW_IDX,
38+
ST_VIEW_PARAM_ID, ST_VIEW_PARAM_IDX, ST_VIEW_SUB_ID, ST_VIEW_SUB_IDX,
3939
},
4040
};
4141
use anyhow::anyhow;
@@ -399,6 +399,7 @@ impl CommittedState {
399399
self.create_table(ST_OUTBOUND_MSG_ID, schemas[ST_OUTBOUND_MSG_IDX].clone());
400400
self.create_table(ST_INBOUND_STREAM_ID, schemas[ST_INBOUND_STREAM_IDX].clone());
401401
self.create_table(ST_INBOUND_MSG_ID, schemas[ST_INBOUND_MSG_IDX].clone());
402+
self.create_table(ST_OUTBOX_ID, schemas[ST_OUTBOX_IDX].clone());
402403

403404
// Insert the sequences into `st_sequences`
404405
let (st_sequences, blob_store, pool) =
@@ -875,6 +876,11 @@ impl CommittedState {
875876
let table = self.tables.get_mut(&table_id)?;
876877
table.with_mut_schema(|s| s.primary_key = old_pk.and_then(|cl| cl.as_singleton()));
877878
}
879+
// A table's outbox metadata changed. Change back to the old one.
880+
TableAlterOutbox(table_id, old_outbox) => {
881+
let table = self.tables.get_mut(&table_id)?;
882+
table.set_outbox(old_outbox);
883+
}
878884
// A table's row type was changed. Change back to the old one.
879885
// The row representation of old rows hasn't changed,
880886
// so it's safe to not rewrite the rows and merely change the type back.

0 commit comments

Comments
 (0)