Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
4ca0632
fix(streams): disabling a stream closes its shards; enabling creates …
LeeroyHannigan Oct 31, 2026
6f9089f
fix(streams): GetRecords resolves a shard against the stream its id n…
LeeroyHannigan Oct 31, 2026
690073d
test(streams): read re-enabled and relabelled streams by the shard id…
LeeroyHannigan Oct 31, 2026
ff46ea4
fix(engine): a StreamSpecification that is not an object stays a Seri…
LeeroyHannigan Oct 31, 2026
cea6f30
fix(streams): apply the ShardId maximum only when the shard is not found
LeeroyHannigan Oct 31, 2026
7723249
fix(streams): a refused disable of a disabled stream closes the shard…
LeeroyHannigan Oct 31, 2026
bdba4fb
perf(postgres): lock only the record's shard row on a streamed write
LeeroyHannigan Oct 31, 2026
b3eae75
perf(mongodb): index stream_shards by table_id and shard_id
LeeroyHannigan Oct 31, 2026
7823db8
fix(cassandra): start a new stream above the closed streams' ending n…
LeeroyHannigan Oct 31, 2026
c818c0a
docs(streams): upgrade order for stream changes, and what a disable s…
LeeroyHannigan Oct 31, 2026
5433ed3
refactor(streams): move the engine stream tests and the ListStreams b…
LeeroyHannigan Oct 31, 2026
53e42fb
test(postgres): pin stream labels through the shard ids the stream ca…
LeeroyHannigan Oct 31, 2026
a8d80ef
fix(streams): an enable closes the open shards of every earlier stream
LeeroyHannigan Oct 31, 2026
25f9593
refactor(postgres): split the stream disable storage tests into two f…
LeeroyHannigan Oct 31, 2026
a42aeb7
refactor(streams): move the labelled shard checks and the Cassandra s…
LeeroyHannigan Oct 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ jobs:
run: >-
cargo test --release -p extenddb-storage-postgres
--test vector_control_plane --test put_create_race --test twi_conflict
--test stream_shard_retention --test stream_shard_ownership --test stream_records_page_order --test backup_restore
--test stream_shard_retention --test stream_shard_ownership --test stream_records_page_order --test stream_disable --test stream_disable_legacy --test backup_restore
--test gsi_online_build --test gsi_stale_writer --test table_snapshot

# The daemonized server logs to syslog; dump it so server-side failures
Expand Down
12 changes: 12 additions & 0 deletions crates/core/src/types/table.rs
Original file line number Diff line number Diff line change
Expand Up @@ -815,8 +815,20 @@ impl TableDescription {
/// table is ACTIVE (measured on DynamoDB, and still absent 30 s later).
/// Backends report the summary whenever the table has a registered
/// restore.
/// - A disabled `StreamSpecification` is omitted: the service carries the
/// member only while a stream is enabled (measured 2026-10-24 on the
/// disable response and on `DescribeTable` afterwards). Backends keep
/// the disabled specification stored, with the closed stream's view
/// type, so `DescribeStream` of that stream can still report it.
pub fn prepare_for_response(&mut self) {
self.populate_table_throughput_mode_summary();
if self
.stream_specification
.as_ref()
.is_some_and(|s| !s.stream_enabled)
{
self.stream_specification = None;
}
if self.table_status != TableStatus::Creating {
self.restore_summary = None;
} else if let Some(summary) = self.restore_summary.as_mut() {
Expand Down
33 changes: 25 additions & 8 deletions crates/core/src/validation/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -659,15 +659,21 @@ fn validate_index_projections(input: &CreateTableInput) -> Result<(), DynamoDbEr
/// # Errors
///
/// Returns `DynamoDbError::ValidationException` when `StreamEnabled` is false
/// but a `StreamViewType` is present.
/// but a `StreamViewType` is present, or `StreamEnabled` is true without one
/// (measured 2026-10-24; its order against the other CreateTable checks is not
/// measured, so it sits with the other stream check).
fn validate_stream_specification(input: &CreateTableInput) -> Result<(), DynamoDbError> {
if let Some(stream) = &input.stream_specification
&& !stream.stream_enabled
&& stream.stream_view_type.is_some()
{
return Err(DynamoDbError::ValidationException(
"One or more parameter values were invalid: Table is being created with a stream disabled, UpdateViewType should not be specified".to_owned(),
));
if let Some(stream) = &input.stream_specification {
if !stream.stream_enabled && stream.stream_view_type.is_some() {
return Err(DynamoDbError::ValidationException(
"One or more parameter values were invalid: Table is being created with a stream disabled, UpdateViewType should not be specified".to_owned(),
));
}
if stream.stream_enabled && stream.stream_view_type.is_none() {
return Err(DynamoDbError::ValidationException(
"One or more parameter values were invalid: Table is being created with a stream enabled, UpdateViewType is required".to_owned(),
));
}
}
Ok(())
}
Expand Down Expand Up @@ -3722,6 +3728,17 @@ mod tests {
stream_view_type: Some(StreamViewType::NewImage),
});
assert!(validate_create_table(&input, &LimitsConfig::default()).is_ok());

// Enabled without a view type is refused (measured 2026-10-24).
input.stream_specification = Some(StreamSpecification {
stream_enabled: true,
stream_view_type: None,
});
let err = validate_create_table(&input, &LimitsConfig::default()).unwrap_err();
assert_eq!(
err.to_string(),
"One or more parameter values were invalid: Table is being created with a stream enabled, UpdateViewType is required"
);
}

#[test]
Expand Down
20 changes: 18 additions & 2 deletions crates/engine/src/backup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ use extenddb_core::limits::LimitsConfig;
use extenddb_core::types::{
BackupDescription, BackupSummary, BillingMode, CreateTableInput, GlobalSecondaryIndexInfo,
LocalSecondaryIndexInfo, ProvisionedThroughput, RestoreSummary, SourceTableFeatureDetails,
SseDescription, TableDescription, TableStatus,
SseDescription, StreamSpecification, TableDescription, TableStatus,
};
use extenddb_storage::BackupEngine;
use extenddb_storage::error::StorageError;
Expand Down Expand Up @@ -489,7 +489,23 @@ fn apply_source_to_description(
})
.collect()
}),
stream_description: d.stream_specification.clone(),
// A backup of a table whose stream was disabled carries the
// stored `{StreamEnabled: false}`, and DescribeBackup shows it
// as `StreamDescription: {StreamEnabled: false}`, as it did
// before the view type was kept in the stored specification;
// the service's answer for that backup is not measured. The
// view type the backends now keep for the closed stream is
// dropped here so the answer stays the one before.
stream_description: d.stream_specification.clone().map(|s| {
if s.stream_enabled {
s
} else {
StreamSpecification {
stream_enabled: false,
stream_view_type: None,
}
}
}),
time_to_live_description: d.time_to_live.clone(),
sse_description: SseDescription::from_specification(
d.sse_specification.as_ref(),
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/create_table.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ pub async fn handle_create_table(
],
)?;

crate::require_stream_enabled_member(&body)?;
let mut input: CreateTableInput = serde_json::from_value(body).map_err(|e| {
let msg = e.to_string();
if msg.contains("validation error detected")
Expand Down
20 changes: 20 additions & 0 deletions crates/engine/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,26 @@ pub(crate) fn sanitize_storage_error(e: extenddb_storage::error::StorageError) -
DynamoDbError::InternalServerError("Internal server error".to_owned())
}

/// The null-member sentence for a `StreamSpecification` object without
/// `StreamEnabled`, on `CreateTable` and `UpdateTable` alike (measured
/// 2026-10-24). Checked before the body is deserialised, where the missing
/// field would otherwise surface as a serialization error; its order against
/// the other request checks is not measured. A `StreamSpecification` that is
/// not an object is left to deserialisation, which refuses it as a
/// `SerializationException`.
pub(crate) fn require_stream_enabled_member(body: &serde_json::Value) -> Result<(), DynamoDbError> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: for a StreamSpecification that isn't an object ("x", 1, []), spec.get("StreamEnabled") is None, so this answers the null-member ValidationException where main answered SerializationException. SDKs can't send those shapes, so it hardly matters, but if spec.is_object() would keep it. Also, the new doc block sits inside deserialize_error's doc comment, so the "Enum validation errors ... SerializationException" lines now document this function, and deserialize_error has none.

if let Some(spec) = body.get("StreamSpecification").filter(|v| v.is_object())
&& spec
.get("StreamEnabled")
.is_none_or(serde_json::Value::is_null)
{
return Err(DynamoDbError::ValidationException(
"1 validation error detected: Value null at 'streamSpecification.streamEnabled' failed to satisfy constraint: Member must not be null".to_owned(),
));
}
Ok(())
}

/// Map a serde deserialization error to the appropriate `DynamoDB` error type.
///
/// Enum validation errors (produced by custom Deserialize impls) contain
Expand Down
Loading
Loading