Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
0af920c
feat: store and report WarmThroughput on tables and global secondary …
LeeroyHannigan Oct 23, 2026
3906cdb
Merge branch 'main' into fix/462-warm-throughput
LeeroyHannigan Nov 3, 2026
641e663
feat(storage): add the warm_throughput columns as catalog migration 6
LeeroyHannigan Nov 3, 2026
f709fb1
fix(backup): give the manifest its own index entry types
LeeroyHannigan Nov 3, 2026
3546b08
fix(engine): refuse a GSI Update that carries a throughput beside War…
LeeroyHannigan Nov 3, 2026
33d4e66
fix(engine): keep the larger WarmThroughput when an update overlaps o…
LeeroyHannigan Nov 3, 2026
ed0a481
fix(storage): merge concurrent WarmThroughput updates without losing …
LeeroyHannigan Nov 3, 2026
9fc0a41
fix(engine): clear an imported index's WarmThroughput
LeeroyHannigan Nov 3, 2026
cce4348
fix(devtools): leave the Terraform suite out of run-tests --all
LeeroyHannigan Nov 3, 2026
5940f95
test(mongodb): count migration 6's step in the two-migrator lock test
LeeroyHannigan Nov 3, 2026
e531256
Merge branch 'main' into fix/462-warm-throughput
LeeroyHannigan Nov 3, 2026
74cb23d
docs(schema): regenerate the schema files for migration 6
LeeroyHannigan Nov 3, 2026
0b2315a
test(engine): pin the billing mode a bare GSI Update is answered by
LeeroyHannigan Nov 3, 2026
af59c94
test(engine): call the billing mode loop variable mode
LeeroyHannigan Nov 3, 2026
2d338bf
Merge branch 'main' into fix/462-warm-throughput
LeeroyHannigan Nov 4, 2026
8c35832
test(storage-cassandra): set the warm throughput fields in the delete…
LeeroyHannigan Nov 4, 2026
a1badc3
Merge branch 'main' into fix/462-warm-throughput
LeeroyHannigan Nov 6, 2026
d8879f9
test: skip the warm throughput restore cells on a server with a backu…
LeeroyHannigan Nov 6, 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
4 changes: 3 additions & 1 deletion .github/workflows/integration-cassandra.yml
Original file line number Diff line number Diff line change
Expand Up @@ -172,12 +172,14 @@ jobs:
run: pip install -r requirements.txt
- name: Build release (cassandra backend)
run: cargo build --release --no-default-features --features cassandra
- name: Install Terraform (pinned version, checksum asserted)
run: echo "TERRAFORM=$(tests/terraform/install-terraform.sh "$RUNNER_TEMP/terraform")" >> "$GITHUB_ENV"
- name: Run Cassandra pytest suite
env:
# run-cassandra-tests initializes the server without [backup.store],
# and Cassandra takes the in-catalog create with one.
EXTENDDB_TEST_BACKUP_STORE: none
run: devtools/run-cassandra-tests -- --pytest --comprehensive --parallel
run: devtools/run-cassandra-tests -- --pytest --comprehensive --terraform --parallel

cassandra-rust:
runs-on: extenddb_ubuntu-2404_4-core
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/integration-mongodb.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,14 @@ jobs:
run: pip install -r requirements.txt
- name: Build release (mongodb backend)
run: cargo build --release --no-default-features --features mongodb-test-hooks
- name: Install Terraform (pinned version, checksum asserted)
run: echo "TERRAFORM=$(tests/terraform/install-terraform.sh "$RUNNER_TEMP/terraform")" >> "$GITHUB_ENV"
- name: Run MongoDB pytest suite
env:
# run-mongodb-tests initializes the server without [backup.store]:
# CreateBackup is the in-catalog create.
EXTENDDB_TEST_BACKUP_STORE: none
run: devtools/run-mongodb-tests -- --pytest --comprehensive --parallel
run: devtools/run-mongodb-tests -- --pytest --comprehensive --terraform --parallel

# SnapshotEngine consistency check against a disposable replica set. The
# tests build their own per-test scratch databases (snap<uuid>_cat and
Expand Down
10 changes: 8 additions & 2 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -163,14 +163,17 @@ jobs:
crates/storage-postgres/tests/fixtures/released/0.1.14 \
extenddb/extenddb-postgres:0.1.14 18453

- name: Install Terraform (pinned version, checksum asserted)
run: echo "TERRAFORM=$(tests/terraform/install-terraform.sh "$RUNNER_TEMP/terraform")" >> "$GITHUB_ENV"

- name: Run integration tests
env:
EXTENDDB_TEST_ENDPOINT: https://127.0.0.1:18443
EXTENDDB_ADMIN_USER: admin
EXTENDDB_ADMIN_PASSWORD: ${{ steps.init.outputs.admin_password }}
EXTENDDB_TEST_PG_ADMIN_CONNECTION_STRING: postgresql://postgres:devpass@127.0.0.1:5432
EXTENDDB_TEST_BACKUP_STORE: filesystem
run: devtools/run-tests --extenddb --pytest --comprehensive --parallel --filter "not import_export"
run: devtools/run-tests --extenddb --pytest --comprehensive --terraform --parallel --filter "not import_export"

# The backup store and job suites start servers of their own, so they
# are excluded from run-tests' line and run by name.
Expand Down Expand Up @@ -295,6 +298,9 @@ jobs:
done
echo "moto_server did not start"; cat moto.log; exit 1

- name: Install Terraform (pinned version, checksum asserted)
run: echo "TERRAFORM=$(tests/terraform/install-terraform.sh "$RUNNER_TEMP/terraform")" >> "$GITHUB_ENV"

- name: Run integration tests
env:
EXTENDDB_TEST_ENDPOINT: https://127.0.0.1:18443
Expand All @@ -306,7 +312,7 @@ jobs:
# A file-backed SQLite server without [backup.store] writes to the
# defaulted root beside the database file.
EXTENDDB_TEST_BACKUP_STORE: filesystem
run: devtools/run-tests --extenddb --pytest --comprehensive --parallel --filter "not import_export"
run: devtools/run-tests --extenddb --pytest --comprehensive --terraform --parallel --filter "not import_export"

- name: Run the backup store and job suites
run: python3 -m pytest tests/test_backup_store_config.py tests/test_backup_job.py -v -rP -p no:cacheprovider
Expand Down
47 changes: 24 additions & 23 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,28 +342,28 @@ SQLite, PostgreSQL, Cassandra and MongoDB migrate through this framework.

### Example: a nullable column on `tables`

Migration 8, `table_note` (8 being the next free id at the time of writing;
migrations 6 and 7 are `backup_catalog_index` and `stream_pending`), adds a
nullable `note TEXT` column to the catalog's `tables` table on SQLite and
PostgreSQL.
Migration 9, `table_note` (9 being the next free id at the time of writing;
migrations 6, 7 and 8 are `backup_catalog_index`, `stream_pending` and
`warm_throughput`), adds a nullable `note TEXT` column to the catalog's
`tables` table on SQLite and PostgreSQL.

In `crates/storage/src/migrations/registry.rs`, at the end of the
`migration_ids!` list:

```rust
/// Adds a nullable `note` column to the `tables` catalog table.
TableNote = 8, "table_note",
TableNote = 9, "table_note",
"add a nullable note column to the tables catalog";
```

PostgreSQL has `ADD COLUMN IF NOT EXISTS`, so its body is a file,
`crates/storage-postgres/migrations/0008_table_note.sql`:
`crates/storage-postgres/migrations/0009_table_note.sql`:

```sql
-- Copyright 2026 ExtendDB contributors
-- SPDX-License-Identifier: Apache-2.0
--
-- Migration 8, table_note, PostgreSQL catalog database: a nullable note
-- Migration 9, table_note, PostgreSQL catalog database: a nullable note
-- column on tables. IF NOT EXISTS makes a second run change nothing.

ALTER TABLE tables ADD COLUMN IF NOT EXISTS note TEXT;
Expand All @@ -378,7 +378,7 @@ In `crates/storage-postgres/src/migrations/mod.rs`, the arm in `migration()`:
bodies: vec![file_body!(
StoreKind::Catalog,
"migrations",
"0008_table_note.sql"
"0009_table_note.sql"
)],
},
```
Expand All @@ -392,19 +392,20 @@ and the id in the transactional arm of `runs_in()`:
| MigrationId::DropContinuousBackups
| MigrationId::BackupCatalogIndex
| MigrationId::StreamPending
| MigrationId::WarmThroughput
| MigrationId::TableNote => RunsIn::Transaction,
```

SQLite has no `ADD COLUMN IF NOT EXISTS`, and a file with a plain
`ADD COLUMN` fails when it runs a second time, so the SQLite body is code
that checks for the column first.
`crates/storage-sqlite/src/migrations/m0008_table_note.rs`:
`crates/storage-sqlite/src/migrations/m0009_table_note.rs`:

```rust
// Copyright 2026 ExtendDB contributors
// SPDX-License-Identifier: Apache-2.0

//! Migration 8, `table_note`, SQLite: a nullable `note` column on `tables`.
//! Migration 9, `table_note`, SQLite: a nullable `note` column on `tables`.
//!
//! SQLite has no `ADD COLUMN IF NOT EXISTS`, so the body reads the table's
//! columns first and adds the column only when it is missing. A second run
Expand All @@ -416,10 +417,10 @@ use extenddb_storage::migrations::{CodeBody, Done, MigrationError};
use super::SqliteStores;

/// This file, whose SHA-256 the checksum test pins.
pub(crate) const SOURCE: &str = include_str!("m0008_table_note.rs");
pub(crate) const SOURCE: &str = include_str!("m0009_table_note.rs");

/// Recorded as this migration's checksum. Changes only with a new migration.
const FINGERPRINT: &str = "0008_table_note/sqlite/1";
const FINGERPRINT: &str = "0009_table_note/sqlite/1";

pub(crate) struct TableNote;

Expand Down Expand Up @@ -458,7 +459,7 @@ beside the others:
```rust
#[rustfmt::skip]
#[allow(clippy::all, deprecated)]
mod m0008_table_note;
mod m0009_table_note;
```

and the arm in `migration()`:
Expand All @@ -469,8 +470,8 @@ and the arm in `migration()`:
convergent: true,
bodies: vec![StoreBody::code(
StoreKind::Catalog,
&m0008_table_note::TableNote,
m0008_table_note::SOURCE,
&m0009_table_note::TableNote,
m0009_table_note::SOURCE,
)],
},
```
Expand All @@ -482,8 +483,8 @@ rows of `tables`:

```rust
#[tokio::test]
async fn migration_8_adds_the_note_column_to_tables() {
let db = temp_db("m8");
async fn migration_9_adds_the_note_column_to_tables() {
let db = temp_db("m9");
load_dump(&db, DEV_0_1_14).await;
run(&db).await.expect("migrate");
let mut text = dump(
Expand All @@ -493,12 +494,12 @@ async fn migration_8_adds_the_note_column_to_tables() {
)
.await;
text.push_str(&dump(&db, "SELECT table_name, note FROM tables ORDER BY table_name").await);
golden("0008_table_note", &text);
golden("0009_table_note", &text);
}
```

`EXTENDDB_BLESS_EXPECTED=1 cargo test -p extenddb-storage-sqlite --lib migration_8`
writes `crates/storage-sqlite/tests/fixtures/golden/0008_table_note.txt`, one
`EXTENDDB_BLESS_EXPECTED=1 cargo test -p extenddb-storage-sqlite --lib migration_9`
writes `crates/storage-sqlite/tests/fixtures/golden/0009_table_note.txt`, one
tab-separated line per row under each query:

```
Expand All @@ -513,15 +514,15 @@ The PostgreSQL body is a file, so it has no golden test.
The example declares `Kind::Breaking`. A nullable column can be additive by
the rule in step 4, but declaring it additive needs the old-binary job, which
this example does not add. As a breaking migration it makes a 1.x binary
without migration 8 refuse a catalog that has it, and a rollback restores the
without migration 9 refuse a catalog that has it, and a rollback restores the
backup.

The same PR changes the code that reads and writes `tables.note` in each
backend's catalog store, and commits the `docs/schema/sqlite.txt` and
`docs/schema/postgres.txt` that the integration jobs regenerate. It changes
no per-table DDL, because `tables` is a catalog table, and adds no `RELEASED`
entry. `extenddb migrate --list` then shows migration 8, and on a catalog at
migration 7 `extenddb migrate` lists migration 8 as pending and exits 3.
entry. `extenddb migrate --list` then shows migration 9, and on a catalog at
migration 8 `extenddb migrate` lists migration 9 as pending and exits 3.

### Running the migration tests

Expand Down
43 changes: 22 additions & 21 deletions crates/core/src/backup_format/golden_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -472,15 +472,15 @@ mod generate_golden_manifest {
//! writes new ones beside them.
use super::super::manifest::manifest_checksum;
use super::super::{
CURRENT_FORMAT_VERSION, DataFileEntry, ExtenddbManifest, ItemOrder, KeyRange,
SnapshotDescriptor, SourceTableSchema,
BackupGsi, BackupLsi, CURRENT_FORMAT_VERSION, DataFileEntry, ExtenddbManifest, ItemOrder,
KeyRange, SnapshotDescriptor, SourceTableSchema,
};
use crate::types::{
AttributeDefinition, AttributeValue, BillingMode, DistanceFunction, GsiInput, Item,
KeySchemaElement, KeyType, LsiInput, OnDemandThroughput, Projection, ProjectionType,
ProvisionedThroughput, ScalarAttributeType, SearchSchemaElement, SearchSchemaElementType,
SseDescription, SseType, StreamSpecification, StreamViewType, TimeToLiveDescription,
TimeToLiveStatus, VectorAttribute, VectorIndexSpecification,
AttributeDefinition, AttributeValue, BillingMode, DistanceFunction, Item, KeySchemaElement,
KeyType, OnDemandThroughput, Projection, ProjectionType, ProvisionedThroughput,
ScalarAttributeType, SearchSchemaElement, SearchSchemaElementType, SseDescription, SseType,
StreamSpecification, StreamViewType, TimeToLiveDescription, TimeToLiveStatus,
VectorAttribute, VectorIndexSpecification,
};

fn key(a: &str, t: &str) -> Item {
Expand Down Expand Up @@ -554,7 +554,7 @@ mod generate_golden_manifest {
"arn:aws:kms:us-east-1:123456789012:key/abcd".to_owned(),
),
}),
global_secondary_indexes: vec![GsiInput {
global_secondary_indexes: vec![BackupGsi {
index_name: "GenreIndex".to_owned(),
key_schema: vec![KeySchemaElement {
attribute_name: "Genre".to_owned(),
Expand All @@ -569,7 +569,7 @@ mod generate_golden_manifest {
write_capacity_units: 1,
}),
}],
local_secondary_indexes: vec![LsiInput {
local_secondary_indexes: vec![BackupLsi {
index_name: "TitleIndex".to_owned(),
key_schema: vec![
KeySchemaElement {
Expand Down Expand Up @@ -760,7 +760,7 @@ mod generate_golden_manifest {
kms_master_key_arn: None,
}),
// The GSI of an on-demand table has no `ProvisionedThroughput`.
global_secondary_indexes: vec![GsiInput {
global_secondary_indexes: vec![BackupGsi {
index_name: "ByKindGlobal".to_owned(),
key_schema: vec![element("kind", KeyType::Hash)],
projection: Projection {
Expand All @@ -769,7 +769,7 @@ mod generate_golden_manifest {
},
provisioned_throughput: None,
}],
local_secondary_indexes: vec![LsiInput {
local_secondary_indexes: vec![BackupLsi {
index_name: "ByKind".to_owned(),
key_schema: vec![
element("tenant", KeyType::Hash),
Expand Down Expand Up @@ -925,15 +925,16 @@ mod member_names {
use serde::{Serialize, forward_to_deserialize_any};

use super::super::{
DataFileEntry, ExtenddbManifest, ItemOrder, KeyRange, SnapshotDescriptor, SourceTableSchema,
BackupGsi, BackupLsi, DataFileEntry, ExtenddbManifest, ItemOrder, KeyRange,
SnapshotDescriptor, SourceTableSchema,
};
use super::generate_golden_manifest::{golden, unordered};
use crate::types::{
AttributeDefinition, AttributeValue, BillingMode, DistanceFunction, GsiInput,
KeySchemaElement, KeyType, LsiInput, OnDemandThroughput, Projection, ProjectionType,
ProvisionedThroughput, ScalarAttributeType, SearchSchemaElement, SearchSchemaElementType,
SseDescription, SseType, StreamSpecification, StreamViewType, TimeToLiveDescription,
TimeToLiveStatus, VectorAttribute, VectorIndexSpecification,
AttributeDefinition, AttributeValue, BillingMode, DistanceFunction, KeySchemaElement,
KeyType, OnDemandThroughput, Projection, ProjectionType, ProvisionedThroughput,
ScalarAttributeType, SearchSchemaElement, SearchSchemaElementType, SseDescription, SseType,
StreamSpecification, StreamViewType, TimeToLiveDescription, TimeToLiveStatus,
VectorAttribute, VectorIndexSpecification,
};

/// A deserializer that records what a derived `Deserialize` passes to
Expand Down Expand Up @@ -1355,8 +1356,8 @@ mod member_names {
&["Status", "SSEType", "KMSMasterKeyArn"],
);
let gsi = &t.global_secondary_indexes[0];
check::<GsiInput>(
"GsiInput",
check::<BackupGsi>(
"BackupGsi",
gsi,
&[
"IndexName",
Expand All @@ -1370,8 +1371,8 @@ mod member_names {
&gsi.projection,
&["ProjectionType", "NonKeyAttributes"],
);
check::<LsiInput>(
"LsiInput",
check::<BackupLsi>(
"BackupLsi",
&other.source_table.local_secondary_indexes[0],
&["IndexName", "KeySchema", "Projection"],
);
Expand Down
Loading
Loading