From d9fed1055ff346e9ae5347a4c84ba40006cd7442 Mon Sep 17 00:00:00 2001 From: Joel Shepherd Date: Mon, 22 Jun 2026 23:06:11 +0000 Subject: [PATCH 01/48] docs: draft rfc for accepting backend database plugins --- docs/rfcs/0002-backend-plugin-policy.md | 239 ++++++++++++++++++++++++ docs/rfcs/README.md | 9 +- 2 files changed, 246 insertions(+), 2 deletions(-) create mode 100644 docs/rfcs/0002-backend-plugin-policy.md diff --git a/docs/rfcs/0002-backend-plugin-policy.md b/docs/rfcs/0002-backend-plugin-policy.md new file mode 100644 index 00000000..d6ea68f0 --- /dev/null +++ b/docs/rfcs/0002-backend-plugin-policy.md @@ -0,0 +1,239 @@ +# RFC-0002: Backend Plugin Development and Maintenance Policy + +- Status: Draft +- Author: @jcshepherd +- Created: 2026-06-22 +- FCP ends: (set when entering Final Comment Period) +- Tracking issue: #NNN + +## Summary + +This RFC establishes policies for developing, contributing, and maintaining database backend plugins for ExtendDB. It recommends a mono-repo architecture where all officially supported backends live as separate crates within the main ExtendDB repository, defines conformance requirements for backend acceptance, and establishes processes for external contributions. + +## Motivation + +ExtendDB's core value proposition is providing an Amazon DynamoDB-compatible API over multiple database backends. The reference PostgreSQL backend demonstrates feasibility. Active development of Apache Cassandra and SQLite backends shows demand for backend diversity. As the project grows, we need policies that: + +1. **Scale beyond the core team.** The ExtendDB organization cannot personally implement and maintain every backend indefinitely. We must enable external contributions while ensuring quality. + +2. **Preserve DynamoDB compatibility.** ExtendDB's value is compatibility with Amazon DynamoDB. Any backend released by the ExtendDB organization must demonstrate conformance with DynamoDB's APIs and features. + +3. **Support backend diversity as a feature.** Different backends serve different needs: PostgreSQL (reference implementation), Cassandra (horizontal scale), SQLite (testing/embedded), and future backends extend ExtendDB's reach in terms of the infrastructure it can operate on and the use-cases it can support. + +4. **Maintain velocity while managing complexity.** We must balance fast iteration for a small team with the eventual need for modular external contributions. + +Without formal policies, we risk inconsistent quality, fragmented implementations, and contributor confusion. This RFC provides the framework for sustainable growth. + +## Detailed design + +### Repository structure: Mono-repo with feature flags + +All officially supported backends live in the main `extenddb` repository as separate crates under `crates/storage-{backend}/`. Examples: + +- `crates/storage-postgres/` (reference implementation) +- `crates/storage-cassandra/` +- `crates/storage-sqlite/` + +Backends are selected at build time via Cargo feature flags: + +```toml +[features] +default = ["postgres"] +postgres = ["dep:storage-postgres"] +cassandra = ["dep:storage-cassandra"] +sqlite = ["dep:storage-sqlite"] +all-backends = ["postgres", "cassandra", "sqlite"] +``` + +Mono-repo maximizes development velocity while avoiding the operational overhead of managing N separate repositories with N CI pipelines, N release processes, N issue trackers and N documentation sites. Cross-cutting changes spanning frontend and backend require one PR, one review, one merge. + +Additionally, the `Storage` trait is still evolving. It will take several iterations to stabilize the trait API, and it will continue to evolve as ExtendDB adds support for new DynamoDB features. The mono-repo approach makes it significantly easier to keep the storage traits and all implementing backends in sync during this evolution. Trait changes can be tested, reviewed, and landed atomically across all backends rather than coordinating updates across multiple repositories. + +### Conformance requirements + +Backends released under the ExtendDB organization must adhere to a trait-based conformance model: + +1. **Mandatory traits.** All backends must implement the core `Storage` traits (data plane and control plane operations) and pass 100% of conformance tests for those traits. Core traits are the minimum required for a functional DynamoDB-compatible backend. + +2. **Optional traits.** Backends may choose not to implement optional traits (example: Streams, Transactions) or may stub them out to return `Unimplemented` errors. However, if a backend *does* implement an optional trait, it must pass 100% of conformance tests for that trait. Partial implementations are not permitted. + +3. **All-or-nothing per trait.** The trait is the finest-grained unit of conformance. Backends cannot claim partial support for a trait (example: "Streams partially works but records are incomplete" is not allowed). Either the trait is fully implemented and conformant, or it is not implemented. + +4. **Maintain semantic correctness.** Where a backend implements an operation, it must match DynamoDB behavior including error responses, pagination, atomicity guarantees, and consistency models. + +5. **Clear documentation.** Backends must document in their README which optional traits they implement and which they do not. Conformance test results (per-trait pass rates) must be published and tracked in CI. + +This model reduces friction by allowing new backends to launch with a well-defined subset of functionality without requiring support for every ExtendDB feature from day one. As backends mature, they can add optional traits incrementally, but each addition must be complete and correct. This prevents fragmented, partially-working implementations that erode ExtendDB's compatibility guarantee. + +#### Trait classification + +ExtendDB's storage abstraction consists of 13 traits covering storage operations and management functions. The following classification defines which traits are mandatory for backend acceptance and which are optional: + +**Mandatory storage traits:** +- `TableEngine` - table-level operations +- `DataEngine` - item-level data operations + +**Optional storage traits:** +- `MetadataEngine` - metadata operations +- `StreamEngine` - DynamoDB Streams support +- `WorkerStore` - background worker state + +**Mandatory management traits:** +- `ManagementStore` - core management operations +- `AdminStore` - administrative functions +- `Bootstrapper` - initialization and bootstrap + +**Optional management traits:** +- `SettingsStore` - settings persistence +- `MetricsStore` - metrics collection +- `RateLimitStore` - rate limiting state +- `AuthorizationStore` - authorization data +- `BackupEngine` - backup and restore + +Note: This classification may be refined as the trait design evolves. Some traits may be split, merged, or reclassified before this policy is finalized. + +Conformance test results are published in the backend's README and tracked in CI. + +### Contribution process + +#### For ExtendDB organization members + +Standard PR review process applies. Changes require: +- Code review from at least one maintainer +- Conformance tests passing in CI +- Documentation updates + +#### For external contributors + +External contributors may propose new backends or maintain existing ones. The process: + +1. **Proposal.** Open a GitHub issue describing the proposed backend, target use cases, and maintenance commitment. Include links to the underlying database project and any existing compatibility layers. + +2. **Review.** Maintainers evaluate whether the backend aligns with ExtendDB's goals and whether the contributor can sustain maintenance. Approval grants the contributor directory-level write access via GitHub CODEOWNERS. + +3. **Implementation.** Contributor develops the backend in `crates/storage-{backend}/` following the `Storage` trait contract. The contributor owns their backend directory but ExtendDB maintainers retain override authority for repository-wide concerns. + +4. **Acceptance criteria:** + - Conformance tests pass for all required traits and for any implemented, optional traits. + - Documentation includes setup guide, architecture notes, troubleshooting + - Integration tests run successfully in CI (contributor may need to provide sandbox credentials for cloud-based backends via GitHub Secrets) + - Maintainer review approved + +5. **Ongoing maintenance.** Contributor commits to: + - Responding to issues and PRs related to their backend + - Keeping dependencies updated + - Adapting to breaking changes in the `Storage` trait + - Supporting new DynamoDB features as feasible + +If the original contributor becomes unresponsive, ExtendDB maintainers may take over maintenance or deprecate the backend. + +### Architectural discipline + +To prevent accidental coupling between frontend and specific backend implementations, CI should enforce that: + +1. The `extenddb` service binary builds successfully without any backend feature flags enabled (frontend depends only on `storage` trait crate, not concrete backends). + +2. Backend crates depend only on the `storage` trait and common utilities, not on each other. + +This will be validated via: +```bash +cargo build --no-default-features --bin extenddb +``` + +(Note: as of June 2026, `extenddb catalog-check` still has a direct PostgreSQL dependency.) + +### Release model + +ExtendDB will move to a release model that includes releases through Crates.io. All crates follow semantic versioning. Breaking changes to `extenddb-storage` trigger coordinated releases. Binaries will also be available as GitHub releases. The project will also prioritize releasing images through Docker Hub. + +### Third-party backends outside ExtendDB organization + +Anyone can implement ExtendDB backends independently. The ExtendDB organization: +- Does **not** endorse or guarantee compatibility of third-party backends +- Does **not** provide support for third-party backends +- **May** link to third-party backends in a community-maintained list in documentation + +Third-party backends should use distinct package names prefixed with the name of the backend itself (e.g., `mongodb-extenddb-storage` when published by a third party, not by the ExtendDB organization). + +## Drawbacks + +1. **Dependency bloat.** Mono-repo means all backend dependencies exist in the workspace even if only one backend is used. Mitigation: Cargo feature flags and workspace optimization reduce impact. Users building from source select only needed backends. + +2. **CI failure impact.** CI failure in any backend blocks all releases. Mitigation: Backend tests run in parallel jobs. Release process can gate on critical backends (PostgreSQL) while allowing others to lag. + +3. **Coordination overhead for trait changes.** Breaking changes to `Storage` trait require updating all backends simultaneously. Mitigation: Small team currently maintains all backends; design changes are coordinated. As external contributions grow, we establish trait stability periods. + +4. **Access control granularity.** External contributors receive directory-level write access to main repo rather than owning separate repos. Risk: contributor error impacts shared infrastructure (CI config, dependencies). Mitigation: CODEOWNERS enforces review requirements; maintainers retain override authority. + +## Alternatives + +### Option 2: Multiple repositories (one per backend) + +Create separate repos: `extenddb` (frontend + postgres), `extenddb-cassandra-plugin`, `extenddb-sqlite-plugin`, etc. Backends publish as separate crates linking against `extenddb-storage` traits. + +**Pros:** +- Clean separation; no dependency conflicts between backends +- Backend contributors don't need frontend write access +- Backend CI failures don't block frontend releases +- Natural separation of maintenance responsibilities + +**Cons:** +- More repos to manage (N CI configs, N release processes, N issue trackers) +- Cross-repo coordination overhead (trait changes require synchronized PRs) +- Trait version management complexity (breaking changes in `extenddb-storage` break other repos) +- Higher infrastructure setup and maintenance overhead +- Slower iteration for small team + +**Why rejected:** Operational overhead outweighs benefits at current scale. Managing multiple repos requires infrastructure (CICD, docs, releases) and coordination (cross-repo PRs, trait versioning) that a small team cannot sustain. Mono-repo maximizes velocity. If the project scales to dozens of backends or hundreds of contributors, we can migrate to multi-repo with minimal user disruption (artifacts remain on crates.io and Docker Hub). + +### Option 3: Fork per backend + +Fork entire ExtendDB repo for each backend. Each fork contains frontend + one backend. + +**Pros:** +- Complete isolation + +**Cons:** +- Frontend code duplicated N times +- Bug fixes must be ported to N repos +- Nearly impossible to keep in sync +- Contradicts ExtendDB's architecture (decoupled frontend/backend) + +**Why rejected:** Unworkable for any team size. Violates DRY principle and ExtendDB's design. + +## Unresolved questions + +1. **Security review process.** Backend implementations handle database credentials, execute queries, and manage connections—all surfaces with security implications (SQL injection, connection security, credential leakage). Should we formalize security review as part of the PR process? If so, what does that look like? Do we need security-focused reviewers, automated scanning tools, or documented security patterns? This wasn't part of the original discussion but seems important for production deployments. + +2. **Conformance test ownership.** Should conformance tests live in the main `extenddb` repo, or in a separate `extenddb-conformance` repo used by all backends? Leaning toward main repo for simplicity, but open to feedback. + +3. **Credential management for cloud backends.** External contributors implementing cloud-based backends (e.g., AWS DynamoDB passthrough, Azure Cosmos DB) need sandbox credentials in CI. How do we handle credential provisioning? Options: + - Contributor provides credentials via GitHub Secrets (preferred) + - ExtendDB org funds test accounts (expensive, scales poorly) + - Backend maintainers run their own CI externally and report results (trust issue) + +4. **Trait stability guarantees.** As external backends grow, we need a policy for breaking changes to `Storage` trait. Options: + - Major version bumps with migration guide + - Deprecation periods (announce breaking change, grace period for backends to adapt) + - Trait versioning (maintain old trait versions temporarily) + +## Prior art + +**Diesel (Rust SQL toolkit):** Mono-repo with multiple database backends (PostgreSQL, MySQL, SQLite). Backends are separate crates but all maintained in one repo. Uses feature flags for selection. Demonstrates that mono-repo scales for database abstraction layers. + +**SQLx (Rust async SQL library):** Similar mono-repo structure with feature-flagged backends. Strong conformance via compile-time query checking. + +**Apache Arrow DataFusion:** Query engine with pluggable data sources. Data sources live in-tree and out-of-tree. In-tree sources have strong conformance requirements; out-of-tree sources are community-maintained. + +**PostgreSQL ecosystem:** Single Postgres core repo, extension ecosystem lives externally. Extensions link against stable APIs. Demonstrates successful decoupling but requires strong API stability guarantees (10+ year compatibility). + +--- + +## License + +Copyright 2026 ExtendDB contributors. Licensed under the Apache License, Version 2.0. +See [LICENSE](../../LICENSE) for the full text. + +This software is provided "as is" without warranty of any kind. ExtendDB is not +affiliated with, endorsed by, or sponsored by Amazon Web Services. "DynamoDB" is +a trademark of Amazon.com, Inc. diff --git a/docs/rfcs/README.md b/docs/rfcs/README.md index 0ea538a6..216e8ce7 100644 --- a/docs/rfcs/README.md +++ b/docs/rfcs/README.md @@ -47,6 +47,11 @@ you whether an RFC, an ADR, or neither is needed. ## Index - + + +| RFC | Title | Status | Release | +|:---------|:------|:-------|:--------| +| [0001]() | | Draft | | +| [0002](0002-backend-plug-policy.md) | Backend Plugin Policy | Draft | | + -_(none yet)_ From 70dfe1b9adeb7bdf5a4116db3978669a13751652 Mon Sep 17 00:00:00 2001 From: Joel Shepherd Date: Fri, 26 Jun 2026 23:16:35 +0000 Subject: [PATCH 02/48] docs: addressing feedback on RFC-0002 --- docs/rfcs/0002-backend-plugin-policy.md | 114 +++++++++++++++++++++++- 1 file changed, 112 insertions(+), 2 deletions(-) diff --git a/docs/rfcs/0002-backend-plugin-policy.md b/docs/rfcs/0002-backend-plugin-policy.md index d6ea68f0..65d5ae8a 100644 --- a/docs/rfcs/0002-backend-plugin-policy.md +++ b/docs/rfcs/0002-backend-plugin-policy.md @@ -59,12 +59,66 @@ Backends released under the ExtendDB organization must adhere to a trait-based c 3. **All-or-nothing per trait.** The trait is the finest-grained unit of conformance. Backends cannot claim partial support for a trait (example: "Streams partially works but records are incomplete" is not allowed). Either the trait is fully implemented and conformant, or it is not implemented. -4. **Maintain semantic correctness.** Where a backend implements an operation, it must match DynamoDB behavior including error responses, pagination, atomicity guarantees, and consistency models. +4. **Maintain semantic correctness.** Where a backend implements an operation, it must match DynamoDB behavior including error responses, pagination, isolation guarantees, atomicity guarantees, and consistency models. 5. **Clear documentation.** Backends must document in their README which optional traits they implement and which they do not. Conformance test results (per-trait pass rates) must be published and tracked in CI. This model reduces friction by allowing new backends to launch with a well-defined subset of functionality without requiring support for every ExtendDB feature from day one. As backends mature, they can add optional traits incrementally, but each addition must be complete and correct. This prevents fragmented, partially-working implementations that erode ExtendDB's compatibility guarantee. +#### Conformance expectations and communication + +**Compatibility matrices:** Backends must publish a compatibility matrix documenting which DynamoDB operations and features are supported. This matrix should be maintained in the backend's README and updated with each release. Example format: + +| Feature | Status | Notes | +|---------|--------|-------| +| Basic CRUD | Supported | PutItem, GetItem, DeleteItem, UpdateItem | +| Query/Scan | Supported | Filters and projections supported | +| GSI/LSI | Not supported | Returns `OperationNotSupported` | +| Transactions | Partial | TransactWriteItems only; read transactions unsupported | +| Streams | Supported | Full support with configurable retention | + +**Error handling for unsupported features:** Backends MUST return explicit errors for unsupported operations rather than silently degrading behavior or returning incorrect results. Acceptable approaches: +- Return `OperationNotSupported` error for unimplemented operations +- Return `ValidationException` with clear message for unsupported parameters within implemented operations +- Document degraded behavior explicitly (e.g., "eventually consistent reads treated as strongly consistent") + +Backends MUST NOT silently ignore unsupported features or return partial/incorrect data. + +**Capability discovery and enforcement:** To help users understand what a backend supports and to prevent confusing error messages deep in operation execution, ExtendDB should provide a mechanism for backends to declare their capabilities and for the server to check support before dispatching requests. + +In a trait-based model, Rust's type system provides some of this enforcement: if a backend doesn't implement `StreamEngine`, stream operations won't compile. However, this doesn't help with partial trait implementations (e.g., a backend that implements `DataEngine` but not all optional parameters within PutItem). + +In an operation-based model, explicit capability declaration becomes more important. Backends would declare supported operations in a manifest (TOML, JSON, or Rust code), which the server reads at startup to populate a capability registry. When a request arrives, the server checks the registry before dispatching. If the operation isn't supported, the server returns `OperationNotSupported` immediately without calling the backend. + +This approach provides benefits regardless of conformance model: +- Users can query capabilities programmatically (e.g., `DescribeBackendCapabilities` API) +- Conformance tests can adapt to backend capabilities without manual configuration +- Error messages are consistent and occur at request validation rather than deep in execution +- Backend developers get a single place to document support + +Implementation could range from simple (static capability declaration in backend code) to sophisticated (dynamic capability querying, versioned capability schemas). The key principle: make it easy for users to discover what works before trying it. + +**Beyond DynamoDB compatibility:** Backends may implement features beyond DynamoDB compatibility (e.g., backend-specific query optimizations, extended data types, native full-text search). These extensions: +- MUST NOT break DynamoDB compatibility for standard operations +- MUST NOT fundamentally change the DynamoDB API paradigm +- MUST be clearly documented as extensions +- SHOULD be exposed via backend-specific APIs or configuration, not by altering DynamoDB API semantics +- MUST NOT be required for core DynamoDB functionality to work + +The "DynamoDB API paradigm" centers on predictable, consistent performance where developers can reason unambiguously about operation cost. Extensions that introduce unpredictable performance characteristics are prohibited even if they don't technically break API compatibility. Examples: + +**Prohibited extensions:** +- SQL-style joins (unpredictable performance, violates DynamoDB's explicit access pattern design) +- Automatic GSI selection via query optimizer (hides performance characteristics from developer) +- Cross-table transactions (changes atomicity guarantees and performance model) + +**Acceptable extensions:** +- Backend-specific full-text search via separate API endpoint (doesn't alter Query/Scan semantics) +- Extended data types exposed through backend-specific configuration (DynamoDB types still work) +- Read-your-writes consistency mode as opt-in configuration (doesn't change standard consistency guarantees) + +When in doubt, extensions should be backend-specific APIs rather than modifications to DynamoDB operations. + #### Trait classification ExtendDB's storage abstraction consists of 13 traits covering storage operations and management functions. The following classification defines which traits are mandatory for backend acceptance and which are optional: @@ -83,17 +137,73 @@ ExtendDB's storage abstraction consists of 13 traits covering storage operations - `AdminStore` - administrative functions - `Bootstrapper` - initialization and bootstrap +**Mandatory authentication/authorization traits:** +- `AuthorizationStore` - authorization data (policies, users, groups, roles) + +ExtendDB requires SigV4 authentication on all DynamoDB API requests. Backends must persist auth primitives (users, groups, roles, policies, access keys) to support this requirement. While these traits don't map directly to DynamoDB APIs, they are infrastructure requirements for a conformant ExtendDB deployment. + **Optional management traits:** - `SettingsStore` - settings persistence - `MetricsStore` - metrics collection - `RateLimitStore` - rate limiting state -- `AuthorizationStore` - authorization data - `BackupEngine` - backup and restore Note: This classification may be refined as the trait design evolves. Some traits may be split, merged, or reclassified before this policy is finalized. Conformance test results are published in the backend's README and tracked in CI. +#### Alternate proposal: Operation-based conformance + +Trait boundaries and feature boundaries don't always align. If conformance is trait-based ("implement all or nothing of a trait"), backends must implement entire traits even when only a subset of operations is needed. If conformance is operation- or feature-based ("implement these specific operations"), backends stub out unneeded operations but must track conformance at finer granularity. + +If we define conformance in terms of a stable set of DynamoDB features rather than traits, a classification more like the following emerges: + +**Mandatory DynamoDB operations (control plane):** +- CreateTable, DeleteTable, DescribeTable, ListTables, UpdateTable +- TagResource, UntagResource, ListTagsOfResource + +**Mandatory DynamoDB operations (data plane):** +- PutItem, GetItem, DeleteItem, UpdateItem +- Query, Scan (basic, no index selection) +- BatchGetItem, BatchWriteItem + +**Mandatory infrastructure traits:** +- `ManagementStore` - core management operations +- `AdminStore` - administrative functions +- `AuthorizationStore` - authorization data (policies, users, groups, roles) +- `Bootstrapper` - initialization and bootstrap + +ExtendDB requires SigV4 authentication on all DynamoDB API requests. Backends must implement these infrastructure traits to persist auth primitives, even though they don't map directly to DynamoDB APIs. Unlike optional DynamoDB operations, these infrastructure traits remain mandatory in both conformance models. + +**Optional DynamoDB operation classes:** +- Secondary indexes (GSI, LSI) +- Transactions (TransactGetItems, TransactWriteItems) +- Streams (ListStreams, DescribeStream, GetRecords) +- TTL (UpdateTimeToLive, DescribeTimeToLive) +- Import/Export (ImportTable, ExportTableToPointInTime) +- Advanced Query/Scan features (filters, projections, index selection) + +Under this model, backends would declare supported operations explicitly (via manifest or code), and the server would maintain a capability registry for runtime enforcement. See "Capability discovery and enforcement" above for implementation considerations that apply to both conformance models. + +**Tradeoffs:** + +| Aspect | Trait-based | Operation-based | +|--------|-------------|-----------------| +| Developer clarity | "Implement these traits" | "Implement these operations" | +| Granularity | Coarse (whole trait) | Fine (individual operation) | +| Partial features | Forces full trait implementation | Allows targeted implementation | +| Maintenance | Trait changes affect all backends | Operation registry must stay stable | +| Test complexity | Test entire trait or skip it | Test operation-by-operation | +| Runtime checks | None (compile-time trait bounds) | Capability registry + error handling | +| Stub implementations | Not required | Required for unimplemented operations | + +**Current recommendation:** Start with trait-based conformance for simplicity. The ExtendDB team currently maintains all backends and can absorb the cost of implementing full traits. Revisit operation-based conformance if: +- External contributors request narrower conformance targets +- Trait/feature misalignment becomes a blocking issue +- Backends emerge with fundamentally different capability models (e.g., read-only, control-plane-only) + +This alternate proposal is documented for future consideration, not immediate adoption. + ### Contribution process #### For ExtendDB organization members From 02314fb72e71b4e07c101fe9bc08f77811b6f70d Mon Sep 17 00:00:00 2001 From: Joel Shepherd Date: Tue, 15 Sep 2026 18:38:58 +0000 Subject: [PATCH 03/48] feat: enable node-specific instance_id to be set through StorageConfig. Backends that need per-node identity (e.g. to construct unique hybrid logical clock (HLC) values for stream sequence ids), can receive an identity via StorageConfig during extenddb serve. Default implementations to avoid breaking backends that don't need this. --- crates/config/src/lib.rs | 9 +++++++++ crates/server/src/serve.rs | 10 +++++++++- crates/storage/src/config.rs | 11 +++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 05430306..4f940c80 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -165,6 +165,15 @@ impl StorageConfig { pub fn as_trait(&self) -> &(dyn extenddb_storage::config::StorageConfig + 'static) { &*self.config } + + /// Set the node's instance identifier on the underlying config. + /// + /// Backends that need a per-node identity read this via + /// [`extenddb_storage::config::StorageConfig::instance_id`]. Call this + /// before passing the config to the backend factory. + pub fn set_instance_id(&mut self, instance_id: &str) { + self.config.set_instance_id(instance_id); + } } impl<'de> serde::Deserialize<'de> for StorageConfig { diff --git a/crates/server/src/serve.rs b/crates/server/src/serve.rs index 6d7c9da0..802be8fc 100644 --- a/crates/server/src/serve.rs +++ b/crates/server/src/serve.rs @@ -191,7 +191,7 @@ pub async fn serve(params: ServeParams) -> anyhow::Result<()> { /// any error path. async fn serve_inner(params: ServeParams, port: u16) -> anyhow::Result<()> { let ServeParams { - app_config, + mut app_config, listener: std_listener, pid_file, log_target, @@ -287,7 +287,15 @@ async fn serve_inner(params: ServeParams, port: u16) -> anyhow::Result<()> { // to bootstrap an uninitialized catalog at serve time (zero-config use). let mut component_options = extenddb_storage::server_components::ServerComponentsOptions::default(); + component_options.bootstrap_if_uninitialized = dev_mode; + + // Wire the node's own bind address as the instance ID so each ExtendDB node + // has a distinct node ID (to support logical clocks for sequences, etc.). + app_config + .storage + .set_instance_id(&format!("{}:{}", app_config.server.bind_addr, port)); + let components = extenddb_storage::create_server_components( app_config.storage.as_trait(), &app_config.server.region, diff --git a/crates/storage/src/config.rs b/crates/storage/src/config.rs index 2101bc5f..3bdb1204 100644 --- a/crates/storage/src/config.rs +++ b/crates/storage/src/config.rs @@ -68,6 +68,17 @@ pub trait StorageConfig: Send + Sync + std::fmt::Debug { fn as_any(&self) -> &dyn std::any::Any where Self: 'static; + + /// Set a per-node instance identifier used to derive backend-specific + /// node identities (e.g. HLC node IDs for stream sequence numbers). + /// Backends that do not need a node identity may ignore this. + fn set_instance_id(&mut self, _instance_id: &str) {} + + /// Return the instance identifier previously set via [`set_instance_id`], + /// if any. + fn instance_id(&self) -> Option<&str> { + None + } } impl Clone for Box { From 4e19666763cb3c639f8c90c6bf87854fee7f7c0a Mon Sep 17 00:00:00 2001 From: Joel Shepherd Date: Fri, 28 Aug 2026 01:23:57 +0000 Subject: [PATCH 04/48] feat: Apache Cassandra storage backend (in-tree) Migrates extenddb-cassandra-plugin into crates/storage-cassandra as a first-class backend, selected via --features cassandra. --- Cargo.lock | 1276 ++++++++------ Cargo.toml | 6 +- crates/app/src/cmd_init.rs | 16 + crates/bin/Cargo.toml | 4 +- crates/bin/src/main.rs | 32 +- crates/engine/src/streams.rs | 6 +- crates/storage-cassandra/Cargo.toml | 31 + .../catalog/V001__initial_schema.cql | 206 +++ .../catalog/V002__backup_restore.cql | 65 + .../V003__account_scoped_idempotency.cql | 13 + .../data/V001__initial_data_schema.cql | 62 + .../migrations/data/V002__backup_items.cql | 12 + crates/storage-cassandra/src/admin_store.rs | 207 +++ .../src/authorization_store.rs | 492 ++++++ crates/storage-cassandra/src/backup_engine.rs | 993 +++++++++++ crates/storage-cassandra/src/bootstrapper.rs | 723 ++++++++ .../storage-cassandra/src/cassandra_util.rs | 290 ++++ crates/storage-cassandra/src/catalog_store.rs | 452 +++++ crates/storage-cassandra/src/config.rs | 313 ++++ crates/storage-cassandra/src/create_table.rs | 508 ++++++ .../storage-cassandra/src/credential_store.rs | 288 ++++ .../storage-cassandra/src/data/condition.rs | 30 + .../storage-cassandra/src/data/data_engine.rs | 365 ++++ crates/storage-cassandra/src/data/ddl.rs | 503 ++++++ .../storage-cassandra/src/data/delete_item.rs | 420 +++++ crates/storage-cassandra/src/data/index.rs | 747 ++++++++ crates/storage-cassandra/src/data/mod.rs | 349 ++++ .../src/data/put_get_item.rs | 441 +++++ crates/storage-cassandra/src/data/query.rs | 730 ++++++++ .../src/data/query_helpers.rs | 236 +++ crates/storage-cassandra/src/data/scan.rs | 403 +++++ .../src/data/transaction_ledger.rs | 319 ++++ .../src/data/transactions.rs | 1529 +++++++++++++++++ .../storage-cassandra/src/data/update_item.rs | 379 ++++ crates/storage-cassandra/src/delete_table.rs | 144 ++ crates/storage-cassandra/src/engine.rs | 235 +++ crates/storage-cassandra/src/gsi_queue.rs | 78 + crates/storage-cassandra/src/lib.rs | 293 ++++ .../src/management_store/access_keys.rs | 421 +++++ .../src/management_store/accounts.rs | 434 +++++ .../src/management_store/groups.rs | 293 ++++ .../src/management_store/mod.rs | 590 +++++++ .../src/management_store/policies.rs | 248 +++ .../src/management_store/roles.rs | 325 ++++ .../src/management_store/users.rs | 442 +++++ .../storage-cassandra/src/metadata_engine.rs | 176 ++ crates/storage-cassandra/src/migrations.rs | 251 +++ crates/storage-cassandra/src/operations.rs | 215 +++ crates/storage-cassandra/src/stream_engine.rs | 578 +++++++ crates/storage-cassandra/src/stream_util.rs | 274 +++ crates/storage-cassandra/src/table_engine.rs | 195 +++ crates/storage-cassandra/src/table_helpers.rs | 266 +++ crates/storage-cassandra/src/update_table.rs | 363 ++++ crates/storage-cassandra/src/worker_store.rs | 179 ++ crates/storage-cassandra/src/workers.rs | 428 +++++ crates/storage/src/config.rs | 13 + docs/design/11-high-availability.md | 2 +- 57 files changed, 18382 insertions(+), 507 deletions(-) create mode 100644 crates/storage-cassandra/Cargo.toml create mode 100644 crates/storage-cassandra/migrations/catalog/V001__initial_schema.cql create mode 100644 crates/storage-cassandra/migrations/catalog/V002__backup_restore.cql create mode 100644 crates/storage-cassandra/migrations/catalog/V003__account_scoped_idempotency.cql create mode 100644 crates/storage-cassandra/migrations/data/V001__initial_data_schema.cql create mode 100644 crates/storage-cassandra/migrations/data/V002__backup_items.cql create mode 100755 crates/storage-cassandra/src/admin_store.rs create mode 100755 crates/storage-cassandra/src/authorization_store.rs create mode 100755 crates/storage-cassandra/src/backup_engine.rs create mode 100644 crates/storage-cassandra/src/bootstrapper.rs create mode 100644 crates/storage-cassandra/src/cassandra_util.rs create mode 100644 crates/storage-cassandra/src/catalog_store.rs create mode 100644 crates/storage-cassandra/src/config.rs create mode 100644 crates/storage-cassandra/src/create_table.rs create mode 100644 crates/storage-cassandra/src/credential_store.rs create mode 100644 crates/storage-cassandra/src/data/condition.rs create mode 100644 crates/storage-cassandra/src/data/data_engine.rs create mode 100644 crates/storage-cassandra/src/data/ddl.rs create mode 100644 crates/storage-cassandra/src/data/delete_item.rs create mode 100644 crates/storage-cassandra/src/data/index.rs create mode 100644 crates/storage-cassandra/src/data/mod.rs create mode 100644 crates/storage-cassandra/src/data/put_get_item.rs create mode 100644 crates/storage-cassandra/src/data/query.rs create mode 100644 crates/storage-cassandra/src/data/query_helpers.rs create mode 100644 crates/storage-cassandra/src/data/scan.rs create mode 100644 crates/storage-cassandra/src/data/transaction_ledger.rs create mode 100644 crates/storage-cassandra/src/data/transactions.rs create mode 100644 crates/storage-cassandra/src/data/update_item.rs create mode 100644 crates/storage-cassandra/src/delete_table.rs create mode 100644 crates/storage-cassandra/src/engine.rs create mode 100644 crates/storage-cassandra/src/gsi_queue.rs create mode 100644 crates/storage-cassandra/src/lib.rs create mode 100755 crates/storage-cassandra/src/management_store/access_keys.rs create mode 100644 crates/storage-cassandra/src/management_store/accounts.rs create mode 100644 crates/storage-cassandra/src/management_store/groups.rs create mode 100755 crates/storage-cassandra/src/management_store/mod.rs create mode 100644 crates/storage-cassandra/src/management_store/policies.rs create mode 100755 crates/storage-cassandra/src/management_store/roles.rs create mode 100644 crates/storage-cassandra/src/management_store/users.rs create mode 100755 crates/storage-cassandra/src/metadata_engine.rs create mode 100644 crates/storage-cassandra/src/migrations.rs create mode 100644 crates/storage-cassandra/src/operations.rs create mode 100755 crates/storage-cassandra/src/stream_engine.rs create mode 100644 crates/storage-cassandra/src/stream_util.rs create mode 100644 crates/storage-cassandra/src/table_engine.rs create mode 100644 crates/storage-cassandra/src/table_helpers.rs create mode 100644 crates/storage-cassandra/src/update_table.rs create mode 100755 crates/storage-cassandra/src/worker_store.rs create mode 100644 crates/storage-cassandra/src/workers.rs diff --git a/Cargo.lock b/Cargo.lock index 9cf1a6d5..99280461 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -58,9 +58,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -71,6 +71,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -123,15 +132,15 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.103" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arc-swap" -version = "1.9.1" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" dependencies = [ "rustversion", ] @@ -166,7 +175,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] @@ -178,14 +187,14 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "async-compression" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" dependencies = [ "compression-codecs", "compression-core", @@ -206,13 +215,13 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.4", ] [[package]] @@ -224,6 +233,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -238,9 +256,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -249,14 +267,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.41.0" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -320,7 +339,7 @@ checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -416,9 +435,9 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" dependencies = [ "serde_core", ] @@ -478,7 +497,7 @@ dependencies = [ "indexmap", "js-sys", "once_cell", - "rand 0.9.4", + "rand 0.9.5", "serde", "serde_bytes", "serde_json", @@ -492,6 +511,26 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "byteorder" version = "1.5.0" @@ -500,15 +539,39 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cassandra-protocol" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b9432628147943eb265642273714eb5a5451bb61f652c1f06001c0ef3e099b" +dependencies = [ + "arc-swap", + "bitflags", + "bytes", + "chrono", + "crc32fast", + "derivative", + "derive_more", + "float_eq", + "integer-encoding", + "itertools 0.14.0", + "lz4_flex", + "num-bigint", + "snap", + "thiserror", + "time", + "uuid", +] [[package]] name = "cc" -version = "1.2.62" +version = "1.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" dependencies = [ "find-msvc-tools", "jobserver", @@ -516,6 +579,42 @@ dependencies = [ "shlex", ] +[[package]] +name = "cdrs-tokio" +version = "9.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a209ff3bbfc3db4e1699cb0aff12dfd7a7361e3820736cba5d24eecb6ee224e8" +dependencies = [ + "arc-swap", + "atomic", + "bytemuck", + "cassandra-protocol", + "cdrs-tokio-helpers-derive", + "derivative", + "derive_more", + "futures", + "fxhash", + "itertools 0.14.0", + "rand 0.10.2", + "serde_json", + "thiserror", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "cdrs-tokio-helpers-derive" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61cefbb7648a59bd7de42b0d8bdb5a39e029f429305190dec4e5ed56f98859cf" +dependencies = [ + "itertools 0.11.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -524,15 +623,28 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "rand_core 0.10.1", ] +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "cipher" version = "0.4.4" @@ -545,9 +657,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.1" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -555,9 +667,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -567,14 +679,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 3.0.4", ] [[package]] @@ -606,9 +718,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "combine" -version = "4.6.7" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ "bytes", "memchr", @@ -727,9 +839,9 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] @@ -751,9 +863,9 @@ checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" dependencies = [ "cfg-if", ] @@ -766,9 +878,9 @@ checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" [[package]] name = "crossbeam-channel" -version = "0.5.15" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" dependencies = [ "crossbeam-utils", ] @@ -784,18 +896,18 @@ dependencies = [ [[package]] name = "crossbeam-queue" -version = "0.3.12" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crunchy" @@ -870,7 +982,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.119", ] [[package]] @@ -881,7 +993,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -900,9 +1012,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] name = "der" @@ -935,10 +1047,20 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "powerfmt", "serde_core", ] +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "derive-syn-parse" version = "0.2.0" @@ -947,7 +1069,7 @@ checksum = "d65d7ce8132b7c0e54497a4d9a55a1c2a0912a0d786cf894472ba818fba45762" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -958,7 +1080,7 @@ checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -980,7 +1102,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn", + "syn 2.0.119", "unicode-xid", ] @@ -1010,13 +1132,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.4", ] [[package]] @@ -1042,9 +1164,9 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "either" -version = "1.16.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" dependencies = [ "serde", ] @@ -1085,6 +1207,16 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + [[package]] name = "event-listener" version = "5.4.2" @@ -1113,6 +1245,7 @@ dependencies = [ "extenddb-app", "extenddb-config", "extenddb-storage", + "extenddb-storage-cassandra", "extenddb-storage-mongodb", "extenddb-storage-postgres", "extenddb-storage-sqlite", @@ -1137,7 +1270,7 @@ dependencies = [ "rustls", "serde", "serde_json", - "sqlx", + "sqlx 0.8.6", "time", "tokio", "toml", @@ -1243,7 +1376,7 @@ dependencies = [ "hyper", "libc", "metrics", - "rand 0.9.4", + "rand 0.9.5", "rustls", "serde", "serde_json", @@ -1269,7 +1402,7 @@ dependencies = [ "extenddb-auth", "extenddb-core", "futures", - "rand 0.9.4", + "rand 0.9.5", "serde", "serde_json", "thiserror", @@ -1281,6 +1414,33 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "extenddb-storage-cassandra" +version = "0.1.10" +dependencies = [ + "aes-gcm", + "async-trait", + "base64 0.22.1", + "bcrypt", + "bigdecimal", + "cdrs-tokio", + "chrono", + "crc32fast", + "extenddb-auth", + "extenddb-core", + "extenddb-storage", + "futures", + "rand 0.9.5", + "serde", + "serde_json", + "time", + "tokio", + "toml", + "tracing", + "uuid", + "zeroize", +] + [[package]] name = "extenddb-storage-mongodb" version = "0.1.11" @@ -1299,7 +1459,7 @@ dependencies = [ "futures", "mongodb", "proptest", - "rand 0.9.4", + "rand 0.9.5", "serde", "serde_json", "thiserror", @@ -1326,10 +1486,10 @@ dependencies = [ "extenddb-storage", "futures", "pgvector", - "rand 0.9.4", + "rand 0.9.5", "serde", "serde_json", - "sqlx", + "sqlx 0.8.6", "time", "tokio", "toml", @@ -1353,10 +1513,10 @@ dependencies = [ "extenddb-core", "extenddb-storage", "futures", - "rand 0.9.4", + "rand 0.9.5", "serde", "serde_json", - "sqlx", + "sqlx 0.8.6", "time", "tokio", "toml", @@ -1373,9 +1533,9 @@ checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] name = "flate2" @@ -1387,6 +1547,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "float_eq" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28a80e3145d8ad11ba0995949bbcf48b9df2be62772b3d351ef017dff6ecb853" + [[package]] name = "flume" version = "0.11.1" @@ -1410,6 +1576,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1421,9 +1593,9 @@ dependencies = [ [[package]] name = "fs-err" -version = "3.3.0" +version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0" +checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a" dependencies = [ "autocfg", "tokio", @@ -1443,9 +1615,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -1458,9 +1630,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -1468,15 +1640,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -1496,38 +1668,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.4", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -1540,6 +1712,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -1579,16 +1760,14 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", "rand_core 0.10.1", - "wasip2", - "wasip3", ] [[package]] @@ -1603,9 +1782,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.14" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" dependencies = [ "atomic-waker", "bytes", @@ -1638,7 +1817,18 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", ] [[package]] @@ -1665,6 +1855,15 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "heck" version = "0.5.0" @@ -1756,6 +1955,15 @@ dependencies = [ "hmac 0.12.1", ] +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", +] + [[package]] name = "hmac" version = "0.12.1" @@ -1785,9 +1993,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -1795,9 +2003,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -1805,9 +2013,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -1839,9 +2047,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.9.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" dependencies = [ "atomic-waker", "bytes", @@ -1873,11 +2081,35 @@ dependencies = [ "tower-service", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -1889,9 +2121,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -1902,9 +2134,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1916,16 +2148,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -1936,15 +2169,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -1955,12 +2188,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -1996,8 +2223,6 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown 0.17.1", - "serde", - "serde_core", ] [[package]] @@ -2009,6 +2234,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "integer-encoding" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c00403deb17c3221a1fe4fb571b9ed0370b3dcd116553c77fa294a3d918699" + [[package]] name = "ipconfig" version = "0.3.4" @@ -2037,6 +2268,24 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -2070,7 +2319,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn", + "syn 2.0.119", ] [[package]] @@ -2089,28 +2338,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.99" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -2134,17 +2382,11 @@ dependencies = [ "spin", ] -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libm" @@ -2154,14 +2396,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.16" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "d7955dfc218a8afb29dfeffd540e3a6e96baeb94fe7138228dd7cc6937fbbf96" dependencies = [ "bitflags", "libc", "plain", - "redox_syscall 0.7.5", + "redox_syscall 0.9.3", ] [[package]] @@ -2183,9 +2425,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "lock_api" @@ -2198,9 +2440,18 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lz4_flex" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e" +dependencies = [ + "twox-hash", +] [[package]] name = "macro_magic" @@ -2211,7 +2462,7 @@ dependencies = [ "macro_magic_core", "macro_magic_macros", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2225,7 +2476,7 @@ dependencies = [ "macro_magic_core_macros", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2236,7 +2487,7 @@ checksum = "b02abfe41815b5bd98dbd4260173db2c116dda171dc0fe7838cb206333b83308" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2247,7 +2498,7 @@ checksum = "73ea28ee64b88876bf45277ed9a5817c1817df061a74f2b988971a12570e5869" dependencies = [ "macro_magic_core", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2287,9 +2538,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "metrics" @@ -2325,9 +2576,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "wasi", @@ -2336,9 +2587,9 @@ dependencies = [ [[package]] name = "moka" -version = "0.12.15" +version = "0.12.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9" dependencies = [ "async-lock", "crossbeam-channel", @@ -2374,9 +2625,9 @@ checksum = "851fac73f7fe22f6a3ab87f720ce509cae7c9fd08e7dd27866cc232dee07ccf4" [[package]] name = "mongodb" -version = "3.8.0" +version = "3.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b814038f367d212f55de0a630cb35102a9b8ca23785a86955d62c0087c93846d" +checksum = "d220eb9ba80bad420e1f9efc4e895fede7418f8779a8e38a3b750e57ff397720" dependencies = [ "base64 0.22.1", "bitflags", @@ -2397,7 +2648,7 @@ dependencies = [ "mongodb-internal-macros", "pbkdf2", "percent-encoding", - "rand 0.9.4", + "rand 0.9.5", "rustc_version_runtime", "rustls", "serde", @@ -2415,19 +2666,19 @@ dependencies = [ "tokio-util", "typed-builder", "uuid", - "webpki-roots 1.0.7", + "webpki-roots 1.0.9", ] [[package]] name = "mongodb-internal-macros" -version = "3.8.0" +version = "3.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f736d2fbc56e0011a341fbb9172bd822fda75c5f93b82fae1c7aab1e2613c810" +checksum = "e38ff3c46c59c2f4d9b26a86e6980dc23583e9d4b45aa579cef6584642093128" dependencies = [ "macro_magic", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2457,9 +2708,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -2476,7 +2727,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.6", + "rand 0.8.8", "smallvec", "zeroize", ] @@ -2489,20 +2740,19 @@ checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] [[package]] name = "num-iter" -version = "0.1.45" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" dependencies = [ - "autocfg", "num-integer", "num-traits", ] @@ -2629,9 +2879,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.6" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf" dependencies = [ "memchr", "ucd-trie", @@ -2639,9 +2889,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.6" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d" dependencies = [ "pest", "pest_generator", @@ -2649,25 +2899,24 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.6" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "pest_meta" -version = "2.8.6" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496" dependencies = [ "pest", - "sha2 0.10.9", ] [[package]] @@ -2676,7 +2925,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3673cba5b9a124916096a423b806a9f29620972c6c97b08db5f2053e9428b481" dependencies = [ - "sqlx", + "sqlx 0.9.0", ] [[package]] @@ -2708,9 +2957,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "plain" @@ -2732,15 +2981,15 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -2771,21 +3020,11 @@ dependencies = [ "num-traits", ] -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -2800,7 +3039,7 @@ dependencies = [ "bit-vec 0.8.0", "bitflags", "num-traits", - "rand 0.9.4", + "rand 0.9.5", "rand_chacha 0.9.0", "rand_xorshift", "regex-syntax", @@ -2817,9 +3056,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -2844,9 +3083,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -2855,9 +3094,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -2870,7 +3109,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", - "getrandom 0.4.2", + "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -2929,18 +3168,18 @@ dependencies = [ [[package]] name = "rapidhash" -version = "4.4.1" +version = "4.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e48930979c155e2f33aa36ab3119b5ee81332beb6482199a8ecd6029b80b59" +checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e" dependencies = [ "rustversion", ] [[package]] name = "rcgen" -version = "0.14.8" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" +checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4" dependencies = [ "aws-lc-rs", "pem", @@ -2961,18 +3200,18 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.7.5" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +checksum = "d678d17679829e73d371e96880897e98fee2ded7acc0a50bdf8af2affa4b2fe5" dependencies = [ "bitflags", ] [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -2981,9 +3220,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "resolv-conf" @@ -3090,9 +3329,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "aws-lc-rs", "log", @@ -3106,18 +3345,18 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "zeroize", ] [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -3127,9 +3366,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "rusty-fork" @@ -3172,9 +3411,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -3192,29 +3431,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.4", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "indexmap", "itoa", @@ -3258,9 +3497,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.21.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" dependencies = [ "serde_core", "serde_with_macros", @@ -3268,21 +3507,21 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.21.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" dependencies = [ "darling", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -3296,7 +3535,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "digest 0.11.3", ] @@ -3318,7 +3557,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "digest 0.11.3", ] @@ -3333,9 +3572,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -3359,9 +3598,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simd_cesu8" @@ -3387,18 +3626,24 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" dependencies = [ "serde", ] +[[package]] +name = "snap" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "199905e6153d6405f9728fe44daace35f8f837bbf830bb6e85fbd5828709a886" + [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -3406,9 +3651,9 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" dependencies = [ "lock_api", ] @@ -3429,13 +3674,24 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" dependencies = [ - "sqlx-core", - "sqlx-macros", + "sqlx-core 0.8.6", + "sqlx-macros 0.8.6", "sqlx-mysql", - "sqlx-postgres", + "sqlx-postgres 0.8.6", "sqlx-sqlite", ] +[[package]] +name = "sqlx" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" +dependencies = [ + "sqlx-core 0.9.0", + "sqlx-macros 0.9.0", + "sqlx-postgres 0.9.0", +] + [[package]] name = "sqlx-core" version = "0.8.6" @@ -3475,6 +3731,38 @@ dependencies = [ "webpki-roots 0.26.11", ] +[[package]] +name = "sqlx-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" +dependencies = [ + "base64 0.22.1", + "bytes", + "cfg-if", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.16.1", + "hashlink 0.11.1", + "indexmap", + "log", + "memchr", + "percent-encoding", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror", + "tracing", + "url", +] + [[package]] name = "sqlx-macros" version = "0.8.6" @@ -3483,9 +3771,22 @@ checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" dependencies = [ "proc-macro2", "quote", - "sqlx-core", - "sqlx-macros-core", - "syn", + "sqlx-core 0.8.6", + "sqlx-macros-core 0.8.6", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core 0.9.0", + "sqlx-macros-core 0.9.0", + "syn 2.0.119", ] [[package]] @@ -3504,15 +3805,37 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "sqlx-core", + "sqlx-core 0.8.6", "sqlx-mysql", - "sqlx-postgres", + "sqlx-postgres 0.8.6", "sqlx-sqlite", - "syn", + "syn 2.0.119", "tokio", "url", ] +[[package]] +name = "sqlx-macros-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" +dependencies = [ + "cfg-if", + "dotenvy", + "either", + "heck", + "hex", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core 0.9.0", + "sqlx-postgres 0.9.0", + "syn 2.0.119", + "url", +] + [[package]] name = "sqlx-mysql" version = "0.8.6" @@ -3535,7 +3858,7 @@ dependencies = [ "futures-util", "generic-array", "hex", - "hkdf", + "hkdf 0.12.4", "hmac 0.12.1", "itoa", "log", @@ -3543,19 +3866,19 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand 0.8.6", + "rand 0.8.8", "rsa", "serde", - "sha1 0.10.6", + "sha1 0.10.7", "sha2 0.10.9", "smallvec", - "sqlx-core", + "sqlx-core 0.8.6", "stringprep", "thiserror", "time", "tracing", "uuid", - "whoami", + "whoami 1.6.1", ] [[package]] @@ -3571,12 +3894,12 @@ dependencies = [ "byteorder", "crc", "dotenvy", - "etcetera", + "etcetera 0.8.0", "futures-channel", "futures-core", "futures-util", "hex", - "hkdf", + "hkdf 0.12.4", "hmac 0.12.1", "home", "itoa", @@ -3585,18 +3908,53 @@ dependencies = [ "memchr", "num-bigint", "once_cell", - "rand 0.8.6", + "rand 0.8.8", "serde", "serde_json", "sha2 0.10.9", "smallvec", - "sqlx-core", + "sqlx-core 0.8.6", "stringprep", "thiserror", "time", "tracing", "uuid", - "whoami", + "whoami 1.6.1", +] + +[[package]] +name = "sqlx-postgres" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "crc", + "dotenvy", + "etcetera 0.11.0", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf 0.13.0", + "hmac 0.13.0", + "itoa", + "log", + "md-5 0.11.0", + "memchr", + "rand 0.10.2", + "serde", + "serde_json", + "sha2 0.11.0", + "smallvec", + "sqlx-core 0.9.0", + "stringprep", + "thiserror", + "tracing", + "whoami 2.1.3", ] [[package]] @@ -3617,7 +3975,7 @@ dependencies = [ "percent-encoding", "serde", "serde_urlencoded", - "sqlx-core", + "sqlx-core 0.8.6", "thiserror", "time", "tracing", @@ -3656,9 +4014,31 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.117" +version = "1.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" dependencies = [ "proc-macro2", "quote", @@ -3679,7 +4059,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -3739,7 +4119,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -3747,41 +4127,40 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.4", ] [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] [[package]] name = "time" -version = "0.3.47" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde_core", @@ -3791,15 +4170,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -3816,9 +4195,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -3826,9 +4205,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -3841,9 +4220,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -3858,13 +4237,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.4", ] [[package]] @@ -3879,9 +4258,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ "futures-core", "pin-project-lite", @@ -3890,15 +4269,16 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-io", "futures-sink", "futures-util", + "libc", "pin-project-lite", "tokio", ] @@ -4011,7 +4391,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -4066,6 +4446,12 @@ dependencies = [ "tracing-serde", ] +[[package]] +name = "twox-hash" +version = "2.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5283634e518fe9e82c7b20520bb4bc209009fd16c82077c802f8111ecbb0117a" + [[package]] name = "typed-builder" version = "0.22.0" @@ -4083,14 +4469,14 @@ checksum = "0e48cea23f68d1f78eb7bc092881b6bb88d3d6b5b7e6234f6f9c911da1ffb221" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "ucd-trie" @@ -4133,9 +4519,9 @@ checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-xid" @@ -4197,11 +4583,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -4252,20 +4638,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] @@ -4276,9 +4653,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.122" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -4289,9 +4666,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4299,74 +4676,40 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.119", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - [[package]] name = "webpki-roots" version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.7", + "webpki-roots 1.0.9", ] [[package]] name = "webpki-roots" -version = "1.0.7" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" dependencies = [ "rustls-pki-types", ] @@ -4381,6 +4724,12 @@ dependencies = [ "wasite", ] +[[package]] +name = "whoami" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" + [[package]] name = "widestring" version = "1.2.1" @@ -4396,6 +4745,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -4588,105 +4972,17 @@ dependencies = [ "memchr", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "wyz" @@ -4738,9 +5034,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -4755,28 +5051,28 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -4796,35 +5092,35 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.4.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -4833,9 +5129,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -4844,17 +5140,17 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.4", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 44ebd9fa..297f1558 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crates/storage", "crates/config", "crates/storage-postgres", + "crates/storage-cassandra", "crates/storage-mongodb", "crates/storage-sqlite", "crates/auth", @@ -30,8 +31,9 @@ extenddb-cache = { path = "crates/cache" } extenddb-engine = { path = "crates/engine" } extenddb-storage = { path = "crates/storage" } extenddb-config = { path = "crates/config" } -extenddb-storage-postgres = { path = "crates/storage-postgres" } +extenddb-storage-cassandra = { path = "crates/storage-cassandra" } extenddb-storage-mongodb = { path = "crates/storage-mongodb" } +extenddb-storage-postgres = { path = "crates/storage-postgres" } extenddb-storage-sqlite = { path = "crates/storage-sqlite" } extenddb-auth = { path = "crates/auth" } extenddb-server = { path = "crates/server" } @@ -63,6 +65,7 @@ urlencoding = { version = "2.1" } moka = { version = "0.12", features = ["future"] } # Database +cdrs-tokio = { version = "9.0", features = ["derive"] } sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "tls-rustls-aws-lc-rs", "postgres", "json", "time", "uuid", "bigdecimal", "derive"] } # Vector column type for the PostgreSQL backend. The `sqlx` feature gives a typed # `Vector` that encodes and decodes pgvector's binary format, so embeddings keep @@ -82,6 +85,7 @@ aes-gcm = "0.10" rand = "0.9" # Time +chrono = "0.4" time = { version = "0.3", features = ["serde", "formatting", "parsing"] } # TLS diff --git a/crates/app/src/cmd_init.rs b/crates/app/src/cmd_init.rs index 6cbe339f..5e944d29 100755 --- a/crates/app/src/cmd_init.rs +++ b/crates/app/src/cmd_init.rs @@ -52,6 +52,22 @@ pub struct InitArgs { #[arg(long)] pg_pass: Option, + /// Cassandra contact points (comma-separated host:port) + #[arg(long)] + cassandra_contact_points: Option, + + /// Cassandra admin user (for CREATE ROLE) + #[arg(long)] + cassandra_user: Option, + + /// Cassandra admin password + #[arg(long)] + cassandra_pass: Option, + + /// Cassandra keyspace prefix + #[arg(long)] + keyspace_prefix: Option, + /// extenddb application user #[arg(long)] extenddb_user: Option, diff --git a/crates/bin/Cargo.toml b/crates/bin/Cargo.toml index 46099a38..baf3fc7d 100755 --- a/crates/bin/Cargo.toml +++ b/crates/bin/Cargo.toml @@ -14,6 +14,7 @@ path = "src/main.rs" [features] default = ["postgres"] postgres = ["extenddb-storage-postgres"] +cassandra = ["extenddb-storage-cassandra"] sqlite = ["extenddb-storage-sqlite"] # Build the SQLite backend in its zero-config, ephemeral in-memory mode. sqlite-memory = ["sqlite", "extenddb-storage-sqlite/memory"] @@ -33,8 +34,9 @@ dev-mode = ["extenddb-app/dev-mode"] [dependencies] extenddb-app = { workspace = true } extenddb-storage = { workspace = true } -extenddb-storage-postgres = { workspace = true, optional = true } +extenddb-storage-cassandra = { workspace = true, optional = true } extenddb-storage-mongodb = { workspace = true, optional = true } +extenddb-storage-postgres = { workspace = true, optional = true } extenddb-storage-sqlite = { workspace = true, optional = true } anyhow = { workspace = true } extenddb-config = { workspace = true } diff --git a/crates/bin/src/main.rs b/crates/bin/src/main.rs index bee1430d..1e2b27d0 100755 --- a/crates/bin/src/main.rs +++ b/crates/bin/src/main.rs @@ -11,25 +11,29 @@ //! //! In-tree backends are selected by mutually exclusive Cargo features: //! `postgres` (the default production backend), `mongodb` (production, built with -//! `--no-default-features --features mongodb`), and `sqlite`/`sqlite-memory` (the -//! dev/CI backend). Exactly one must be enabled: [`set_backend`] installs one +//! `--no-default-features --features mongodb`), `cassandra`, and `sqlite`/`sqlite-memory` +//! (the dev/CI backend). Exactly one must be enabled: [`set_backend`] installs one //! backend per process, so a build with more than one would be ambiguous and is //! rejected at compile time. // Exactly one backend feature must be enabled. #[cfg(any( - all(feature = "postgres", feature = "sqlite"), + all(feature = "postgres", feature = "cassandra"), all(feature = "postgres", feature = "mongodb"), + all(feature = "postgres", feature = "sqlite"), + all(feature = "sqlite", feature = "cassandra"), all(feature = "sqlite", feature = "mongodb"), + all(feature = "cassandra", feature = "mongodb"), ))] compile_error!( - "the `postgres`, `mongodb`, and `sqlite` features are mutually exclusive: a \ - thin bin installs exactly one backend (e.g. build the MongoDB binary with \ - `--no-default-features --features mongodb`)" + "the `postgres`, `mongodb`, `sqlite`, and `cassandra` features are mutually exclusive: \ + a thin bin installs exactly one backend (e.g. build the MongoDB binary with \ + `--no-default-features --features mongodb)" ); -#[cfg(not(any(feature = "postgres", feature = "mongodb", feature = "sqlite")))] + +#[cfg(not(any(feature = "postgres", feature = "mongodb", feature = "sqlite", feature = "cassandra")))] compile_error!( - "no backend selected: enable the `postgres` (default), `mongodb`, or `sqlite` feature" + "no backend selected: enable the `postgres` (default), `mongodb`, `sqlite` or `cassandra` feature" ); // Developer mode relaxes the security posture (plain HTTP on loopback, open @@ -51,12 +55,14 @@ fn main() -> anyhow::Result<()> { // Install the compiled-in backend before dispatch. The compiler checks this // call; there is no link-time auto-registration and no name to resolve, so a // missing or mistyped backend cannot become a runtime error. + #[cfg(feature = "cassandra")] + extenddb_storage::set_backend(extenddb_storage_cassandra::backend())?; + #[cfg(feature = "mongodb")] + extenddb_storage::set_backend(extenddb_storage_mongodb::backend())?; #[cfg(feature = "postgres")] extenddb_storage::set_backend(extenddb_storage_postgres::backend())?; #[cfg(feature = "sqlite")] extenddb_storage::set_backend(extenddb_storage_sqlite::backend())?; - #[cfg(feature = "mongodb")] - extenddb_storage::set_backend(extenddb_storage_mongodb::backend())?; extenddb_app::run(extenddb_app::BuildInfo { // Read from the bin crate so the reported version is the deployed @@ -72,12 +78,14 @@ fn main() -> anyhow::Result<()> { mod tests { /// Install this binary's backend once for the test process. fn install_backend() { + #[cfg(feature = "cassandra")] + let _ = extenddb_storage::set_backend(extenddb_storage_cassandra::backend()); + #[cfg(feature = "mongodb")] + let _ = extenddb_storage::set_backend(extenddb_storage_mongodb::backend()); #[cfg(feature = "postgres")] let _ = extenddb_storage::set_backend(extenddb_storage_postgres::backend()); #[cfg(feature = "sqlite")] let _ = extenddb_storage::set_backend(extenddb_storage_sqlite::backend()); - #[cfg(feature = "mongodb")] - let _ = extenddb_storage::set_backend(extenddb_storage_mongodb::backend()); } /// Zero-config serve contract: with the SQLite backend installed, diff --git a/crates/engine/src/streams.rs b/crates/engine/src/streams.rs index 0803a80f..fef3c836 100755 --- a/crates/engine/src/streams.rs +++ b/crates/engine/src/streams.rs @@ -111,13 +111,15 @@ pub async fn handle_get_shard_iterator( "SequenceNumber is required for AT_SEQUENCE_NUMBER iterator type".to_owned(), ) })?; - let n = raw.parse::().map_err(|_| { + let n = raw.parse::().map_err(|_| { DynamoDbError::ValidationException("Invalid SequenceNumber".to_owned()) })?; // n == 0: sequence 0 is the first possible record, so "at 0" // means "read from the beginning" — same as TRIM_HORIZON. if n > 0 { - format!("{:021}", n - 1) + // Pad to the same width as the input so lexicographic order + // matches numeric order against stored sequence numbers. + format!("{:0>width$}", n - 1, width = raw.len()) } else { String::new() } diff --git a/crates/storage-cassandra/Cargo.toml b/crates/storage-cassandra/Cargo.toml new file mode 100644 index 00000000..92e3fab9 --- /dev/null +++ b/crates/storage-cassandra/Cargo.toml @@ -0,0 +1,31 @@ +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 +[package] +name = "extenddb-storage-cassandra" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +extenddb-core = { workspace = true } +extenddb-storage = { workspace = true } +extenddb-auth = { workspace = true } +async-trait = { workspace = true } +futures = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +toml = { workspace = true } +tokio = { workspace = true, features = ["sync"] } +tracing = { workspace = true } +uuid = { workspace = true } +time = { workspace = true } +base64 = { workspace = true } +bcrypt = { workspace = true } +aes-gcm = { workspace = true } +rand = { workspace = true } +bigdecimal = { workspace = true } +crc32fast = { workspace = true } +zeroize = { workspace = true } +cdrs-tokio = { workspace = true } +chrono = { workspace = true } diff --git a/crates/storage-cassandra/migrations/catalog/V001__initial_schema.cql b/crates/storage-cassandra/migrations/catalog/V001__initial_schema.cql new file mode 100644 index 00000000..9c128fde --- /dev/null +++ b/crates/storage-cassandra/migrations/catalog/V001__initial_schema.cql @@ -0,0 +1,206 @@ +-- Copyright 2026 ExtendDB contributors +-- SPDX-License-Identifier: Apache-2.0 +-- Consolidated catalog schema for extenddb (catalog version 0.0.2). +-- This is the complete schema applied on fresh installs. + +-- Migration tracking table (must be first) +CREATE TABLE IF NOT EXISTS schema_history ( + version int PRIMARY KEY, + description text, + applied_at timestamp +); + +-- Settings table (key-value store) +CREATE TABLE IF NOT EXISTS settings ( + key text PRIMARY KEY, + value text +); + +-- Accounts table +CREATE TABLE IF NOT EXISTS accounts ( + account_id text PRIMARY KEY, + account_name text, + created_at timestamp +); + +-- Tables (per-account table definitions) +CREATE TABLE IF NOT EXISTS tables ( + account_id text, + table_name text, + table_id text, + key_schema text, + attribute_definitions text, + table_status text, + created_at timestamp, + billing_mode text, + provisioned_throughput text, + on_demand_throughput text, + table_class text, + stream_specification text, + table_size_bytes bigint, + item_count bigint, + table_arn text, + ttl_attribute text, + deletion_protection_enabled boolean, + status_transition_at timestamp, + stream_label text, + ttl_index_ready boolean, + PRIMARY KEY ((account_id), table_name) +); +CREATE INDEX IF NOT EXISTS ON tables (table_id); + +-- Indexes (GSI/LSI metadata) +CREATE TABLE IF NOT EXISTS indexes ( + table_id text, + index_name text, + index_id text, + index_type text, + key_schema text, + projection text, + index_status text, + provisioned_throughput text, + propagation_delay_ms int, + PRIMARY KEY ((table_id), index_name) +); + +-- Tags (resource tagging) +CREATE TABLE IF NOT EXISTS tags ( + resource_arn text, + tag_key text, + tag_value text, + PRIMARY KEY ((resource_arn), tag_key) +); + +-- Metrics (metrics collection) +CREATE TABLE IF NOT EXISTS metrics ( + bucket timestamp, + metric text, + table_name text, + index_name text, + operation text, + sum double, + count bigint, + min double, + max double, + PRIMARY KEY ((bucket, metric, table_name, index_name, operation)) +); + +-- Login attempts (security auditing) +CREATE TABLE IF NOT EXISTS login_attempts ( + principal text, + attempted_at timestamp, + success boolean, + source_ip text, + PRIMARY KEY ((principal), attempted_at) +) WITH CLUSTERING ORDER BY (attempted_at DESC); + +-- Admin users table +CREATE TABLE IF NOT EXISTS admin_users ( + admin_name text PRIMARY KEY, + password_hash text, + created_at timestamp +); + +-- IAM users table +CREATE TABLE IF NOT EXISTS iam_users ( + account_id text, + user_name text, + user_arn text, + password_hash text, + created_at timestamp, + PRIMARY KEY ((account_id), user_name) +); + +-- IAM groups table +CREATE TABLE IF NOT EXISTS iam_groups ( + account_id text, + group_name text, + group_arn text, + created_at timestamp, + PRIMARY KEY ((account_id), group_name) +); + +-- IAM roles table +CREATE TABLE IF NOT EXISTS iam_roles ( + account_id text, + role_name text, + role_arn text, + trust_policy text, + permissions_boundary_arn text, + created_at timestamp, + PRIMARY KEY ((account_id), role_name) +); + +-- IAM policies table +CREATE TABLE IF NOT EXISTS iam_policies ( + account_id text, + principal_type text, + principal_name text, + policy_name text, + policy_document text, + created_at timestamp, + PRIMARY KEY ((account_id), principal_type, principal_name, policy_name) +); + +-- Access keys table +CREATE TABLE IF NOT EXISTS access_keys ( + access_key_id text PRIMARY KEY, + account_id text, + user_name text, + secret_key_encrypted blob, + is_active boolean, + created_at timestamp +); + +-- IAM sessions table +CREATE TABLE IF NOT EXISTS iam_sessions ( + session_token text PRIMARY KEY, + account_id text, + role_name text, + session_name text, + access_key_id text, + secret_key_encrypted blob, + session_tags text, + session_policy text, + expires_at timestamp, + created_at timestamp +); + +-- IAM user tags table +CREATE TABLE IF NOT EXISTS iam_user_tags ( + account_id text, + user_name text, + tag_key text, + tag_value text, + PRIMARY KEY ((account_id, user_name), tag_key) +); + +-- IAM role tags table +CREATE TABLE IF NOT EXISTS iam_role_tags ( + account_id text, + role_name text, + tag_key text, + tag_value text, + PRIMARY KEY ((account_id, role_name), tag_key) +); + +-- IAM group members table +CREATE TABLE IF NOT EXISTS iam_group_members ( + account_id text, + group_name text, + user_name text, + PRIMARY KEY ((account_id, group_name), user_name) +); + +-- IAM permissions boundaries table +CREATE TABLE IF NOT EXISTS iam_permissions_boundaries ( + account_id text, + principal_type text, + principal_name text, + policy_document text, + PRIMARY KEY (account_id, principal_type, principal_name) +); + +-- Seed settings +INSERT INTO settings (key, value) VALUES ('catalog_version', '0.0.1'); +INSERT INTO settings (key, value) VALUES ('control_plane_delay_seconds', '0.25'); diff --git a/crates/storage-cassandra/migrations/catalog/V002__backup_restore.cql b/crates/storage-cassandra/migrations/catalog/V002__backup_restore.cql new file mode 100644 index 00000000..29709b54 --- /dev/null +++ b/crates/storage-cassandra/migrations/catalog/V002__backup_restore.cql @@ -0,0 +1,65 @@ +-- Copyright 2026 ExtendDB contributors +-- SPDX-License-Identifier: Apache-2.0 +-- Logical backup metadata and continuous backup settings. + +-- Authoritative lookup for ARN-addressed operations. Include account_id in the +-- partition key so a caller cannot even address another account's backup row. +CREATE TABLE IF NOT EXISTS backups_by_arn ( + account_id text, + backup_arn text, + backup_name text, + table_id text, + table_name text, + table_arn text, + backup_status text, + backup_type text, + backup_size_bytes bigint, + item_count bigint, + key_schema text, + attribute_definitions text, + billing_mode text, + provisioned_throughput text, + stream_specification text, + table_created_at timestamp, + created_at timestamp, + PRIMARY KEY ((account_id, backup_arn)) +); + +-- Denormalized list access path for all backups owned by an account. table_id +-- records the exact source-table incarnation even if the name is later reused. +CREATE TABLE IF NOT EXISTS backups_by_account ( + account_id text, + created_at timestamp, + backup_arn text, + backup_name text, + table_id text, + table_name text, + table_arn text, + backup_size_bytes bigint, + PRIMARY KEY ((account_id), created_at, backup_arn) +) WITH CLUSTERING ORDER BY (created_at DESC, backup_arn ASC); + +-- ListBackups filters by table name, and backups intentionally outlive their +-- source table. Keep the name in the partition key so backups from an earlier +-- table incarnation remain discoverable after delete/recreate. table_id retains +-- the immutable source identity used by the authoritative metadata. +CREATE TABLE IF NOT EXISTS backups_by_table ( + account_id text, + table_name text, + created_at timestamp, + backup_arn text, + backup_name text, + table_id text, + table_arn text, + backup_size_bytes bigint, + PRIMARY KEY ((account_id, table_name), created_at, backup_arn) +) WITH CLUSTERING ORDER BY (created_at DESC, backup_arn ASC); + +CREATE TABLE IF NOT EXISTS continuous_backups ( + account_id text, + table_name text, + pitr_enabled boolean, + PRIMARY KEY ((account_id), table_name) +); + +INSERT INTO settings (key, value) VALUES ('catalog_version', '0.0.2'); diff --git a/crates/storage-cassandra/migrations/catalog/V003__account_scoped_idempotency.cql b/crates/storage-cassandra/migrations/catalog/V003__account_scoped_idempotency.cql new file mode 100644 index 00000000..95713498 --- /dev/null +++ b/crates/storage-cassandra/migrations/catalog/V003__account_scoped_idempotency.cql @@ -0,0 +1,13 @@ +-- Copyright 2026 ExtendDB contributors +-- SPDX-License-Identifier: Apache-2.0 +-- Scope TransactWriteItems client tokens to their owning account. + +CREATE TABLE IF NOT EXISTS idempotency_tokens_by_account ( + account_id text, + "token" text, + fingerprint text, + created_at timestamp, + PRIMARY KEY ((account_id), "token") +); + +INSERT INTO settings (key, value) VALUES ('catalog_version', '0.0.3'); diff --git a/crates/storage-cassandra/migrations/data/V001__initial_data_schema.cql b/crates/storage-cassandra/migrations/data/V001__initial_data_schema.cql new file mode 100644 index 00000000..9456fd81 --- /dev/null +++ b/crates/storage-cassandra/migrations/data/V001__initial_data_schema.cql @@ -0,0 +1,62 @@ +-- Copyright 2026 ExtendDB contributors +-- SPDX-License-Identifier: Apache-2.0 +-- Data schema for user-facing DynamoDB resources + +-- Migration tracking table (must be first) +CREATE TABLE IF NOT EXISTS schema_history ( + version int PRIMARY KEY, + description text, + applied_at timestamp +); + +-- Stream shards (DynamoDB Streams shard metadata) +CREATE TABLE IF NOT EXISTS stream_shards ( + shard_id text PRIMARY KEY, + table_id text, + parent_shard_id text, + starting_sequence_number text, + ending_sequence_number text, + created_at timestamp +); + +CREATE INDEX IF NOT EXISTS ON stream_shards (table_id); + +-- Stream records table (DynamoDB Streams) +CREATE TABLE IF NOT EXISTS stream_records ( + shard_id text, + sequence_number text, + table_id text, + event_name text, + record_data text, + created_at timestamp, + PRIMARY KEY ((shard_id), sequence_number) +) WITH CLUSTERING ORDER BY (sequence_number ASC); + +-- Transaction ledger (DynamoDB Transactions) +CREATE TABLE IF NOT EXISTS transaction_ledger ( + txn_id uuid PRIMARY KEY, + state text, + started_at bigint, + client_token text, + request_fingerprint text, + items_blob text +); + +-- Index for efficient age-based scanning during recovery +CREATE INDEX IF NOT EXISTS idx_txn_started_at ON transaction_ledger (started_at); + +-- Persistent GSI propagation queue. +-- Rows are inserted inside the same logged batch as the base write, so they +-- survive a crash. Workers drain their partition in (ready_at, id) order +-- and ready_at enforces the per-GSI propagation delay. +CREATE TABLE IF NOT EXISTS gsi_pending ( + worker_partition int, + last_ready_at timestamp STATIC, + ready_at timestamp, + id timeuuid, + table_id text, + old_item text, + new_item text, + index_context text, + PRIMARY KEY ((worker_partition), ready_at, id) +) WITH CLUSTERING ORDER BY (ready_at ASC, id ASC); diff --git a/crates/storage-cassandra/migrations/data/V002__backup_items.cql b/crates/storage-cassandra/migrations/data/V002__backup_items.cql new file mode 100644 index 00000000..8b63b6d7 --- /dev/null +++ b/crates/storage-cassandra/migrations/data/V002__backup_items.cql @@ -0,0 +1,12 @@ +-- Copyright 2026 ExtendDB contributors +-- SPDX-License-Identifier: Apache-2.0 +-- Logical backup payloads. Each backup is split into bounded partitions so a +-- large DynamoDB table does not become one unbounded Cassandra partition. + +CREATE TABLE IF NOT EXISTS backup_items ( + backup_arn text, + bucket int, + item_index bigint, + item_data text, + PRIMARY KEY ((backup_arn, bucket), item_index) +) WITH CLUSTERING ORDER BY (item_index ASC); diff --git a/crates/storage-cassandra/src/admin_store.rs b/crates/storage-cassandra/src/admin_store.rs new file mode 100755 index 00000000..c16d46e8 --- /dev/null +++ b/crates/storage-cassandra/src/admin_store.rs @@ -0,0 +1,207 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! `AdminStore` implementation for `CassandraCatalogStore`. + +use cdrs_tokio::types::IntoRustByName; +use extenddb_storage::management_store::{AdminEntry, OpError, OpResult}; +use futures::future::BoxFuture; + +use super::catalog_store::CassandraCatalogStore; + +async fn admin_exists( + session: &std::sync::Arc, + catalog_keyspace: &str, + admin_name: &str, +) -> OpResult { + let query = format!( + "SELECT admin_name FROM {}.admin_users WHERE admin_name = ?", + catalog_keyspace + ); + let row = crate::cassandra_util::query_optional( + session, + &query, + cdrs_tokio::query_values!(admin_name), + "admin_exists", + ) + .await?; + Ok(row.is_some()) +} + +impl extenddb_storage::management_store::AdminStore for CassandraCatalogStore { + fn create_admin(&self, admin_name: &str, password_hash: &str) -> BoxFuture<'_, OpResult<()>> { + let admin_name = admin_name.to_owned(); + let password_hash = password_hash.to_owned(); + let session = self.session().clone(); + let catalog_keyspace = self.catalog_keyspace(); + Box::pin(async move { + let query = format!( + "INSERT INTO {}.admin_users (admin_name, password_hash, created_at) \ + VALUES (?, ?, toTimestamp(now())) IF NOT EXISTS", + catalog_keyspace + ); + + let result = session + .query_with_values( + &query, + cdrs_tokio::query_values!(admin_name.as_str(), password_hash.as_str()), + ) + .await + .map_err(|e| { + tracing::error!("create_admin: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + + let body = result.response_body().map_err(|e| { + tracing::error!("create_admin response_body: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + + let rows = body.into_rows().unwrap_or_default(); + if let Some(row) = rows.first() { + let applied: bool = row.get_r_by_name("[applied]").map_err(|e| { + tracing::error!("create_admin parse [applied]: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + if !applied { + return Err(OpError::AlreadyExists( + "Admin user already exists".to_owned(), + )); + } + } + + Ok(()) + }) + } + + fn list_admins(&self) -> BoxFuture<'_, OpResult>> { + let session = self.session().clone(); + let catalog_keyspace = self.catalog_keyspace(); + Box::pin(async move { + let query = format!( + "SELECT admin_name, created_at FROM {}.admin_users", + catalog_keyspace + ); + let rows = crate::cassandra_util::query_rows( + &session, + &query, + cdrs_tokio::query_values!(), + "list_admins", + ) + .await?; + + let mut admins = crate::cassandra_util::map_rows( + rows, + |row| { + Ok(AdminEntry { + admin_name: crate::cassandra_util::get_column( + row, + "admin_name", + "list_admins", + )?, + created_at: crate::cassandra_util::get_timestamp( + row, + "created_at", + "list_admins", + )?, + }) + }, + "list_admins", + )?; + admins.sort_by(|a, b| a.admin_name.cmp(&b.admin_name)); + Ok(admins) + }) + } + + fn delete_admin(&self, admin_name: &str) -> BoxFuture<'_, OpResult<()>> { + let admin_name = admin_name.to_owned(); + let session = self.session().clone(); + let catalog_keyspace = self.catalog_keyspace(); + Box::pin(async move { + if !admin_exists(&session, &catalog_keyspace, &admin_name).await? { + return Err(OpError::NotFound("Admin user not found".to_owned())); + } + + let query = format!( + "DELETE FROM {}.admin_users WHERE admin_name = ?", + catalog_keyspace + ); + crate::cassandra_util::execute( + &session, + &query, + cdrs_tokio::query_values!(admin_name.as_str()), + "delete_admin", + ) + .await + }) + } + + fn change_admin_password( + &self, + admin_name: &str, + password_hash: &str, + ) -> BoxFuture<'_, OpResult<()>> { + let admin_name = admin_name.to_owned(); + let password_hash = password_hash.to_owned(); + let session = self.session().clone(); + let catalog_keyspace = self.catalog_keyspace(); + Box::pin(async move { + if !admin_exists(&session, &catalog_keyspace, &admin_name).await? { + return Err(OpError::NotFound("Admin user not found".to_owned())); + } + + let query = format!( + "UPDATE {}.admin_users SET password_hash = ? WHERE admin_name = ?", + catalog_keyspace + ); + crate::cassandra_util::execute( + &session, + &query, + cdrs_tokio::query_values!(password_hash.as_str(), admin_name.as_str()), + "change_admin_password", + ) + .await + }) + } + + fn verify_admin_password( + &self, + admin_name: &str, + password: &str, + ) -> BoxFuture<'_, OpResult>> { + let admin_name = admin_name.to_owned(); + let password = password.to_owned(); + let session = self.session().clone(); + let catalog_keyspace = self.catalog_keyspace(); + Box::pin(async move { + let query = format!( + "SELECT password_hash FROM {}.admin_users WHERE admin_name = ?", + catalog_keyspace + ); + + let row = crate::cassandra_util::query_optional( + &session, + &query, + cdrs_tokio::query_values!(admin_name.as_str()), + "verify_admin_password", + ) + .await?; + + let Some(row) = row else { + return Ok(None); + }; + + let hash: String = + crate::cassandra_util::get_column(&row, "password_hash", "verify_admin_password")?; + + Ok(Some(verify_bcrypt(password, hash).await)) + }) + } +} + +/// Verify a bcrypt password on a blocking thread (same logic as server::password). +async fn verify_bcrypt(password: String, hash: String) -> bool { + tokio::task::spawn_blocking(move || bcrypt::verify(password, &hash).unwrap_or(false)) + .await + .unwrap_or(false) +} diff --git a/crates/storage-cassandra/src/authorization_store.rs b/crates/storage-cassandra/src/authorization_store.rs new file mode 100755 index 00000000..bf792aa7 --- /dev/null +++ b/crates/storage-cassandra/src/authorization_store.rs @@ -0,0 +1,492 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! `AuthorizationStore` implementation for `CassandraCatalogStore`. + +use cdrs_tokio::types::IntoRustByName; +use extenddb_storage::authorization_store::{AuthorizationStore, SessionData}; +use extenddb_storage::management_store::{OpError, OpResult}; +use futures::future::BoxFuture; + +use super::catalog_store::CassandraCatalogStore; + +impl AuthorizationStore for CassandraCatalogStore { + fn fetch_user_policies( + &self, + account_id: &str, + user_name: &str, + ) -> BoxFuture<'_, OpResult>> { + let account_id = account_id.to_owned(); + let user_name = user_name.to_owned(); + let session = self.session().clone(); + let catalog_keyspace = self.catalog_keyspace(); + Box::pin(async move { + let query = format!( + "SELECT policy_document FROM {}.iam_policies \ + WHERE account_id = ? AND principal_type = 'user' AND principal_name = ?", + catalog_keyspace + ); + let result = session + .query_with_values( + &query, + cdrs_tokio::query_values!(account_id.as_str(), user_name.as_str()), + ) + .await + .map_err(|e| { + tracing::error!("fetch_user_policies: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + + let body = result.response_body().map_err(|e| { + tracing::error!("fetch_user_policies response body: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + + let rows = body.into_rows().unwrap_or_default(); + let mut policies = Vec::new(); + for row in rows { + let policy_doc: String = row.get_r_by_name("policy_document").map_err(|e| { + tracing::error!("fetch_user_policies parse policy_document: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + policies.push(policy_doc); + } + Ok(policies) + }) + } + + fn fetch_user_group_policies( + &self, + account_id: &str, + user_name: &str, + ) -> BoxFuture<'_, OpResult>> { + let account_id = account_id.to_owned(); + let user_name = user_name.to_owned(); + let session = self.session().clone(); + let catalog_keyspace = self.catalog_keyspace(); + Box::pin(async move { + // First, get groups the user belongs to + // Uses ALLOW FILTERING for reverse lookup (user -> groups). + // TODO: Consider denormalizing to iam_user_groups table for scale + // See notes/performance-considerations.md + let groups_query = format!( + "SELECT group_name FROM {}.iam_group_members \ + WHERE account_id = ? AND user_name = ? ALLOW FILTERING", + catalog_keyspace + ); + let groups_result = session + .query_with_values( + &groups_query, + cdrs_tokio::query_values!(account_id.as_str(), user_name.as_str()), + ) + .await + .map_err(|e| { + tracing::error!("fetch_user_group_policies groups: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + + let groups_body = groups_result.response_body().map_err(|e| { + tracing::error!("fetch_user_group_policies groups response body: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + + let group_rows = groups_body.into_rows().unwrap_or_default(); + let mut policies = Vec::new(); + + // For each group, fetch policies + for row in group_rows { + let group_name: String = row.get_r_by_name("group_name").map_err(|e| { + tracing::error!("fetch_user_group_policies parse group_name: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + + let policy_query = format!( + "SELECT policy_document FROM {}.iam_policies \ + WHERE account_id = ? AND principal_type = 'group' AND principal_name = ?", + catalog_keyspace + ); + let policy_result = session + .query_with_values( + &policy_query, + cdrs_tokio::query_values!(account_id.as_str(), group_name.as_str()), + ) + .await + .map_err(|e| { + tracing::error!("fetch_user_group_policies policies: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + + let policy_body = policy_result.response_body().map_err(|e| { + tracing::error!("fetch_user_group_policies policies response body: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + + if let Some(policy_rows) = policy_body.into_rows() { + for policy_row in policy_rows { + let policy_doc: String = + policy_row.get_r_by_name("policy_document").map_err(|e| { + tracing::error!( + "fetch_user_group_policies parse policy_document: {e}" + ); + OpError::Internal("Database error".to_owned()) + })?; + policies.push(policy_doc); + } + } + } + + Ok(policies) + }) + } + + fn fetch_user_boundary( + &self, + account_id: &str, + user_name: &str, + ) -> BoxFuture<'_, OpResult>> { + let account_id = account_id.to_owned(); + let user_name = user_name.to_owned(); + let session = self.session().clone(); + let catalog_keyspace = self.catalog_keyspace(); + Box::pin(async move { + let query = format!( + "SELECT policy_document FROM {}.iam_permissions_boundaries \ + WHERE account_id = ? AND principal_type = 'user' AND principal_name = ?", + catalog_keyspace + ); + let result = session + .query_with_values( + &query, + cdrs_tokio::query_values!(account_id.as_str(), user_name.as_str()), + ) + .await + .map_err(|e| { + tracing::error!("fetch_user_boundary: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + + let body = result.response_body().map_err(|e| { + tracing::error!("fetch_user_boundary response body: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + + let rows = body.into_rows().unwrap_or_default(); + if let Some(row) = rows.first() { + let policy_doc: String = row.get_r_by_name("policy_document").map_err(|e| { + tracing::error!("fetch_user_boundary parse policy_document: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + Ok(Some(policy_doc)) + } else { + Ok(None) + } + }) + } + + fn fetch_role_policies( + &self, + account_id: &str, + role_name: &str, + ) -> BoxFuture<'_, OpResult>> { + let account_id = account_id.to_owned(); + let role_name = role_name.to_owned(); + let session = self.session().clone(); + let catalog_keyspace = self.catalog_keyspace(); + Box::pin(async move { + let query = format!( + "SELECT policy_document FROM {}.iam_policies \ + WHERE account_id = ? AND principal_type = 'role' AND principal_name = ?", + catalog_keyspace + ); + let rows = crate::cassandra_util::query_rows( + &session, + &query, + cdrs_tokio::query_values!(account_id.as_str(), role_name.as_str()), + "fetch_role_policies", + ) + .await?; + + crate::cassandra_util::map_rows( + rows, + |row| { + crate::cassandra_util::get_column(row, "policy_document", "fetch_role_policies") + }, + "fetch_role_policies", + ) + }) + } + + fn fetch_role_boundary( + &self, + account_id: &str, + role_name: &str, + ) -> BoxFuture<'_, OpResult>> { + let account_id = account_id.to_owned(); + let role_name = role_name.to_owned(); + let session = self.session().clone(); + let catalog_keyspace = self.catalog_keyspace(); + Box::pin(async move { + let query = format!( + "SELECT policy_document FROM {}.iam_permissions_boundaries \ + WHERE account_id = ? AND principal_type = 'role' AND principal_name = ?", + catalog_keyspace + ); + let row = crate::cassandra_util::query_optional( + &session, + &query, + cdrs_tokio::query_values!(account_id.as_str(), role_name.as_str()), + "fetch_role_boundary", + ) + .await?; + + match row { + Some(r) => { + let doc = crate::cassandra_util::get_column( + &r, + "policy_document", + "fetch_role_boundary", + )?; + Ok(Some(doc)) + } + None => Ok(None), + } + }) + } + + fn fetch_session_data( + &self, + account_id: &str, + role_name: &str, + session_name: &str, + ) -> BoxFuture<'_, OpResult>> { + let account_id = account_id.to_owned(); + let role_name = role_name.to_owned(); + let session_name = session_name.to_owned(); + let catalog_keyspace = self.catalog_keyspace(); + let session = self.session().clone(); + + Box::pin(async move { + let query = format!( + "SELECT session_policy, session_tags FROM {}.iam_sessions \ + WHERE account_id = ? AND role_name = ? AND session_name = ? ALLOW FILTERING", + catalog_keyspace + ); + + let result = session + .query_with_values( + &query, + cdrs_tokio::query_values!( + account_id.as_str(), + role_name.as_str(), + session_name.as_str() + ), + ) + .await + .map_err(|e| { + tracing::error!("fetch_session_data query failed: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + + let body = result.response_body().map_err(|e| { + tracing::error!("fetch_session_data response_body failed: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + + let rows = match body.into_rows() { + Some(r) if !r.is_empty() => r, + _ => return Ok(None), + }; + + let row = &rows[0]; + + // Parse session_policy (optional text) + let session_policy: Option = row.get_r_by_name("session_policy").ok(); + + // Parse session_tags (optional text containing JSON) + let session_tags_text: Option = row.get_r_by_name("session_tags").ok(); + let mut session_tags = Vec::new(); + + if let Some(tags_text) = session_tags_text { + if let Ok(tags_val) = serde_json::from_str::(&tags_text) { + if let Some(arr) = tags_val.as_array() { + for tag in arr { + if let (Some(k), Some(v)) = ( + tag.get("Key").and_then(|k| k.as_str()), + tag.get("Value").and_then(|v| v.as_str()), + ) { + session_tags.push((k.to_owned(), v.to_owned())); + } + } + } else if let Some(obj) = tags_val.as_object() { + for (k, v) in obj { + if let Some(v_str) = v.as_str() { + session_tags.push((k.clone(), v_str.to_owned())); + } + } + } + } + } + + Ok(Some(SessionData { + session_policy, + session_tags, + })) + }) + } + + fn fetch_user_tags( + &self, + account_id: &str, + user_name: &str, + ) -> BoxFuture<'_, OpResult>> { + let account_id = account_id.to_owned(); + let user_name = user_name.to_owned(); + let catalog_keyspace = self.catalog_keyspace(); + let session = self.session().clone(); + + Box::pin(async move { + let query = format!( + "SELECT tag_key, tag_value FROM {}.iam_user_tags \ + WHERE account_id = ? AND user_name = ?", + catalog_keyspace + ); + + let result = session + .query_with_values( + &query, + cdrs_tokio::query_values!(account_id.as_str(), user_name.as_str()), + ) + .await + .map_err(|e| { + tracing::error!("fetch_user_tags query failed: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + + let body = result.response_body().map_err(|e| { + tracing::error!("fetch_user_tags response_body failed: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + + let rows = match body.into_rows() { + Some(r) => r, + None => return Ok(Vec::new()), + }; + + let mut tags = Vec::new(); + for row in rows { + let tag_key: String = row.get_r_by_name("tag_key").map_err(|e| { + tracing::error!("fetch_user_tags parse tag_key failed: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + let tag_value: String = row.get_r_by_name("tag_value").map_err(|e| { + tracing::error!("fetch_user_tags parse tag_value failed: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + tags.push((tag_key, tag_value)); + } + + Ok(tags) + }) + } + + fn fetch_role_tags( + &self, + account_id: &str, + role_name: &str, + ) -> BoxFuture<'_, OpResult>> { + let account_id = account_id.to_owned(); + let role_name = role_name.to_owned(); + let catalog_keyspace = self.catalog_keyspace(); + let session = self.session().clone(); + + Box::pin(async move { + let query = format!( + "SELECT tag_key, tag_value FROM {}.iam_role_tags \ + WHERE account_id = ? AND role_name = ?", + catalog_keyspace + ); + + let result = session + .query_with_values( + &query, + cdrs_tokio::query_values!(account_id.as_str(), role_name.as_str()), + ) + .await + .map_err(|e| { + tracing::error!("fetch_role_tags query failed: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + + let body = result.response_body().map_err(|e| { + tracing::error!("fetch_role_tags response_body failed: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + + let rows = match body.into_rows() { + Some(r) => r, + None => return Ok(Vec::new()), + }; + + let mut tags = Vec::new(); + for row in rows { + let tag_key: String = row.get_r_by_name("tag_key").map_err(|e| { + tracing::error!("fetch_role_tags parse tag_key failed: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + let tag_value: String = row.get_r_by_name("tag_value").map_err(|e| { + tracing::error!("fetch_role_tags parse tag_value failed: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + tags.push((tag_key, tag_value)); + } + + Ok(tags) + }) + } + + fn fetch_resource_tags(&self, arn: &str) -> BoxFuture<'_, OpResult>> { + let arn = arn.to_owned(); + let catalog_keyspace = self.catalog_keyspace(); + let session = self.session().clone(); + + Box::pin(async move { + let query = format!( + "SELECT tag_key, tag_value FROM {}.tags WHERE resource_arn = ?", + catalog_keyspace + ); + + let result = session + .query_with_values(&query, cdrs_tokio::query_values!(arn.as_str())) + .await + .map_err(|e| { + tracing::error!("fetch_resource_tags query failed: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + + let body = result.response_body().map_err(|e| { + tracing::error!("fetch_resource_tags response_body failed: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + + let rows = match body.into_rows() { + Some(r) => r, + None => return Ok(Vec::new()), + }; + + let mut tags = Vec::new(); + for row in rows { + let tag_key: String = row.get_r_by_name("tag_key").map_err(|e| { + tracing::error!("fetch_resource_tags parse tag_key failed: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + let tag_value: String = row.get_r_by_name("tag_value").map_err(|e| { + tracing::error!("fetch_resource_tags parse tag_value failed: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + tags.push((tag_key, tag_value)); + } + + Ok(tags) + }) + } +} diff --git a/crates/storage-cassandra/src/backup_engine.rs b/crates/storage-cassandra/src/backup_engine.rs new file mode 100755 index 00000000..dd7af985 --- /dev/null +++ b/crates/storage-cassandra/src/backup_engine.rs @@ -0,0 +1,993 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Backup and restore implementation for Cassandra storage. +//! +//! Cassandra's native snapshots are node-local operational artifacts and require +//! filesystem/JMX orchestration that is not available through CQL. This module +//! therefore implements the DynamoDB-facing API as a logical snapshot: immutable +//! item JSON is stored in bounded partitions in the owning account keyspace, +//! while metadata and list access paths are stored in the catalog keyspace. + +use cdrs_tokio::consistency::Consistency; +use cdrs_tokio::query::BatchQueryBuilder; +use cdrs_tokio::types::{IntoRustByName, rows::Row}; +use extenddb_core::types::{ + BackupDescription, BackupDetails, BackupSummary, BillingMode, ContinuousBackupsDescription, + CreateTableInput, PointInTimeRecoveryDescription, ProvisionedThroughput, SourceTableDetails, + TableDescription, TableKeyInfo, +}; +use extenddb_storage::BackupEngine; +use extenddb_storage::error::StorageError; +use futures::future::BoxFuture; + +use crate::CassandraEngine; + +/// Maximum number of item payloads stored in one Cassandra partition. +/// +/// DynamoDB items are at most 400 KiB, so 64 items bound a partition to roughly +/// 25 MiB before Cassandra overhead. +const BACKUP_ITEMS_PER_BUCKET: i64 = 64; +const BACKUP_SCAN_PAGE_SIZE: i64 = 1_000; + +/// Current epoch milliseconds, used in backup identifiers and timestamps. +fn epoch_millis() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() +} + +/// Build the trailing `backup/` component of a backup ARN. +fn backup_id() -> String { + use rand::Rng; + let suffix: u32 = rand::rng().random(); + format!("{}-{suffix:08x}", epoch_millis()) +} + +fn epoch_seconds(timestamp_millis: i64) -> f64 { + timestamp_millis as f64 / 1_000.0 +} + +fn bucket_count(item_count: i64) -> i64 { + if item_count <= 0 { + 0 + } else { + (item_count + BACKUP_ITEMS_PER_BUCKET - 1) / BACKUP_ITEMS_PER_BUCKET + } +} + +#[derive(Debug)] +struct StoredBackup { + backup_arn: String, + account_id: String, + backup_name: String, + table_id: String, + table_name: String, + table_arn: String, + backup_status: String, + backup_type: String, + backup_size_bytes: i64, + item_count: i64, + key_schema: String, + attribute_definitions: String, + billing_mode: String, + provisioned_throughput: Option, + stream_specification: Option, + table_created_at: i64, + created_at: i64, +} + +impl StoredBackup { + fn from_row(row: &Row) -> Result { + Ok(Self { + backup_arn: row + .get_r_by_name("backup_arn") + .map_err(|e| StorageError::Internal(format!("Parse backup_arn: {e}")))?, + account_id: row + .get_r_by_name("account_id") + .map_err(|e| StorageError::Internal(format!("Parse account_id: {e}")))?, + backup_name: row + .get_r_by_name("backup_name") + .map_err(|e| StorageError::Internal(format!("Parse backup_name: {e}")))?, + table_id: row + .get_r_by_name("table_id") + .map_err(|e| StorageError::Internal(format!("Parse table_id: {e}")))?, + table_name: row + .get_r_by_name("table_name") + .map_err(|e| StorageError::Internal(format!("Parse table_name: {e}")))?, + table_arn: row + .get_r_by_name("table_arn") + .map_err(|e| StorageError::Internal(format!("Parse table_arn: {e}")))?, + backup_status: row + .get_r_by_name("backup_status") + .map_err(|e| StorageError::Internal(format!("Parse backup_status: {e}")))?, + backup_type: row + .get_r_by_name("backup_type") + .map_err(|e| StorageError::Internal(format!("Parse backup_type: {e}")))?, + backup_size_bytes: row.get_r_by_name("backup_size_bytes").unwrap_or(0), + item_count: row.get_r_by_name("item_count").unwrap_or(0), + key_schema: row + .get_r_by_name("key_schema") + .map_err(|e| StorageError::Internal(format!("Parse key_schema: {e}")))?, + attribute_definitions: row + .get_r_by_name("attribute_definitions") + .map_err(|e| StorageError::Internal(format!("Parse attribute_definitions: {e}")))?, + billing_mode: row + .get_r_by_name("billing_mode") + .map_err(|e| StorageError::Internal(format!("Parse billing_mode: {e}")))?, + provisioned_throughput: row.get_by_name("provisioned_throughput").ok().flatten(), + stream_specification: row.get_by_name("stream_specification").ok().flatten(), + table_created_at: row + .get_r_by_name("table_created_at") + .map_err(|e| StorageError::Internal(format!("Parse table_created_at: {e}")))?, + created_at: row + .get_r_by_name("created_at") + .map_err(|e| StorageError::Internal(format!("Parse created_at: {e}")))?, + }) + } + + fn details(&self) -> BackupDetails { + BackupDetails { + backup_arn: self.backup_arn.clone(), + backup_name: self.backup_name.clone(), + backup_status: self.backup_status.clone(), + backup_type: self.backup_type.clone(), + backup_size_bytes: self.backup_size_bytes, + backup_creation_date_time: epoch_seconds(self.created_at), + } + } + + fn summary(&self) -> BackupSummary { + BackupSummary { + backup_arn: self.backup_arn.clone(), + backup_name: self.backup_name.clone(), + table_name: self.table_name.clone(), + table_arn: self.table_arn.clone(), + backup_status: self.backup_status.clone(), + backup_type: self.backup_type.clone(), + backup_size_bytes: self.backup_size_bytes, + backup_creation_date_time: epoch_seconds(self.created_at), + } + } + + fn description(&self) -> Result { + let key_schema = serde_json::from_str(&self.key_schema) + .map_err(|e| StorageError::Internal(format!("Parse key schema: {e}")))?; + Ok(BackupDescription { + backup_details: self.details(), + source_table_details: SourceTableDetails { + table_name: self.table_name.clone(), + table_id: self.table_id.clone(), + table_arn: self.table_arn.clone(), + key_schema, + item_count: self.item_count, + table_size_bytes: self.backup_size_bytes, + billing_mode: Some(self.billing_mode.clone()), + table_creation_date_time: epoch_seconds(self.table_created_at), + }, + }) + } +} + +impl CassandraEngine { + async fn load_backup( + &self, + account_id: &str, + backup_arn: &str, + ) -> Result { + let query = format!( + "SELECT * FROM {}.backups_by_arn WHERE account_id = ? AND backup_arn = ?", + self.catalog_keyspace() + ); + let result = self + .session + .query_with_values(&query, cdrs_tokio::query_values!(account_id, backup_arn)) + .await + .map_err(|e| StorageError::Internal(format!("Query backup: {e}")))?; + let rows = result + .response_body() + .map_err(|e| StorageError::Internal(format!("Parse backup response: {e}")))? + .into_rows() + .unwrap_or_default(); + let backup = rows + .first() + .map(StoredBackup::from_row) + .transpose()? + .filter(|backup| backup.account_id == account_id) + .ok_or_else(|| StorageError::Validation(format!("Backup not found: {backup_arn}")))?; + Ok(backup) + } + + async fn load_available_backup( + &self, + account_id: &str, + backup_arn: &str, + ) -> Result { + let backup = self.load_backup(account_id, backup_arn).await?; + if backup.backup_status != "AVAILABLE" { + return Err(StorageError::Validation(format!( + "Backup not found: {backup_arn}" + ))); + } + Ok(backup) + } + + async fn cleanup_backup_payload( + &self, + account_id: &str, + backup_arn: &str, + item_count: i64, + ) -> Result<(), StorageError> { + let account_keyspace = self.account_keyspace(account_id); + let query = format!( + "DELETE FROM {}.backup_items WHERE backup_arn = ? AND bucket = ?", + account_keyspace + ); + for bucket in 0..bucket_count(item_count) { + self.session + .query_with_values(&query, cdrs_tokio::query_values!(backup_arn, bucket as i32)) + .await + .map_err(|e| StorageError::Internal(format!("Delete backup payload: {e}")))?; + } + Ok(()) + } + + async fn remove_backup_index_rows(&self, backup: &StoredBackup) -> Result<(), StorageError> { + let catalog = self.catalog_keyspace(); + let by_account = format!( + "DELETE FROM {}.backups_by_account WHERE account_id = ? AND created_at = ? AND backup_arn = ?", + catalog + ); + self.session + .query_with_values( + &by_account, + cdrs_tokio::query_values!( + backup.account_id.as_str(), + backup.created_at, + backup.backup_arn.as_str() + ), + ) + .await + .map_err(|e| StorageError::Internal(format!("Delete account backup index: {e}")))?; + + let by_table = format!( + "DELETE FROM {}.backups_by_table WHERE account_id = ? AND table_name = ? AND created_at = ? AND backup_arn = ?", + catalog + ); + self.session + .query_with_values( + &by_table, + cdrs_tokio::query_values!( + backup.account_id.as_str(), + backup.table_name.as_str(), + backup.created_at, + backup.backup_arn.as_str() + ), + ) + .await + .map_err(|e| StorageError::Internal(format!("Delete table backup index: {e}")))?; + Ok(()) + } + + async fn mark_table_active_after_restore( + &self, + account_id: &str, + table_name: &str, + table_id: &str, + item_count: i64, + table_size_bytes: i64, + ) -> Result<(), StorageError> { + let query = format!( + "UPDATE {}.tables SET table_status = 'ACTIVE', status_transition_at = NULL, item_count = ?, table_size_bytes = ? WHERE account_id = ? AND table_name = ? IF table_id = ? AND table_status = 'CREATING'", + self.catalog_keyspace() + ); + let result = self + .session + .query_with_values( + &query, + cdrs_tokio::query_values!( + item_count, + table_size_bytes, + account_id, + table_name, + table_id + ), + ) + .await + .map_err(|e| StorageError::Internal(format!("Activate restored table: {e}")))?; + let rows = result + .response_body() + .map_err(|e| StorageError::Internal(format!("Parse restore activation: {e}")))? + .into_rows() + .unwrap_or_default(); + if let Some(row) = rows.first() { + let applied: bool = row.get_r_by_name("[applied]").map_err(|e| { + StorageError::Internal(format!("Parse restore activation result: {e}")) + })?; + if !applied { + return Err(StorageError::Internal( + "Restore target changed before activation".to_owned(), + )); + } + } + Ok(()) + } + + async fn table_exists_for_backup( + &self, + account_id: &str, + table_name: &str, + ) -> Result { + let query = format!( + "SELECT table_name FROM {}.tables WHERE account_id = ? AND table_name = ?", + self.catalog_keyspace() + ); + let rows = self + .session + .query_with_values(&query, cdrs_tokio::query_values!(account_id, table_name)) + .await + .map_err(|e| StorageError::Internal(format!("Query table: {e}")))? + .response_body() + .map_err(|e| StorageError::Internal(format!("Parse table response: {e}")))? + .into_rows() + .unwrap_or_default(); + Ok(!rows.is_empty()) + } +} + +impl BackupEngine for CassandraEngine { + fn create_backup( + &self, + account_id: &str, + table_name: &str, + backup_name: &str, + ) -> BoxFuture<'_, Result> { + let account_id = account_id.to_owned(); + let table_name = table_name.to_owned(); + let backup_name = backup_name.to_owned(); + Box::pin(async move { + Self::validate_account_id(&account_id)?; + + // fetch_table_key_info enforces the DynamoDB requirement that only + // ACTIVE tables can be backed up. + let key_info = self.fetch_table_key_info(&account_id, &table_name).await?; + let table = self + .build_table_description(&account_id, &table_name) + .await?; + let backup_arn = format!( + "arn:aws:dynamodb:{region}:{account_id}:table/{table_name}/backup/{id}", + region = self.region, + id = backup_id() + ); + let created_at = chrono::Utc::now().timestamp_millis(); + let table_created_at = (table.creation_date_time * 1_000.0) as i64; + let billing_mode = if table.billing_mode_summary.is_some() { + "PAY_PER_REQUEST" + } else { + "PROVISIONED" + }; + let key_schema = serde_json::to_string(&table.key_schema) + .map_err(|e| StorageError::Internal(format!("Serialize key schema: {e}")))?; + let attribute_definitions = serde_json::to_string(&table.attribute_definitions) + .map_err(|e| StorageError::Internal(format!("Serialize attributes: {e}")))?; + let provisioned = ProvisionedThroughput { + read_capacity_units: table.provisioned_throughput.read_capacity_units, + write_capacity_units: table.provisioned_throughput.write_capacity_units, + }; + let provisioned_throughput = serde_json::to_string(&provisioned) + .map_err(|e| StorageError::Internal(format!("Serialize throughput: {e}")))?; + let stream_specification = table + .stream_specification + .as_ref() + .map(serde_json::to_string) + .transpose() + .map_err(|e| StorageError::Internal(format!("Serialize stream: {e}")))?; + + let catalog = self.catalog_keyspace(); + let insert_metadata = format!( + "INSERT INTO {}.backups_by_arn (backup_arn, account_id, backup_name, table_id, table_name, table_arn, backup_status, backup_type, backup_size_bytes, item_count, key_schema, attribute_definitions, billing_mode, provisioned_throughput, stream_specification, table_created_at, created_at) VALUES (?, ?, ?, ?, ?, ?, 'CREATING', 'USER', ?, 0, ?, ?, ?, ?, ?, ?, ?) IF NOT EXISTS", + catalog + ); + self.session + .query_with_values( + &insert_metadata, + cdrs_tokio::query_values!( + backup_arn.as_str(), + account_id.as_str(), + backup_name.as_str(), + table.table_id.as_str(), + table_name.as_str(), + table.table_arn.as_str(), + table.table_size_bytes, + key_schema.as_str(), + attribute_definitions.as_str(), + billing_mode, + provisioned_throughput.as_str(), + stream_specification.as_deref(), + table_created_at, + created_at + ), + ) + .await + .map_err(|e| StorageError::Internal(format!("Create backup metadata: {e}")))?; + + let account_keyspace = self.account_keyspace(&account_id); + let insert_item = format!( + "INSERT INTO {}.backup_items (backup_arn, bucket, item_index, item_data) VALUES (?, ?, ?, ?)", + account_keyspace + ); + let mut item_count = 0_i64; + let mut payload_size = 0_i64; + let mut start_key = None; + + let snapshot_result: Result<(), StorageError> = async { + loop { + let (items, next_key) = self + .scan_impl( + &key_info, + Some(BACKUP_SCAN_PAGE_SIZE), + start_key.as_ref(), + None, + None, + None, + ) + .await?; + for item in items { + let item_data = serde_json::to_string(&item).map_err(|e| { + StorageError::Internal(format!("Serialize backup item: {e}")) + })?; + let bucket = item_count / BACKUP_ITEMS_PER_BUCKET; + self.session + .query_with_values( + &insert_item, + cdrs_tokio::query_values!( + backup_arn.as_str(), + bucket as i32, + item_count, + item_data.as_str() + ), + ) + .await + .map_err(|e| { + StorageError::Internal(format!("Write backup item: {e}")) + })?; + item_count += 1; + payload_size = payload_size.saturating_add(item_data.len() as i64); + } + match next_key { + Some(key) => start_key = Some(key), + None => break, + } + } + Ok(()) + } + .await; + + if let Err(error) = snapshot_result { + let _ = self + .cleanup_backup_payload(&account_id, &backup_arn, item_count) + .await; + let delete_metadata = format!( + "DELETE FROM {}.backups_by_arn WHERE account_id = ? AND backup_arn = ?", + catalog + ); + let _ = self + .session + .query_with_values( + &delete_metadata, + cdrs_tokio::query_values!(account_id.as_str(), backup_arn.as_str()), + ) + .await; + return Err(error); + } + + let backup_size_bytes = table.table_size_bytes.max(payload_size); + let insert_by_account = format!( + "INSERT INTO {}.backups_by_account (account_id, created_at, backup_arn, backup_name, table_id, table_name, table_arn, backup_size_bytes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + catalog + ); + let insert_by_table = format!( + "INSERT INTO {}.backups_by_table (account_id, table_name, created_at, backup_arn, backup_name, table_id, table_arn, backup_size_bytes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + catalog + ); + let publish = format!( + "UPDATE {}.backups_by_arn SET backup_status = 'AVAILABLE', backup_size_bytes = ?, item_count = ? WHERE account_id = ? AND backup_arn = ?", + catalog + ); + // Publication is a fixed three-statement operation. A logged batch + // prevents readers from observing list rows without the matching + // AVAILABLE authoritative row, or vice versa. + let publish_result: Result<(), StorageError> = async { + let batch = BatchQueryBuilder::new() + .with_consistency(Consistency::LocalQuorum) + .add_query( + insert_by_account, + cdrs_tokio::query_values!( + account_id.as_str(), + created_at, + backup_arn.as_str(), + backup_name.as_str(), + table.table_id.as_str(), + table_name.as_str(), + table.table_arn.as_str(), + backup_size_bytes + ), + ) + .add_query( + insert_by_table, + cdrs_tokio::query_values!( + account_id.as_str(), + table_name.as_str(), + created_at, + backup_arn.as_str(), + backup_name.as_str(), + table.table_id.as_str(), + table.table_arn.as_str(), + backup_size_bytes + ), + ) + .add_query( + publish, + cdrs_tokio::query_values!( + backup_size_bytes, + item_count, + account_id.as_str(), + backup_arn.as_str() + ), + ); + self.session + .batch( + batch + .build() + .map_err(|e| StorageError::Internal(e.to_string()))?, + ) + .await + .map_err(|e| StorageError::Internal(format!("Publish backup batch: {e}")))?; + Ok(()) + } + .await; + + if let Err(error) = publish_result { + if let Ok(backup) = self.load_backup(&account_id, &backup_arn).await { + let _ = self.remove_backup_index_rows(&backup).await; + } + let _ = self + .cleanup_backup_payload(&account_id, &backup_arn, item_count) + .await; + let delete_metadata = format!( + "DELETE FROM {}.backups_by_arn WHERE account_id = ? AND backup_arn = ?", + catalog + ); + let _ = self + .session + .query_with_values( + &delete_metadata, + cdrs_tokio::query_values!(account_id.as_str(), backup_arn.as_str()), + ) + .await; + return Err(error); + } + + Ok(BackupDetails { + backup_arn, + backup_name, + backup_status: "AVAILABLE".to_owned(), + backup_type: "USER".to_owned(), + backup_size_bytes, + backup_creation_date_time: epoch_seconds(created_at), + }) + }) + } + + fn describe_backup( + &self, + account_id: &str, + backup_arn: &str, + ) -> BoxFuture<'_, Result> { + let account_id = account_id.to_owned(); + let backup_arn = backup_arn.to_owned(); + Box::pin(async move { + self.load_available_backup(&account_id, &backup_arn) + .await? + .description() + }) + } + + fn list_backups( + &self, + account_id: &str, + table_name: Option<&str>, + ) -> BoxFuture<'_, Result, StorageError>> { + let account_id = account_id.to_owned(); + let table_name = table_name.map(ToOwned::to_owned); + Box::pin(async move { + Self::validate_account_id(&account_id)?; + let catalog = self.catalog_keyspace(); + let (query, result) = if let Some(table_name) = table_name.as_deref() { + let query = format!( + "SELECT backup_arn, backup_name, table_name, table_arn, backup_size_bytes, created_at FROM {}.backups_by_table WHERE account_id = ? AND table_name = ?", + catalog + ); + let result = self + .session + .query_with_values( + &query, + cdrs_tokio::query_values!(account_id.as_str(), table_name), + ) + .await; + (query, result) + } else { + let query = format!( + "SELECT backup_arn, backup_name, table_name, table_arn, backup_size_bytes, created_at FROM {}.backups_by_account WHERE account_id = ?", + catalog + ); + let result = self + .session + .query_with_values(&query, cdrs_tokio::query_values!(account_id.as_str())) + .await; + (query, result) + }; + let _ = query; + let rows = result + .map_err(|e| StorageError::Internal(format!("List backups: {e}")))? + .response_body() + .map_err(|e| StorageError::Internal(format!("Parse backup list: {e}")))? + .into_rows() + .unwrap_or_default(); + + let mut summaries = Vec::with_capacity(rows.len()); + for row in rows { + let backup_arn: String = row + .get_r_by_name("backup_arn") + .map_err(|e| StorageError::Internal(format!("Parse backup_arn: {e}")))?; + match self.load_available_backup(&account_id, &backup_arn).await { + Ok(backup) => summaries.push(backup.summary()), + Err(StorageError::Validation(_)) => { + // A stale denormalized row from an interrupted create or + // delete must never advertise an unavailable backup. + } + Err(error) => return Err(error), + } + } + Ok(summaries) + }) + } + + fn delete_backup( + &self, + account_id: &str, + backup_arn: &str, + ) -> BoxFuture<'_, Result> { + let account_id = account_id.to_owned(); + let backup_arn = backup_arn.to_owned(); + Box::pin(async move { + let mut backup = self.load_backup(&account_id, &backup_arn).await?; + if backup.backup_status != "AVAILABLE" && backup.backup_status != "DELETING" { + return Err(StorageError::Validation(format!( + "Backup not found: {backup_arn}" + ))); + } + let original_description = backup.description()?; + let mark_deleting = format!( + "UPDATE {}.backups_by_arn SET backup_status = 'DELETING' WHERE account_id = ? AND backup_arn = ?", + self.catalog_keyspace() + ); + self.session + .query_with_values( + &mark_deleting, + cdrs_tokio::query_values!(account_id.as_str(), backup_arn.as_str()), + ) + .await + .map_err(|e| StorageError::Internal(format!("Mark backup deleting: {e}")))?; + + self.remove_backup_index_rows(&backup).await?; + self.cleanup_backup_payload(&account_id, &backup_arn, backup.item_count) + .await?; + + let mark_deleted = format!( + "UPDATE {}.backups_by_arn SET backup_status = 'DELETED' WHERE account_id = ? AND backup_arn = ?", + self.catalog_keyspace() + ); + self.session + .query_with_values( + &mark_deleted, + cdrs_tokio::query_values!(account_id.as_str(), backup_arn.as_str()), + ) + .await + .map_err(|e| StorageError::Internal(format!("Mark backup deleted: {e}")))?; + + backup.backup_status = "DELETED".to_owned(); + Ok(BackupDescription { + backup_details: BackupDetails { + backup_status: "DELETED".to_owned(), + ..original_description.backup_details + }, + source_table_details: original_description.source_table_details, + }) + }) + } + + fn restore_table_from_backup( + &self, + account_id: &str, + target_table_name: &str, + backup_arn: &str, + ) -> BoxFuture<'_, Result> { + let account_id = account_id.to_owned(); + let target_table_name = target_table_name.to_owned(); + let backup_arn = backup_arn.to_owned(); + Box::pin(async move { + Self::validate_account_id(&account_id)?; + let backup = self.load_available_backup(&account_id, &backup_arn).await?; + let key_schema: Vec = + serde_json::from_str(&backup.key_schema) + .map_err(|e| StorageError::Internal(format!("Parse key schema: {e}")))?; + let attribute_definitions: Vec = + serde_json::from_str(&backup.attribute_definitions) + .map_err(|e| StorageError::Internal(format!("Parse attributes: {e}")))?; + let billing_mode = if backup.billing_mode == "PAY_PER_REQUEST" { + Some(BillingMode::PayPerRequest) + } else { + Some(BillingMode::Provisioned) + }; + let mut provisioned_throughput: ProvisionedThroughput = backup + .provisioned_throughput + .as_deref() + .map(serde_json::from_str) + .transpose() + .map_err(|e| StorageError::Internal(format!("Parse throughput: {e}")))? + .unwrap_or(ProvisionedThroughput { + read_capacity_units: 5, + write_capacity_units: 5, + }); + if provisioned_throughput.read_capacity_units <= 0 { + provisioned_throughput.read_capacity_units = 5; + } + if provisioned_throughput.write_capacity_units <= 0 { + provisioned_throughput.write_capacity_units = 5; + } + let stream_specification = backup + .stream_specification + .as_deref() + .map(serde_json::from_str) + .transpose() + .map_err(|e| StorageError::Internal(format!("Parse stream: {e}")))?; + + let create_input = CreateTableInput { + table_name: target_table_name.clone(), + key_schema: key_schema.clone(), + attribute_definitions: attribute_definitions.clone(), + billing_mode, + provisioned_throughput: Some(provisioned_throughput), + global_secondary_indexes: None, + local_secondary_indexes: None, + stream_specification, + tags: None, + deletion_protection_enabled: None, + sse_specification: None, + table_class: None, + on_demand_throughput: None, + vector_indexes: None, + }; + let description = self + .create_table_for_restore_impl(&account_id, create_input) + .await?; + let key_info = TableKeyInfo { + table_name: target_table_name.clone(), + account_id: account_id.clone(), + table_id: description.table_id.clone(), + key_schema: key_schema.clone(), + base_key_schema: key_schema, + attribute_definitions, + has_lsi: false, + global_secondary_indexes: Vec::new(), + local_secondary_indexes: Vec::new(), + stream_specification: description.stream_specification.clone(), + vector_indexes: Vec::new(), + }; + + let restore_result: Result<(i64, i64), StorageError> = async { + let account_keyspace = self.account_keyspace(&account_id); + let query = format!( + "SELECT item_data FROM {}.backup_items WHERE backup_arn = ? AND bucket = ?", + account_keyspace + ); + let mut restored_count = 0_i64; + let mut restored_size = 0_i64; + for bucket in 0..bucket_count(backup.item_count) { + let rows = self + .session + .query_with_values( + &query, + cdrs_tokio::query_values!(backup_arn.as_str(), bucket as i32), + ) + .await + .map_err(|e| StorageError::Internal(format!("Read backup payload: {e}")))? + .response_body() + .map_err(|e| StorageError::Internal(format!("Parse backup payload: {e}")))? + .into_rows() + .unwrap_or_default(); + for row in rows { + let item_data: String = row.get_r_by_name("item_data").map_err(|e| { + StorageError::Internal(format!("Parse backup item: {e}")) + })?; + let item = serde_json::from_str(&item_data).map_err(|e| { + StorageError::Internal(format!("Deserialize backup item: {e}")) + })?; + self.put_item_impl(&key_info, item, false, None, &Default::default(), None) + .await?; + restored_count += 1; + restored_size = restored_size.saturating_add(item_data.len() as i64); + } + } + if restored_count != backup.item_count { + return Err(StorageError::Internal(format!( + "Backup item count mismatch: expected {}, restored {}", + backup.item_count, restored_count + ))); + } + Ok((restored_count, restored_size)) + } + .await; + + let completion = match restore_result { + Ok((restored_count, restored_size)) => { + self.mark_table_active_after_restore( + &account_id, + &target_table_name, + &description.table_id, + restored_count, + backup.backup_size_bytes.max(restored_size), + ) + .await + } + Err(error) => Err(error), + }; + + match completion { + Ok(()) => Ok(description), + Err(error) => { + let _ = self + .delete_table_impl( + &account_id, + extenddb_core::types::DeleteTableInput { + table_name: target_table_name, + }, + ) + .await; + Err(error) + } + } + }) + } + + fn describe_continuous_backups( + &self, + account_id: &str, + table_name: &str, + ) -> BoxFuture<'_, Result> { + let account_id = account_id.to_owned(); + let table_name = table_name.to_owned(); + Box::pin(async move { + if !self + .table_exists_for_backup(&account_id, &table_name) + .await? + { + return Err(StorageError::TableNotFound(format!( + "Table not found: {table_name}" + ))); + } + let query = format!( + "SELECT pitr_enabled FROM {}.continuous_backups WHERE account_id = ? AND table_name = ?", + self.catalog_keyspace() + ); + let rows = self + .session + .query_with_values( + &query, + cdrs_tokio::query_values!(account_id.as_str(), table_name.as_str()), + ) + .await + .map_err(|e| StorageError::Internal(format!("Query continuous backups: {e}")))? + .response_body() + .map_err(|e| StorageError::Internal(format!("Parse continuous backups: {e}")))? + .into_rows() + .unwrap_or_default(); + let pitr_enabled = rows + .first() + .and_then(|row| { + let value: Result = row.get_r_by_name("pitr_enabled"); + value.ok() + }) + .unwrap_or(false); + let now = epoch_millis() as f64 / 1_000.0; + Ok(ContinuousBackupsDescription { + continuous_backups_status: "ENABLED".to_owned(), + point_in_time_recovery_description: Some(PointInTimeRecoveryDescription { + point_in_time_recovery_status: if pitr_enabled { + "ENABLED".to_owned() + } else { + "DISABLED".to_owned() + }, + earliest_restorable_date_time: pitr_enabled + .then_some(now - 35.0 * 24.0 * 3_600.0), + latest_restorable_date_time: pitr_enabled.then_some(now), + }), + }) + }) + } + + fn update_continuous_backups( + &self, + account_id: &str, + table_name: &str, + pitr_enabled: bool, + ) -> BoxFuture<'_, Result> { + let account_id = account_id.to_owned(); + let table_name = table_name.to_owned(); + Box::pin(async move { + if !self + .table_exists_for_backup(&account_id, &table_name) + .await? + { + return Err(StorageError::TableNotFound(format!( + "Table not found: {table_name}" + ))); + } + let query = format!( + "INSERT INTO {}.continuous_backups (account_id, table_name, pitr_enabled) VALUES (?, ?, ?)", + self.catalog_keyspace() + ); + self.session + .query_with_values( + &query, + cdrs_tokio::query_values!( + account_id.as_str(), + table_name.as_str(), + pitr_enabled + ), + ) + .await + .map_err(|e| StorageError::Internal(format!("Update continuous backups: {e}")))?; + self.describe_continuous_backups(&account_id, &table_name) + .await + }) + } + + fn restore_table_to_point_in_time( + &self, + _account_id: &str, + _source_table_name: &str, + _target_table_name: &str, + ) -> BoxFuture<'_, Result> { + Box::pin(async move { + Err(StorageError::Validation( + "Point-in-time recovery restore is not yet supported".to_owned(), + )) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn backup_identifier_has_timestamp_and_random_suffix() { + let id = backup_id(); + let (timestamp, suffix) = id.split_once('-').expect("backup id separator"); + assert!(!timestamp.is_empty()); + assert!(timestamp.chars().all(|ch| ch.is_ascii_digit())); + assert_eq!(suffix.len(), 8); + assert!(suffix.chars().all(|ch| ch.is_ascii_hexdigit())); + } + + #[test] + fn item_buckets_are_bounded() { + assert_eq!(bucket_count(0), 0); + assert_eq!(bucket_count(1), 1); + assert_eq!(bucket_count(BACKUP_ITEMS_PER_BUCKET), 1); + assert_eq!(bucket_count(BACKUP_ITEMS_PER_BUCKET + 1), 2); + } +} diff --git a/crates/storage-cassandra/src/bootstrapper.rs b/crates/storage-cassandra/src/bootstrapper.rs new file mode 100644 index 00000000..0bfd2779 --- /dev/null +++ b/crates/storage-cassandra/src/bootstrapper.rs @@ -0,0 +1,723 @@ +// Copyright 2026 ExtendDB Contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Cassandra implementation of `Bootstrapper`. + +use async_trait::async_trait; +use cdrs_tokio::TryFromRow; +use cdrs_tokio::frame::TryFromRow as TryFromRowTrait; +use extenddb_storage::bootstrapper::{ + AdminBootstrapResult, BootstrapConfig, Bootstrapper, + helpers::{ + check_conflict, extract_arg, generate_account_id, generate_encryption_key, + generate_random_password, hash_password_async, + }, +}; +use extenddb_storage::management_store::{OpError, OpResult}; + +use crate::config::CassandraStorageConfig; +use crate::engine::CassandraEngine; +use crate::migrations; + +const CATALOG_VERSION: &str = "0.0.3"; + +// Helper structs for parsing Cassandra rows +#[derive(Debug, Clone, TryFromRow)] +struct TableNameRow { + table_name: String, +} + +#[derive(Debug, Clone, TryFromRow)] +struct KeyspaceNameRow { + keyspace_name: String, +} + +#[derive(Debug, Clone, TryFromRow)] +struct SettingRow { + value: String, +} + +/// Cassandra bootstrapper for init/destroy/migrate operations. +pub struct CassandraBootstrapper { + engine: CassandraEngine, + config: BootstrapConfig, +} + +impl CassandraBootstrapper { + /// Create a new bootstrapper from Cassandra config. + pub async fn new( + cassandra_config: &CassandraStorageConfig, + bootstrap_config: BootstrapConfig, + ) -> OpResult { + let engine = CassandraEngine::new(cassandra_config, "us-east-1") + .await + .map_err(|e| OpError::Internal(format!("Create engine: {e}")))?; + + Ok(Self { + engine, + config: bootstrap_config, + }) + } + + /// Create a bootstrapper from config file and CLI args. Parses + /// Cassandra-specific arguments and merges with config. + pub async fn from_config( + config_path: &str, + cli_args: &[String], + ) -> Result { + use extenddb_storage::error::StorageError; + + // Extract Cassandra-specific CLI args + let cassandra_contact_points = extract_arg(cli_args, "--cassandra-contact-points"); + let cassandra_user = extract_arg(cli_args, "--cassandra-user"); + let cassandra_pass = extract_arg(cli_args, "--cassandra-pass"); + let keyspace_prefix = extract_arg(cli_args, "--keyspace-prefix"); + let replication_factor = extract_arg(cli_args, "--replication-factor"); + let extenddb_user = extract_arg(cli_args, "--extenddb-user"); + let extenddb_pass = extract_arg(cli_args, "--extenddb-pass"); + + // Load config file if it exists + let (contact_points, user, password, prefix, rf_from_config) = if std::path::Path::new( + config_path, + ) + .exists() + { + println!("--- Loading defaults from {}", config_path); + + // Parse Cassandra config from file + let config_content = std::fs::read_to_string(config_path) + .map_err(|e| StorageError::Internal(format!("Failed to read config: {e}")))?; + let app_config: toml::Value = toml::from_str(&config_content) + .map_err(|e| StorageError::Internal(format!("Failed to parse config: {e}")))?; + + let cassandra_config = app_config + .get("storage") + .and_then(|s| s.get("cassandra")) + .ok_or_else(|| { + StorageError::Internal("Missing storage.cassandra section".into()) + })?; + + let config: CassandraStorageConfig = + cassandra_config + .clone() + .try_into() + .map_err(|e: toml::de::Error| { + StorageError::Internal(format!("Invalid cassandra config: {e}")) + })?; + + // Check for conflicts between CLI args and config values + if let Some(ref cli_cp) = cassandra_contact_points { + let cli_list: Vec<&str> = cli_cp.split(',').collect(); + let config_list: Vec<&str> = + config.contact_points.iter().map(|s| s.as_str()).collect(); + if cli_list != config_list { + return Err(StorageError::Internal(format!( + "--cassandra-contact-points '{}' conflicts with config file contact points '{}'", + cli_cp, + config.contact_points.join(",") + ))); + } + } + + check_conflict( + extenddb_user.as_ref(), + config.username.as_ref().unwrap_or(&"extenddb".to_string()), + "--extenddb-user", + )?; + check_conflict( + extenddb_pass.as_ref(), + config + .password + .as_ref() + .unwrap_or(&"extenddb-local-dev".to_string()), + "--extenddb-pass", + )?; + + if let Some(ref cli_prefix) = keyspace_prefix { + if cli_prefix != &config.keyspace_prefix { + return Err(StorageError::Internal(format!( + "--keyspace-prefix '{}' conflicts with config file keyspace prefix '{}'", + cli_prefix, config.keyspace_prefix + ))); + } + } + + if let Some(ref cli_rf) = replication_factor { + let cli_rf_val = cli_rf.parse::().map_err(|_| { + StorageError::Internal(format!( + "Invalid --replication-factor '{}': must be a positive integer", + cli_rf + )) + })?; + if cli_rf_val != config.replication_factor { + return Err(StorageError::Internal(format!( + "--replication-factor {} conflicts with config file replication_factor {}", + cli_rf_val, config.replication_factor + ))); + } + } + + ( + config.contact_points, + config.username.unwrap_or_else(|| "extenddb".to_string()), + config + .password + .unwrap_or_else(|| "extenddb-local-dev".to_string()), + config.keyspace_prefix, + config.replication_factor, + ) + } else { + // No config file - use defaults (single-node dev environment) + ( + vec!["localhost:9042".to_string()], + "extenddb".to_string(), + "extenddb-local-dev".to_string(), + "extenddb".to_string(), + 1, // RF=1 for single-node dev + ) + }; + + // CLI args override config (or use config values if no CLI arg provided) + let resolved_contact_points = cassandra_contact_points + .map(|cp| cp.split(',').map(|s| s.to_string()).collect()) + .unwrap_or(contact_points); + let resolved_admin_user = cassandra_user + .unwrap_or_else(|| std::env::var("USER").unwrap_or_else(|_| "cassandra".to_owned())); + let resolved_keyspace_prefix = keyspace_prefix.unwrap_or(prefix); + let resolved_replication_factor = replication_factor + .map(|rf| rf.parse::().unwrap_or(1)) + .unwrap_or(rf_from_config); + let resolved_app_user = extenddb_user.unwrap_or(user); + let resolved_app_password = extenddb_pass.unwrap_or(password); + + // Extract host and port from first contact point + let (host, port) = resolved_contact_points + .first() + .and_then(|cp| { + let parts: Vec<&str> = cp.split(':').collect(); + if parts.len() == 2 { + Some((parts[0].to_string(), parts[1].parse().ok()?)) + } else { + None + } + }) + .unwrap_or_else(|| ("localhost".to_string(), 9042)); + + let bootstrap_config = BootstrapConfig { + host, + port, + admin_user: resolved_admin_user.clone(), + admin_password: cassandra_pass.clone(), + app_user: resolved_app_user, + app_password: resolved_app_password, + catalog_db: format!("{}_catalog", resolved_keyspace_prefix), + data_db: String::new(), // Not used for Cassandra + }; + + // For bootstrap operations, connect as admin user + let mut cassandra_config = CassandraStorageConfig { + contact_points: resolved_contact_points, + username: Some(resolved_admin_user), + password: cassandra_pass, + keyspace_prefix: resolved_keyspace_prefix, + replication_factor: resolved_replication_factor, + datacenter: "datacenter1".to_string(), + max_connections: 10, + cached_connection_string: None, + instance_id: None, + }; + cassandra_config.ensure_cached_connection_string(); + + Self::new(&cassandra_config, bootstrap_config) + .await + .map_err(|e| StorageError::Internal(format!("{e:?}"))) + } +} + +#[async_trait] +impl Bootstrapper for CassandraBootstrapper { + async fn ensure_app_user(&self) -> OpResult<()> { + let user = &self.config.app_user; + let password = &self.config.app_password; + + println!("--- Ensuring application user '{user}' exists..."); + + // Check if user exists + let check_cql = "SELECT role FROM system_auth.roles WHERE role = ?"; + let exists = self + .engine + .session() + .query_with_values(check_cql, cdrs_tokio::query_values!(user.as_str())) + .await + .ok() + .and_then(|frame| frame.response_body().ok()) + .and_then(|body| body.into_rows()) + .map(|rows| !rows.is_empty()) + .unwrap_or(false); + + if exists { + println!(" User '{user}' already exists."); + return Ok(()); + } + + // Validate password character set + if !password + .chars() + .all(|c| c.is_ascii_alphanumeric() || "-_.,!@#$%^&*()+=~` ".contains(c)) + { + return Err(OpError::Validation( + "Application password contains disallowed characters. \ + Only ASCII letters, digits, and -_.,!@#$%^&*()+=~` space are permitted." + .to_owned(), + )); + } + + // Create user with password + let create_cql = format!( + "CREATE ROLE IF NOT EXISTS '{}' WITH PASSWORD = '{}' AND LOGIN = true", + user, password + ); + self.engine + .session() + .query(create_cql) + .await + .map_err(|e| OpError::Internal(format!("Create user: {}", e)))?; + + println!(" Created user '{user}'."); + Ok(()) + } + + async fn grant_app_role_to_admin(&self) -> OpResult<()> { + if self.config.admin_user == self.config.app_user { + return Ok(()); + } + + // Check if admin already has the app role + let check_cql = + "SELECT role, member FROM system_auth.role_members WHERE role = ? AND member = ?"; + let already_granted = self + .engine + .session() + .query_with_values( + check_cql, + cdrs_tokio::query_values!( + self.config.app_user.as_str(), + self.config.admin_user.as_str() + ), + ) + .await + .ok() + .and_then(|frame| frame.response_body().ok()) + .and_then(|body| body.into_rows()) + .map(|rows| !rows.is_empty()) + .unwrap_or(false); + + if already_granted { + return Ok(()); + } + + let grant_cql = format!( + "GRANT '{}' TO '{}'", + self.config.app_user, self.config.admin_user + ); + self.engine.session().query(grant_cql).await.map_err(|e| { + OpError::Internal(format!( + "Cannot grant {} to {}: {}", + self.config.app_user, self.config.admin_user, e + )) + })?; + Ok(()) + } + + async fn create_catalog_db(&self) -> OpResult<()> { + let keyspace = self.engine.catalog_keyspace(); + println!("--- Creating catalog keyspace '{}'...", keyspace); + + if self + .engine + .keyspace_exists(&keyspace) + .await + .map_err(|e| OpError::Internal(e.to_string()))? + { + return Err(OpError::AlreadyExists(format!( + "Catalog keyspace '{keyspace}' already exists. Run 'destroy' first, then re-run 'init'." + ))); + } + + self.engine + .create_keyspace(&keyspace) + .await + .map_err(|e| OpError::Internal(format!("Create catalog keyspace: {e}")))?; + + println!(" Created."); + Ok(()) + } + + async fn create_data_db(&self) -> OpResult<()> { + // Cassandra uses per-account keyspaces, not a single data keyspace + println!("--- Cassandra uses per-account keyspaces (created on demand)"); + Ok(()) + } + + async fn run_catalog_migrations(&self) -> OpResult<()> { + let keyspace = self.engine.catalog_keyspace(); + migrations::run_catalog_migrations(&self.engine.session_arc(), &keyspace).await + } + + async fn run_data_migrations(&self) -> OpResult<()> { + // Data schema is isolated per account, so upgrades must visit every + // existing account keyspace. New accounts receive the same migration + // list from ensure_account_keyspace/bootstrap_default_account. + let query = format!( + "SELECT account_id FROM {}.accounts", + self.engine.catalog_keyspace() + ); + let rows = self + .engine + .session() + .query(query) + .await + .map_err(|e| OpError::Internal(format!("List accounts for migration: {e}")))? + .response_body() + .map_err(|e| OpError::Internal(format!("Parse accounts for migration: {e}")))? + .into_rows() + .unwrap_or_default(); + + for row in rows { + let account_id: String = crate::cassandra_util::get_column::( + &row, + "account_id", + "run_data_migrations", + )?; + let keyspace = self.engine.account_keyspace(&account_id); + migrations::run_data_migrations(&self.engine.session_arc(), &keyspace).await?; + } + Ok(()) + } + + async fn pending_data_migrations(&self) -> OpResult> { + migrations::pending_data_migrations( + &self.engine.session_arc(), + &self.engine.catalog_keyspace(), + |account_id| self.engine.account_keyspace(account_id), + ) + .await + } + + async fn record_data_connection(&self) -> OpResult<()> { + // Not applicable for Cassandra (no separate data connection) + Ok(()) + } + + async fn bootstrap_encryption_key(&self) -> OpResult<()> { + let keyspace = self.engine.catalog_keyspace(); + println!("--- Generating AES-256-GCM encryption key..."); + + // Check if key already exists + let check_cql = format!( + "SELECT value FROM {}.settings WHERE key = 'encryption_key'", + keyspace + ); + let exists = self + .engine + .session() + .query(check_cql) + .await + .ok() + .and_then(|frame| frame.response_body().ok()) + .and_then(|body| body.into_rows()) + .map(|rows| !rows.is_empty()) + .unwrap_or(false); + + if exists { + println!("--- Encryption key already exists, skipping."); + return Ok(()); + } + + let key_b64 = generate_encryption_key(); + + // Store key + let insert_cql = format!( + "INSERT INTO {}.settings (key, value) VALUES (?, ?)", + keyspace + ); + self.engine + .session() + .query_with_values( + insert_cql, + cdrs_tokio::query_values!("encryption_key", key_b64), + ) + .await + .map_err(|e| OpError::Internal(format!("Store encryption key: {e}")))?; + + println!(" Encryption key stored."); + Ok(()) + } + + async fn bootstrap_default_account(&self) -> OpResult<()> { + let keyspace = self.engine.catalog_keyspace(); + + // Check if any accounts exist + let check_cql = format!("SELECT account_id FROM {}.accounts LIMIT 1", keyspace); + let has_accounts = self + .engine + .session() + .query(check_cql) + .await + .ok() + .and_then(|frame| frame.response_body().ok()) + .and_then(|body| body.into_rows()) + .map(|rows| !rows.is_empty()) + .unwrap_or(false); + + if has_accounts { + println!("--- Default account already exists, skipping."); + return Ok(()); + } + + // Create default account + let account_id = generate_account_id(); + println!("--- Creating default account '{account_id}'..."); + let account_name = "default"; + + let insert_cql = format!( + "INSERT INTO {}.accounts (account_id, account_name, created_at) VALUES (?, ?, toTimestamp(now()))", + keyspace + ); + + self.engine + .session() + .query_with_values( + insert_cql, + cdrs_tokio::query_values!(account_id.as_str(), account_name), + ) + .await + .map_err(|e| OpError::Internal(format!("Create account: {e}")))?; + + println!(" Account ID: {account_id}"); + + // Create account-specific keyspace + let account_keyspace = self.engine.account_keyspace(&account_id); + println!("--- Creating account keyspace '{account_keyspace}'..."); + self.engine + .create_keyspace(&account_keyspace) + .await + .map_err(|e| OpError::Internal(format!("Create account keyspace: {e}")))?; + + // Run data migrations in account keyspace + println!("--- Running data migrations for account '{account_id}'..."); + migrations::run_data_migrations(&self.engine.session_arc(), &account_keyspace).await?; + + Ok(()) + } + + async fn bootstrap_admin_user( + &self, + env_user: Option<&str>, + env_password: Option<&str>, + ) -> OpResult { + let keyspace = self.engine.catalog_keyspace(); + + let username = env_user.unwrap_or("admin"); + let from_env = env_user.is_some() && env_password.is_some(); + + // Check if user exists + let check_cql = format!( + "SELECT admin_name FROM {}.admin_users WHERE admin_name = ?", + keyspace + ); + let exists = self + .engine + .session() + .query_with_values(check_cql, cdrs_tokio::query_values!(username)) + .await + .ok() + .and_then(|frame| frame.response_body().ok()) + .and_then(|body| body.into_rows()) + .map(|rows| !rows.is_empty()) + .unwrap_or(false); + + if exists { + println!("--- Admin user '{username}' already exists, skipping."); + return Ok(AdminBootstrapResult { + username: username.to_string(), + generated_password: None, + already_existed: true, + from_env, + }); + } + + println!("--- Creating admin user '{username}'..."); + + // Generate or use provided password + let password = if let Some(pw) = env_password { + pw.to_string() + } else { + generate_random_password() + }; + + // Hash password (using bcrypt in blocking task to avoid blocking async runtime) + let password_hash = hash_password_async(password.clone()).await?; + + // Insert admin user + let insert_cql = format!( + "INSERT INTO {}.admin_users (admin_name, password_hash) VALUES (?, ?)", + keyspace + ); + + self.engine + .session() + .query_with_values( + insert_cql, + cdrs_tokio::query_values!(username, password_hash), + ) + .await + .map_err(|e| OpError::Internal(format!("Create admin user: {e}")))?; + + let generated_password = if from_env { None } else { Some(password) }; + + Ok(AdminBootstrapResult { + username: username.to_string(), + generated_password, + already_existed: false, + from_env, + }) + } + + async fn is_catalog_initialized(&self) -> OpResult { + let keyspace = self.engine.catalog_keyspace(); + migrations::table_exists(&self.engine.session_arc(), &keyspace, "settings").await + } + + async fn list_table_names(&self) -> OpResult> { + let keyspace = self.engine.catalog_keyspace(); + + let cql = format!( + "SELECT table_name FROM {}.tables ORDER BY table_name", + keyspace + ); + let rows = match self.engine.session().query(cql).await { + Ok(frame) => frame + .response_body() + .ok() + .and_then(|body| body.into_rows()) + .unwrap_or_default(), + Err(_) => return Ok(Vec::new()), + }; + + let table_names: Vec = rows + .into_iter() + .filter_map(|row| { + TryFromRowTrait::try_from_row(row) + .ok() + .map(|r: TableNameRow| r.table_name) + }) + .collect(); + + Ok(table_names) + } + + async fn get_data_db_name(&self) -> OpResult> { + // Cassandra doesn't have a single data database + Ok(None) + } + + async fn drop_databases(&self, _data_db: &str) -> OpResult<()> { + println!("--- Dropping all ExtendDB keyspaces..."); + + // List all keyspaces with our prefix + let prefix = format!( + "{}_", + self.engine.catalog_keyspace().trim_end_matches("_catalog") + ); + + let cql = "SELECT keyspace_name FROM system_schema.keyspaces"; + let rows = self + .engine + .session() + .query(cql) + .await + .map_err(|e| OpError::Internal(format!("List keyspaces: {e}")))? + .response_body() + .map_err(|e| OpError::Internal(format!("Get response: {e}")))? + .into_rows() + .ok_or_else(|| OpError::Internal("No rows returned".to_string()))?; + + let keyspaces: Vec = rows + .into_iter() + .filter_map(|row| { + TryFromRowTrait::try_from_row(row) + .ok() + .map(|r: KeyspaceNameRow| r.keyspace_name) + }) + .filter(|name| name.starts_with(&prefix)) + .collect(); + + for keyspace in keyspaces { + println!(" Dropping keyspace: {}", keyspace); + self.engine + .drop_keyspace(&keyspace) + .await + .map_err(|e| OpError::Internal(e.to_string()))?; + } + + println!(" All ExtendDB keyspaces dropped"); + Ok(()) + } + + async fn read_catalog_version(&self) -> OpResult> { + let keyspace = self.engine.catalog_keyspace(); + + if !migrations::table_exists(&self.engine.session_arc(), &keyspace, "settings").await? { + return Ok(None); + } + + let query = format!( + "SELECT value FROM {}.settings WHERE key = 'catalog_version'", + keyspace + ); + let version = self + .engine + .session() + .query(query) + .await + .ok() + .and_then(|frame| frame.response_body().ok()) + .and_then(|body| body.into_rows()) + .and_then(|mut rows| rows.pop()) + .and_then(|row| TryFromRowTrait::try_from_row(row).ok()) + .map(|r: SettingRow| r.value); + + Ok(version) + } + + fn expected_catalog_version(&self) -> String { + CATALOG_VERSION.to_string() + } + + fn catalog_database_name(&self) -> String { + self.engine.catalog_keyspace() + } + + fn endpoint_info(&self) -> String { + self.config.host.clone() + ":" + &self.config.port.to_string() + } + + fn catalog_connection_url(&self) -> String { + format!("{}:{}", self.config.host, self.config.port) + } + + fn generate_backend_config_section(&self) -> String { + format!( + r#"[storage.cassandra] +contact_points = ["{}"] +# username = "cassandra" # Application user +# password = "cassandra-password" # Application password +keyspace_prefix = "extenddb" +replication_factor = 1 # Single node (use 3+ for production) +datacenter = "datacenter1" +max_connections = 10"#, + self.catalog_connection_url() + ) + } +} diff --git a/crates/storage-cassandra/src/cassandra_util.rs b/crates/storage-cassandra/src/cassandra_util.rs new file mode 100644 index 00000000..77294d5e --- /dev/null +++ b/crates/storage-cassandra/src/cassandra_util.rs @@ -0,0 +1,290 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Cassandra-specific utility functions. + +use cdrs_tokio::cluster::TcpConnectionManager; +use cdrs_tokio::cluster::session::Session; +use cdrs_tokio::load_balancing::RoundRobinLoadBalancingStrategy; +use cdrs_tokio::query::QueryValues; +use cdrs_tokio::transport::TransportTcp; +use cdrs_tokio::types::rows::Row; +use std::sync::Arc; + +pub type CassandraSession = Session< + TransportTcp, + TcpConnectionManager, + RoundRobinLoadBalancingStrategy, +>; + +/// Trait for errors that can be constructed from database operation failures. +/// Implemented by OpError, StorageError, and DynamoDbError. +pub trait FromDbError: Sized { + fn db_error(msg: String) -> Self; +} + +impl FromDbError for extenddb_storage::management_store::OpError { + fn db_error(msg: String) -> Self { + extenddb_storage::management_store::OpError::Internal(msg) + } +} + +impl FromDbError for extenddb_storage::error::StorageError { + fn db_error(msg: String) -> Self { + extenddb_storage::error::StorageError::Internal(msg) + } +} + +impl FromDbError for extenddb_core::error::DynamoDbError { + fn db_error(msg: String) -> Self { + extenddb_core::error::DynamoDbError::InternalServerError(msg) + } +} + +/// Check if an error is a unique constraint violation. +/// For Cassandra, this is always false in stub implementations. +pub fn is_unique_violation(_err: &cdrs_tokio::error::Error) -> bool { + false +} + +/// Check if an error is a foreign key constraint violation. +/// For Cassandra, this is always false in stub implementations. +pub fn is_fk_violation(_err: &cdrs_tokio::error::Error) -> bool { + false +} + +// ══════════════════════════════════════════════════════════════════════════════ +// Phase 1: Core Query Helpers +// ══════════════════════════════════════════════════════════════════════════════ + +/// Execute a query and return all rows with standardized error handling. +/// +/// Generic over error type - works with OpError, StorageError, or DynamoDbError. +/// +/// # Arguments +/// * `session` - Cassandra session +/// * `query` - CQL query string +/// * `values` - Query parameters +/// * `context` - Context string for error logging (e.g., "list_access_keys") +/// +/// # Returns +/// Vec of rows, or error if query fails +pub async fn query_rows( + session: &Arc, + query: &str, + values: QueryValues, + context: &str, +) -> Result, E> { + let result = session + .query_with_values(query, values) + .await + .map_err(|e| { + tracing::error!("{context} query failed: {e}"); + E::db_error(format!("{context}: {e}")) + })?; + + let body = result.response_body().map_err(|e| { + tracing::error!("{context} response_body failed: {e}"); + E::db_error(format!("{context} response_body: {e}")) + })?; + + Ok(body.into_rows().unwrap_or_default()) +} + +/// Execute a query and return the first row, if any. +/// +/// Generic over error type - works with OpError, StorageError, or DynamoDbError. +/// Similar to sqlx::query().fetch_optional(). +/// +/// # Arguments +/// * `session` - Cassandra session +/// * `query` - CQL query string +/// * `values` - Query parameters +/// * `context` - Context string for error logging +/// +/// # Returns +/// Option, or error if query fails +pub async fn query_optional( + session: &Arc, + query: &str, + values: QueryValues, + context: &str, +) -> Result, E> { + let mut rows = query_rows(session, query, values, context).await?; + Ok(rows.drain(..).next()) +} + +/// Execute a non-query statement (INSERT/UPDATE/DELETE). +/// +/// Generic over error type - works with OpError, StorageError, or DynamoDbError. +/// +/// # Arguments +/// * `session` - Cassandra session +/// * `query` - CQL statement +/// * `values` - Query parameters +/// * `context` - Context string for error logging +/// +/// # Returns +/// Ok(()) if successful, error if query fails +pub async fn execute( + session: &Arc, + query: &str, + values: QueryValues, + context: &str, +) -> Result<(), E> { + session + .query_with_values(query, values) + .await + .map_err(|e| { + tracing::error!("{context} execute failed: {e}"); + E::db_error("Database error".to_owned()) + })?; + + Ok(()) +} + +// ══════════════════════════════════════════════════════════════════════════════ +// Type Conversion Helpers +// ══════════════════════════════════════════════════════════════════════════════ + +/// Extract a typed column value from a Row with standardized error handling. +/// +/// Generic over error type - works with OpError, StorageError, or DynamoDbError. +/// The return type T must be specified at the call site. +/// +/// # Arguments +/// * `row` - Row to extract from +/// * `column` - Column name +/// * `context` - Context string for error logging +/// +/// # Returns +/// Typed value T, or error if parsing fails +/// +/// # Example +/// ```ignore +/// let name: String = get_column(&row, "user_name", "create_user")?; +/// let count: i64 = get_column(&row, "count", "list_items")?; +/// ``` +pub fn get_column(row: &Row, column: &str, context: &str) -> Result +where + cdrs_tokio::types::rows::Row: cdrs_tokio::types::IntoRustByName, +{ + use cdrs_tokio::types::IntoRustByName; + row.get_r_by_name(column).map_err(|e| { + tracing::error!("{context} parse column '{column}': {e}"); + E::db_error("Database error".to_owned()) + }) +} + +/// Convert Cassandra timestamp (milliseconds) to OffsetDateTime. +/// +/// Generic over error type - works with OpError, StorageError, or DynamoDbError. +/// Cassandra stores timestamps as milliseconds since Unix epoch. +/// This helper divides by 1000 and converts to OffsetDateTime. +/// +/// # Arguments +/// * `millis` - Timestamp in milliseconds +/// * `context` - Context string for error logging +/// +/// # Returns +/// OffsetDateTime, or error if conversion fails +pub fn timestamp_to_datetime( + millis: i64, + context: &str, +) -> Result { + time::OffsetDateTime::from_unix_timestamp(millis / 1000).map_err(|e| { + tracing::error!("{context} convert timestamp {millis}: {e}"); + E::db_error("Database error".to_owned()) + }) +} + +/// Extract a timestamp column and convert to OffsetDateTime. +/// +/// Generic over error type - works with OpError, StorageError, or DynamoDbError. +/// Convenience helper combining get_column + timestamp_to_datetime. +/// +/// # Arguments +/// * `row` - Row to extract from +/// * `column` - Column name +/// * `context` - Context string for error logging +/// +/// # Returns +/// OffsetDateTime, or error if extraction/conversion fails +/// +/// # Example +/// ```ignore +/// let created_at = get_timestamp(&row, "created_at", "list_users")?; +/// ``` +pub fn get_timestamp( + row: &Row, + column: &str, + context: &str, +) -> Result { + let millis: i64 = get_column(row, column, context)?; + timestamp_to_datetime(millis, context) +} + +/// Map rows to Vec with a mapper function, collecting errors. +/// +/// Generic over error type - works with OpError, StorageError, or DynamoDbError. +/// Reduces boilerplate when transforming Vec to Vec. +/// +/// # Arguments +/// * `rows` - Rows to map +/// * `mapper` - Function to transform each row +/// * `context` - Context string for error logging +/// +/// # Returns +/// Vec, or error if any mapping fails +/// +/// # Example +/// ```ignore +/// let users = map_rows(rows, |row| { +/// Ok(( +/// get_column(&row, "user_name", "list_users")?, +/// get_column(&row, "email", "list_users")?, +/// )) +/// }, "list_users")?; +/// ``` +pub fn map_rows( + rows: Vec, + mapper: F, + _context: &str, +) -> Result, E> +where + F: Fn(&Row) -> Result, +{ + rows.iter().map(mapper).collect() +} + +/// Execute an LWT statement and return whether it was applied. +/// +/// Parses the `[applied]` column from the Cassandra LWT response. +/// Use for `INSERT ... IF NOT EXISTS` and `UPDATE ... IF ...` statements. +pub async fn apply_lwt( + session: &Arc, + query: &str, + values: QueryValues, + context: &str, +) -> Result { + use cdrs_tokio::types::IntoRustByName; + + let result = session + .query_with_values(query, values) + .await + .map_err(|e| { + tracing::error!("{context} lwt failed: {e}"); + E::db_error("Database error".to_owned()) + })?; + + let body = result.response_body().map_err(|e| { + tracing::error!("{context} lwt response_body failed: {e}"); + E::db_error("Database error".to_owned()) + })?; + + Ok(body + .into_rows() + .and_then(|rows| rows.into_iter().next()) + .and_then(|row| row.get_r_by_name("[applied]").ok()) + .unwrap_or(false)) +} diff --git a/crates/storage-cassandra/src/catalog_store.rs b/crates/storage-cassandra/src/catalog_store.rs new file mode 100644 index 00000000..5d9d52e7 --- /dev/null +++ b/crates/storage-cassandra/src/catalog_store.rs @@ -0,0 +1,452 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Cassandra implementations of `SettingsStore`, `MetricsStore`, and +//! `RateLimitStore`. +//! +//! `CassandraCatalogStore` wraps a Cassandra session connected to the catalog +//! keyspace and implements the operational traits defined in `extenddb_storage`. + +use std::sync::Arc; + +use cdrs_tokio::cluster::TcpConnectionManager; +use cdrs_tokio::cluster::session::Session; +use cdrs_tokio::load_balancing::RoundRobinLoadBalancingStrategy; +use cdrs_tokio::query_values; +use cdrs_tokio::transport::TransportTcp; +use cdrs_tokio::types::IntoRustByName; +use extenddb_storage::management_store::{OpError, OpResult}; +use futures::future::BoxFuture; + +type CassandraSession = Session< + TransportTcp, + TcpConnectionManager, + RoundRobinLoadBalancingStrategy, +>; + +/// Cassandra-backed catalog store for settings, metrics, and rate limiting. +/// +/// Holds a session to the catalog keyspace. Created once at startup +/// and shared (via `Arc`) across management API handlers and background workers. +pub struct CassandraCatalogStore { + session: Arc, + keyspace_prefix: String, + datacenter: String, + replication_factor: u32, + /// Cached encryption key (immutable after bootstrap). Avoids + /// per-request DB query on access key and assume-role operations. + encryption_key: Option>, +} + +impl CassandraCatalogStore { + /// Create a new catalog store wrapping the given session. + pub fn new( + session: Arc, + keyspace_prefix: String, + datacenter: String, + replication_factor: u32, + ) -> Self { + Self { + session, + keyspace_prefix, + datacenter, + replication_factor, + encryption_key: None, + } + } + + /// Create a new catalog store with a pre-loaded encryption key. + pub fn with_encryption_key( + session: Arc, + keyspace_prefix: String, + datacenter: String, + replication_factor: u32, + encryption_key: String, + ) -> Self { + Self { + session, + keyspace_prefix, + datacenter, + replication_factor, + encryption_key: Some(Arc::from(encryption_key.as_str())), + } + } + + /// Borrow the underlying session (escape hatch for callers not yet migrated). + pub fn session(&self) -> &Arc { + &self.session + } + + /// Get the cached encryption key. Returns `None` if not loaded at startup. + pub fn encryption_key(&self) -> Option<&Arc> { + self.encryption_key.as_ref() + } + + /// Get the catalog keyspace name. + pub(crate) fn catalog_keyspace(&self) -> String { + format!("{}_catalog", self.keyspace_prefix) + } + + /// Get the account keyspace name. + pub(crate) fn account_keyspace(&self, account_id: &str) -> String { + format!("{}_account_{}", self.keyspace_prefix, account_id) + } + + /// Ensure an account keyspace exists (idempotent). + /// Creates the keyspace with NetworkTopologyStrategy if it doesn't exist. + pub(crate) async fn ensure_account_keyspace(&self, account_id: &str) -> OpResult<()> { + let keyspace_name = self.account_keyspace(account_id); + + // Check if keyspace already exists to avoid re-running migrations on every call. + let exists_result = self.session + .query_with_values( + "SELECT keyspace_name FROM system_schema.keyspaces WHERE keyspace_name = ?", + cdrs_tokio::query_values!(keyspace_name.as_str()), + ) + .await + .ok() + .and_then(|r| r.response_body().ok()) + .map(|b| b.into_rows().unwrap_or_default().len() > 0) + .unwrap_or(false); + + if exists_result { + // Keyspace exists but may have been created by a concurrent caller that + // hasn't finished running migrations yet. Always run migrations — they are + // idempotent (CREATE TABLE IF NOT EXISTS) so re-running is safe. + return crate::migrations::run_data_migrations(&self.session, &keyspace_name) + .await + .map_err(|e| { + tracing::error!("Failed to run data migrations for {}: {:?}", keyspace_name, e); + OpError::Internal("Failed to initialize account storage".to_owned()) + }); + } + + let cql = format!( + "CREATE KEYSPACE IF NOT EXISTS {} WITH replication = {{'class': 'NetworkTopologyStrategy', '{}': {}}}", + keyspace_name, self.datacenter, self.replication_factor + ); + + self.session.query(cql).await.map_err(|e| { + tracing::error!("Failed to create account keyspace {}: {}", keyspace_name, e); + OpError::Internal("Failed to initialize account storage".to_owned()) + })?; + + crate::migrations::run_data_migrations(&self.session, &keyspace_name) + .await + .map_err(|e| { + tracing::error!("Failed to run data migrations for {}: {:?}", keyspace_name, e); + OpError::Internal("Failed to initialize account storage".to_owned()) + })?; + + Ok(()) + } + + /// Drop an account keyspace (idempotent). + pub(crate) async fn drop_account_keyspace(&self, account_id: &str) -> OpResult<()> { + let keyspace_name = self.account_keyspace(account_id); + let cql = format!("DROP KEYSPACE IF EXISTS {}", keyspace_name); + + self.session.query(cql).await.map_err(|e| { + tracing::error!("Failed to drop account keyspace {}: {}", keyspace_name, e); + OpError::Internal("Failed to drop account storage".to_owned()) + })?; + + Ok(()) + } + + /// Check if an account exists. Used to emulate foreign key checks. + pub(crate) async fn account_exists(&self, account_id: &str) -> Result { + let catalog_keyspace = self.catalog_keyspace(); + let query = format!( + "SELECT account_id FROM {}.accounts WHERE account_id = ?", + catalog_keyspace + ); + + let rows = crate::cassandra_util::query_rows( + &self.session, + &query, + cdrs_tokio::query_values!(account_id), + "account_exists", + ) + .await?; + + Ok(!rows.is_empty()) + } + + /// Check if a user exists. Used to emulate foreign key checks. + pub(crate) async fn user_exists( + &self, + account_id: &str, + user_name: &str, + ) -> Result { + let catalog_keyspace = self.catalog_keyspace(); + let query = format!( + "SELECT user_name FROM {}.iam_users WHERE account_id = ? AND user_name = ?", + catalog_keyspace + ); + + let rows = crate::cassandra_util::query_rows( + &self.session, + &query, + cdrs_tokio::query_values!(account_id, user_name), + "user_exists", + ) + .await?; + + Ok(!rows.is_empty()) + } +} + +// ── SettingsStore ────────────────────────────────────────────────────── + +impl extenddb_storage::management_store::SettingsStore for CassandraCatalogStore { + fn get_setting(&self, key: &str) -> BoxFuture<'_, OpResult>> { + let key = key.to_string(); + let session = self.session.clone(); + let catalog_keyspace = self.catalog_keyspace(); + Box::pin(async move { + let query = format!( + "SELECT value FROM {}.settings WHERE key = ?", + catalog_keyspace + ); + + let row = crate::cassandra_util::query_optional( + &session, + &query, + query_values!(key.as_str()), + "get_setting", + ) + .await?; + + if let Some(row) = row { + let value: String = + crate::cassandra_util::get_column(&row, "value", "get_setting")?; + Ok(Some(value)) + } else { + Ok(None) + } + }) + } + + fn set_setting(&self, key: &str, value: &str) -> BoxFuture<'_, OpResult<()>> { + let key = key.to_string(); + let value = value.to_string(); + let session = self.session.clone(); + let catalog_keyspace = self.catalog_keyspace(); + Box::pin(async move { + let query = format!( + "INSERT INTO {}.settings (key, value) VALUES (?, ?)", + catalog_keyspace + ); + + crate::cassandra_util::execute( + &session, + &query, + query_values!(key.as_str(), value.as_str()), + "set_setting", + ) + .await + }) + } + + fn list_settings(&self) -> BoxFuture<'_, OpResult>> { + let session = self.session.clone(); + let catalog_keyspace = self.catalog_keyspace(); + Box::pin(async move { + let query = format!("SELECT key, value FROM {}.settings", catalog_keyspace); + + let rows = crate::cassandra_util::query_rows( + &session, + &query, + query_values!(), + "list_settings", + ) + .await?; + + let mut settings = crate::cassandra_util::map_rows( + rows, + |row| { + use crate::cassandra_util::get_column; + Ok::<_, extenddb_storage::management_store::OpError>(( + get_column::(row, "key", "list_settings")?, + get_column::(row, "value", "list_settings")?, + )) + }, + "list_settings", + )?; + + // Sort by key for consistency with PostgreSQL + settings.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(settings) + }) + } + + fn cached_encryption_key(&self) -> Option { + self.encryption_key.as_ref().map(|k| k.to_string()) + } +} + +// ── DiagnosticsStore ─────────────────────────────────────────────────── + +impl extenddb_storage::diagnostics::DiagnosticsStore for CassandraCatalogStore { + fn count_tables(&self) -> BoxFuture<'_, extenddb_storage::diagnostics::DiagResult> { + let session = self.session.clone(); + let catalog_keyspace = self.catalog_keyspace(); + Box::pin(async move { + let query = format!("SELECT COUNT(*) FROM {}.tables", catalog_keyspace); + let result = session.query(&query).await.map_err(|e| { + extenddb_storage::diagnostics::DiagError::QueryFailed(e.to_string()) + })?; + + let body = result.response_body().map_err(|e| { + extenddb_storage::diagnostics::DiagError::QueryFailed(e.to_string()) + })?; + + if let Some(rows) = body.into_rows() { + if let Some(row) = rows.first() { + let count: i64 = row.get_r_by_name("count").map_err(|e| { + extenddb_storage::diagnostics::DiagError::QueryFailed(e.to_string()) + })?; + return Ok(count); + } + } + + Ok(0) + }) + } + + fn count_indexes(&self) -> BoxFuture<'_, extenddb_storage::diagnostics::DiagResult> { + let session = self.session.clone(); + let catalog_keyspace = self.catalog_keyspace(); + Box::pin(async move { + let query = format!("SELECT COUNT(*) FROM {}.indexes", catalog_keyspace); + let result = session.query(&query).await.map_err(|e| { + extenddb_storage::diagnostics::DiagError::QueryFailed(e.to_string()) + })?; + + let body = result.response_body().map_err(|e| { + extenddb_storage::diagnostics::DiagError::QueryFailed(e.to_string()) + })?; + + if let Some(rows) = body.into_rows() { + if let Some(row) = rows.first() { + let count: i64 = row.get_r_by_name("count").map_err(|e| { + extenddb_storage::diagnostics::DiagError::QueryFailed(e.to_string()) + })?; + return Ok(count); + } + } + + Ok(0) + }) + } + + fn test_data_database_connection( + &self, + ) -> BoxFuture<'_, extenddb_storage::diagnostics::DiagResult> { + let session = self.session.clone(); + let catalog_keyspace = self.catalog_keyspace(); + let keyspace_prefix = self.keyspace_prefix.clone(); + Box::pin(async move { + // For Cassandra, we test connection to account keyspaces + // Get a sample account keyspace name from accounts table + let query = format!( + "SELECT account_id FROM {}.accounts LIMIT 1", + catalog_keyspace + ); + let result = session.query(&query).await.map_err(|e| { + extenddb_storage::diagnostics::DiagError::QueryFailed(format!( + "Failed to query accounts: {}", + e + )) + })?; + + let body = result.response_body().map_err(|e| { + extenddb_storage::diagnostics::DiagError::QueryFailed(e.to_string()) + })?; + + if let Some(rows) = body.into_rows() { + if let Some(row) = rows.first() { + let account_id: String = row.get_r_by_name("account_id").map_err(|e| { + extenddb_storage::diagnostics::DiagError::QueryFailed(e.to_string()) + })?; + + // Test connection to account keyspace by querying schema_history + let account_keyspace = format!("{}_account_{}", keyspace_prefix, account_id); + let test_query = + format!("SELECT COUNT(*) FROM {}.schema_history", account_keyspace); + + session.query(&test_query).await.map_err(|e| { + extenddb_storage::diagnostics::DiagError::ConnectionFailed(format!( + "Failed to query account keyspace {}: {}", + account_keyspace, e + )) + })?; + + return Ok(account_keyspace); + } + } + + // No accounts exist yet - that's okay, just return a message + Ok("No account keyspaces exist yet".to_string()) + }) + } +} + +// ── Stub implementations for remaining catalog traits ────────────────── + +use extenddb_storage::management_store::{MetricsStore, RateLimitStore}; + +impl RateLimitStore for CassandraCatalogStore { + fn count_principal_failures( + &self, + _principal: &str, + _window_seconds: i64, + ) -> BoxFuture<'_, OpResult> { + Box::pin(async move { Ok(0) }) + } + + fn count_ip_failures( + &self, + _source_ip: &str, + _window_seconds: i64, + ) -> BoxFuture<'_, OpResult> { + Box::pin(async move { Ok(0) }) + } + + fn record_failed_login(&self, _principal: &str, _source_ip: Option<&str>) -> BoxFuture<'_, ()> { + Box::pin(async move {}) + } + + fn cleanup_old_attempts(&self, _max_age_seconds: i64) -> BoxFuture<'_, ()> { + Box::pin(async move {}) + } +} + +use extenddb_storage::management_store::MetricsRow; + +impl MetricsStore for CassandraCatalogStore { + fn insert_metrics(&self, _rows: &[MetricsRow]) -> BoxFuture<'_, OpResult<()>> { + Box::pin(async move { Ok(()) }) + } + + fn query_metrics( + &self, + _start: time::OffsetDateTime, + _end: time::OffsetDateTime, + _table_name: Option<&str>, + _metric: Option<&str>, + ) -> BoxFuture<'_, OpResult>> { + Box::pin(async move { Ok(vec![]) }) + } + + fn prune_metrics(&self, _retention: std::time::Duration) -> BoxFuture<'_, OpResult<()>> { + Box::pin(async move { Ok(()) }) + } +} + +impl extenddb_storage::CatalogStore for CassandraCatalogStore { + fn cached_encryption_key(&self) -> Option { + self.encryption_key.as_ref().map(|k| k.to_string()) + } +} diff --git a/crates/storage-cassandra/src/config.rs b/crates/storage-cassandra/src/config.rs new file mode 100644 index 00000000..f63ab5aa --- /dev/null +++ b/crates/storage-cassandra/src/config.rs @@ -0,0 +1,313 @@ +// Copyright 2026 ExtendDB Contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Cassandra storage backend configuration. + +use serde::Deserialize; + +/// Cassandra storage backend configuration. +#[derive(Debug, Clone, Deserialize)] +pub struct CassandraStorageConfig { + /// Cassandra contact points (host:port) + pub contact_points: Vec, + + /// Username for authentication + pub username: Option, + + /// Password for authentication + pub password: Option, + + /// Keyspace prefix (default: "extenddb") + #[serde(default = "default_keyspace_prefix")] + pub keyspace_prefix: String, + + /// Replication factor for keyspaces (default: 3) + #[serde(default = "default_replication_factor")] + pub replication_factor: u32, + + /// Datacenter name for NetworkTopologyStrategy (default: "datacenter1") + #[serde(default = "default_datacenter")] + pub datacenter: String, + + /// Maximum connections per host (default: 10) + #[serde(default = "default_max_connections")] + pub max_connections: u32, + + /// Cached connection string (JDBC-style: host1,host2/keyspace_prefix) + /// This is computed after deserialization and cached for connection_config() + #[serde(skip)] + pub cached_connection_string: Option, + + /// Stable identifier for this server instance, set by the server before + /// constructing the engine. Used to derive the HLC node ID. + #[serde(skip)] + pub instance_id: Option, +} + +fn default_keyspace_prefix() -> String { + "extenddb".to_string() +} + +fn default_replication_factor() -> u32 { + 3 +} + +fn default_datacenter() -> String { + "datacenter1".to_string() +} + +fn default_max_connections() -> u32 { + 10 +} + +impl CassandraStorageConfig { + /// Build the connection string in JDBC-style format: host1,host2/keyspace_prefix + fn build_connection_string(&self) -> String { + let hosts = self.contact_points.join(","); + format!("{}/{}", hosts, self.keyspace_prefix) + } + + /// Ensure the cached connection string is populated. + /// Call this after deserialization or construction. + pub fn ensure_cached_connection_string(&mut self) { + if self.cached_connection_string.is_none() { + self.cached_connection_string = Some(self.build_connection_string()); + } + } + + /// Rebuild the cached connection string. + /// Use this after modifying contact_points or keyspace_prefix. + pub fn rebuild_cached_connection_string(&mut self) { + self.cached_connection_string = Some(self.build_connection_string()); + } + + /// Parse a JDBC-style connection string into contact points and keyspace prefix. + /// Format: "host1:port1,host2:port2/keyspace_prefix" + /// Returns (contact_points, keyspace_prefix) + pub fn parse_connection_string(conn_str: &str) -> (Vec, String) { + if let Some((hosts, keyspace)) = conn_str.split_once('/') { + let contact_points = hosts.split(',').map(|s| s.trim().to_string()).collect(); + (contact_points, keyspace.trim().to_string()) + } else { + // No keyspace specified, use default + let contact_points = conn_str.split(',').map(|s| s.trim().to_string()).collect(); + (contact_points, default_keyspace_prefix()) + } + } + + /// Create default config programmatically + pub fn new(contact_points: Vec) -> Self { + let mut config = Self { + contact_points, + username: None, + password: None, + keyspace_prefix: default_keyspace_prefix(), + replication_factor: default_replication_factor(), + datacenter: default_datacenter(), + max_connections: default_max_connections(), + cached_connection_string: None, + instance_id: None, + }; + config.ensure_cached_connection_string(); + config + } +} + +impl extenddb_storage::config::StorageConfig for CassandraStorageConfig { + fn connection_config(&self) -> &str { + // Return JDBC-style connection string: host1,host2/keyspace_prefix + // This allows factories to parse both contact points and keyspace + self.cached_connection_string + .as_ref() + .map(|s| s.as_str()) + .unwrap_or("(no connection string)") + } + + fn max_connections(&self) -> u32 { + self.max_connections + } + + fn max_catalog_connections(&self) -> u32 { + // Cassandra uses same connection pool for catalog and data + self.max_connections + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } + + fn set_instance_id(&mut self, instance_id: &str) { + self.instance_id = Some(instance_id.to_string()); + } + + fn instance_id(&self) -> Option<&str> { + self.instance_id.as_deref() + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + use extenddb_storage::config::StorageConfig; + + #[test] + fn test_config_defaults() { + let config = CassandraStorageConfig::new(vec!["localhost:9042".to_string()]); + + assert_eq!(config.keyspace_prefix, "extenddb"); + assert_eq!(config.replication_factor, 3); + assert_eq!(config.datacenter, "datacenter1"); + assert_eq!(config.max_connections, 10); + assert_eq!(config.username, None); + assert_eq!(config.password, None); + } + + #[test] + fn test_connection_config() { + let config = + CassandraStorageConfig::new(vec!["host1:9042".to_string(), "host2:9042".to_string()]); + + // Should return JDBC-style connection string with keyspace + assert_eq!(config.connection_config(), "host1:9042,host2:9042/extenddb"); + } + + #[test] + fn test_connection_config_empty() { + let config = CassandraStorageConfig::new(vec![]); + // Empty contact points should still include keyspace + assert_eq!(config.connection_config(), "/extenddb"); + } + + #[test] + fn test_toml_deserialization() { + let toml_str = r#" + contact_points = ["host1:9042", "host2:9042"] + username = "cassandra" + password = "secret" + keyspace_prefix = "myapp" + replication_factor = 5 + datacenter = "dc1" + max_connections = 20 + "#; + + let config: CassandraStorageConfig = toml::from_str(toml_str).unwrap(); + + assert_eq!(config.contact_points, vec!["host1:9042", "host2:9042"]); + assert_eq!(config.username, Some("cassandra".to_string())); + assert_eq!(config.password, Some("secret".to_string())); + assert_eq!(config.keyspace_prefix, "myapp"); + assert_eq!(config.replication_factor, 5); + assert_eq!(config.datacenter, "dc1"); + assert_eq!(config.max_connections, 20); + } + + #[test] + fn test_toml_deserialization_minimal() { + let toml_str = r#" + contact_points = ["localhost:9042"] + "#; + + let config: CassandraStorageConfig = toml::from_str(toml_str).unwrap(); + + // Verify defaults are applied + assert_eq!(config.keyspace_prefix, "extenddb"); + assert_eq!(config.replication_factor, 3); + assert_eq!(config.datacenter, "datacenter1"); + assert_eq!(config.max_connections, 10); + assert_eq!(config.username, None); + assert_eq!(config.password, None); + } + + #[test] + fn test_build_connection_string() { + let mut config = CassandraStorageConfig::new(vec![ + "127.0.0.1:9042".to_string(), + "127.0.0.2:9042".to_string(), + ]); + config.keyspace_prefix = "my_keyspace".to_string(); + config.rebuild_cached_connection_string(); + + assert_eq!( + config.cached_connection_string.as_ref().unwrap(), + "127.0.0.1:9042,127.0.0.2:9042/my_keyspace" + ); + } + + #[test] + fn test_build_connection_string_default_keyspace() { + let config = CassandraStorageConfig::new(vec!["localhost:9042".to_string()]); + + assert_eq!( + config.cached_connection_string.as_ref().unwrap(), + "localhost:9042/extenddb" + ); + } + + #[test] + fn test_parse_connection_string_with_keyspace() { + let (contact_points, keyspace) = CassandraStorageConfig::parse_connection_string( + "127.0.0.1:9042,127.0.0.2:9042/my_keyspace", + ); + + assert_eq!(contact_points.len(), 2); + assert_eq!(contact_points[0], "127.0.0.1:9042"); + assert_eq!(contact_points[1], "127.0.0.2:9042"); + assert_eq!(keyspace, "my_keyspace"); + } + + #[test] + fn test_parse_connection_string_without_keyspace() { + let (contact_points, keyspace) = + CassandraStorageConfig::parse_connection_string("127.0.0.1:9042,127.0.0.2:9042"); + + assert_eq!(contact_points.len(), 2); + assert_eq!(contact_points[0], "127.0.0.1:9042"); + assert_eq!(contact_points[1], "127.0.0.2:9042"); + assert_eq!(keyspace, "extenddb"); // Default + } + + #[test] + fn test_parse_connection_string_single_host() { + let (contact_points, keyspace) = + CassandraStorageConfig::parse_connection_string("localhost:9042/test_keyspace"); + + assert_eq!(contact_points.len(), 1); + assert_eq!(contact_points[0], "localhost:9042"); + assert_eq!(keyspace, "test_keyspace"); + } + + #[test] + fn test_parse_connection_string_with_whitespace() { + let (contact_points, keyspace) = CassandraStorageConfig::parse_connection_string( + " 127.0.0.1:9042 , 127.0.0.2:9042 / my_keyspace ", + ); + + assert_eq!(contact_points.len(), 2); + assert_eq!(contact_points[0], "127.0.0.1:9042"); + assert_eq!(contact_points[1], "127.0.0.2:9042"); + assert_eq!(keyspace, "my_keyspace"); + } + + #[test] + fn test_roundtrip_connection_string() { + // Build a connection string + let mut config = + CassandraStorageConfig::new(vec!["host1:9042".to_string(), "host2:9042".to_string()]); + config.keyspace_prefix = "test_ks".to_string(); + config.rebuild_cached_connection_string(); + + let conn_str = config.cached_connection_string.unwrap(); + + // Parse it back + let (contact_points, keyspace) = CassandraStorageConfig::parse_connection_string(&conn_str); + + assert_eq!(contact_points.len(), 2); + assert_eq!(contact_points[0], "host1:9042"); + assert_eq!(contact_points[1], "host2:9042"); + assert_eq!(keyspace, "test_ks"); + } +} diff --git a/crates/storage-cassandra/src/create_table.rs b/crates/storage-cassandra/src/create_table.rs new file mode 100644 index 00000000..76f8f9d9 --- /dev/null +++ b/crates/storage-cassandra/src/create_table.rs @@ -0,0 +1,508 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! `create_table` implementation for `CassandraEngine`. + +use cdrs_tokio::types::value::Value; +use extenddb_core::types::{ + BillingMode, BillingModeSummary, CreateTableInput, GsiDescription, LsiDescription, + ProvisionedThroughputDescription, TableDescription, TableStatus, +}; +use extenddb_storage::error::StorageError; +use extenddb_storage::util::{index_arn, stream_arn, table_arn}; + +use crate::CassandraEngine; + +impl CassandraEngine { + /// Initialize stream shards for a table atomically. + /// + /// Writes 4 shard rows into the account keyspace and updates + /// `catalog.tables.stream_label` in a single LOGGED BATCH. + /// Returns the stream label (ISO 8601 timestamp). + pub(crate) async fn init_stream_shards( + &self, + account_id: &str, + table_name: &str, + account_keyspace: &str, + table_id: &str, + ) -> Result { + let label = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S").to_string(); + let catalog_keyspace = self.catalog_keyspace(); + let now_ms = chrono::Utc::now().timestamp_millis(); + + let mut statements = Vec::new(); + + // Update stream_label in catalog + statements.push(format!( + "UPDATE {catalog_keyspace}.tables SET stream_label = '{label}' \ + WHERE account_id = '{account_id}' AND table_name = '{table_name}'" + )); + + // Insert 4 shard rows into account keyspace + for i in 0..crate::stream_util::SHARDS_PER_STREAM { + let shard_id = format!("shardId-{table_id}-{i:012}"); + statements.push(format!( + "INSERT INTO {account_keyspace}.stream_shards \ + (shard_id, table_id, starting_sequence_number, created_at) \ + VALUES ('{shard_id}', '{table_id}', '{}', {now_ms})", + crate::stream_util::ZERO_SEQUENCE + )); + } + + let batch = format!("BEGIN BATCH\n{}\nAPPLY BATCH", statements.join(";\n")); + // Note: values are interpolated rather than bound because Cassandra LOGGED BATCH + // does not support parameterized statements spanning multiple tables. + // All interpolated values are server-generated (UUIDs, timestamps, label from chrono). + self.session + .query(&batch) + .await + .map_err(|e| { + tracing::error!("init_stream_shards batch: {e}"); + StorageError::Internal(format!("Failed to initialize stream shards: {e}")) + })?; + + Ok(label) + } + + /// Core implementation of `create_table` with the normal control-plane transition. + pub(crate) async fn create_table_impl( + &self, + account_id: &str, + input: CreateTableInput, + ) -> Result { + self.create_table_impl_inner(account_id, input, true).await + } + + /// Create an inaccessible restore target that must be activated explicitly + /// after all backup items have been copied and verified. + pub(crate) async fn create_table_for_restore_impl( + &self, + account_id: &str, + input: CreateTableInput, + ) -> Result { + self.create_table_impl_inner(account_id, input, false).await + } + + async fn create_table_impl_inner( + &self, + account_id: &str, + input: CreateTableInput, + schedule_activation: bool, + ) -> Result { + Self::validate_account_id(account_id)?; + + let table_id = uuid::Uuid::new_v4().to_string(); + let table_arn = table_arn(&self.region, account_id, &input.table_name); + let billing_mode = input.billing_mode.unwrap_or(BillingMode::Provisioned); + + // Serialize key_schema and attribute_definitions to JSON + let key_schema_json = serde_json::to_value(&input.key_schema) + .map_err(|e| StorageError::Internal(e.to_string()))?; + let attr_defs_json = serde_json::to_value(&input.attribute_definitions) + .map_err(|e| StorageError::Internal(e.to_string()))?; + + let billing_str = match billing_mode { + BillingMode::Provisioned => "PROVISIONED", + BillingMode::PayPerRequest => "PAY_PER_REQUEST", + }; + + let pt_json = input + .provisioned_throughput + .as_ref() + .map(serde_json::to_value) + .transpose() + .map_err(|e| StorageError::Internal(e.to_string()))?; + + let stream_json = input + .stream_specification + .as_ref() + .map(serde_json::to_value) + .transpose() + .map_err(|e| StorageError::Internal(e.to_string()))?; + + let deletion_protection = input.deletion_protection_enabled.unwrap_or(false); + + // Read control_plane_delay_seconds from settings + let catalog_keyspace = self.catalog_keyspace(); + let delay_query = format!( + "SELECT value FROM {}.settings WHERE key = ?", + catalog_keyspace + ); + let delay_seconds: f64 = self + .session + .query_with_values( + &delay_query, + cdrs_tokio::query_values!("control_plane_delay_seconds"), + ) + .await + .ok() + .and_then(|frame| frame.response_body().ok()) + .and_then(|body| body.into_rows()) + .and_then(|rows| rows.first().cloned()) + .and_then(|row| { + use cdrs_tokio::types::IntoRustByName; + let value: String = row.get_r_by_name("value").ok()?; + value.parse::().ok() + }) + .unwrap_or(0.25); + + // Restore targets have no generic transition deadline; only the restore + // path may publish them after payload verification. + let (initial_status, status_transition_at) = if !schedule_activation { + ("CREATING", None) + } else if delay_seconds == 0.0 { + ("ACTIVE", None) + } else { + let transition_at = chrono::Utc::now() + + chrono::Duration::milliseconds((delay_seconds * 1000.0) as i64); + ("CREATING", Some(transition_at.timestamp_millis())) + }; + + let creation_timestamp = chrono::Utc::now().timestamp_millis(); + + // Ensure account keyspace exists + let account_keyspace = self.account_keyspace(account_id); + if !self.keyspace_exists(&account_keyspace).await? { + return Err(StorageError::Internal(format!( + "Account keyspace '{}' does not exist. Account must be provisioned first.", + account_keyspace + ))); + } + + // Insert table metadata with LWT (IF NOT EXISTS) + let insert_table_cql = format!( + "INSERT INTO {}.tables (account_id, table_name, table_id, table_arn, key_schema, \ + attribute_definitions, billing_mode, provisioned_throughput, stream_specification, \ + table_status, created_at, deletion_protection_enabled, status_transition_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) IF NOT EXISTS", + catalog_keyspace + ); + + let result = self + .session + .query_with_values( + &insert_table_cql, + cdrs_tokio::query::QueryValues::SimpleValues(vec![ + Value::from(account_id), + Value::from(input.table_name.as_str()), + Value::from(table_id.as_str()), + Value::from(table_arn.as_str()), + Value::from(key_schema_json.to_string().as_str()), + Value::from(attr_defs_json.to_string().as_str()), + Value::from(billing_str), + match pt_json.as_ref() { + Some(v) => Value::from(v.to_string().as_str()), + None => Value::NotSet, + }, + match stream_json.as_ref() { + Some(v) => Value::from(v.to_string().as_str()), + None => Value::NotSet, + }, + Value::from(initial_status), + Value::from(creation_timestamp), + Value::from(deletion_protection), + Value::from(status_transition_at), + ]), + ) + .await + .map_err(|e| { + tracing::error!("create_table insert table: {e}"); + StorageError::Internal(format!("Failed to insert table: {}", e)) + })?; + + // Check if LWT succeeded + let body = result + .response_body() + .map_err(|e| StorageError::Internal(format!("Failed to get response body: {}", e)))?; + + if let Some(rows) = body.into_rows() { + if let Some(row) = rows.first() { + use cdrs_tokio::types::IntoRustByName; + let applied: bool = row.get_r_by_name("[applied]").map_err(|e| { + StorageError::Internal(format!("Failed to parse [applied]: {}", e)) + })?; + + if !applied { + return Err(StorageError::TableAlreadyExists(input.table_name.clone())); + } + } + } + + // Insert GSI metadata + let mut gsi_index_ids: Vec = Vec::new(); + if let Some(gsis) = &input.global_secondary_indexes { + for gsi in gsis { + let gsi_ks = serde_json::to_value(&gsi.key_schema) + .map_err(|e| StorageError::Internal(e.to_string()))?; + let gsi_proj = serde_json::to_value(&gsi.projection) + .map_err(|e| StorageError::Internal(e.to_string()))?; + let gsi_pt = gsi + .provisioned_throughput + .as_ref() + .map(|pt| { + serde_json::to_value(ProvisionedThroughputDescription { + read_capacity_units: pt.read_capacity_units, + write_capacity_units: pt.write_capacity_units, + number_of_decreases_today: 0, + last_increase_date_time: None, + last_decrease_date_time: None, + }) + }) + .transpose() + .map_err(|e| StorageError::Internal(e.to_string()))?; + + let index_id = uuid::Uuid::new_v4().to_string(); + let insert_index_cql = format!( + "INSERT INTO {}.indexes (table_id, index_name, index_id, index_type, \ + key_schema, projection, index_status, provisioned_throughput) \ + VALUES (?, ?, ?, 'GSI', ?, ?, 'ACTIVE', ?)", + catalog_keyspace + ); + + self.session + .query_with_values( + &insert_index_cql, + cdrs_tokio::query::QueryValues::SimpleValues(vec![ + Value::from(table_id.as_str()), + Value::from(gsi.index_name.as_str()), + Value::from(index_id.as_str()), + Value::from(gsi_ks.to_string().as_str()), + Value::from(gsi_proj.to_string().as_str()), + match gsi_pt.as_ref() { + Some(v) => Value::from(v.to_string().as_str()), + None => Value::NotSet, + }, + ]), + ) + .await + .map_err(|e| { + tracing::error!("create_table insert GSI: {e}"); + StorageError::Internal(format!("Failed to insert GSI: {}", e)) + })?; + + gsi_index_ids.push(index_id); + } + } + + // Insert LSI metadata + let mut lsi_index_ids: Vec = Vec::new(); + if let Some(lsis) = &input.local_secondary_indexes { + for lsi in lsis { + let lsi_ks = serde_json::to_value(&lsi.key_schema) + .map_err(|e| StorageError::Internal(e.to_string()))?; + let lsi_proj = serde_json::to_value(&lsi.projection) + .map_err(|e| StorageError::Internal(e.to_string()))?; + + let index_id = uuid::Uuid::new_v4().to_string(); + let insert_index_cql = format!( + "INSERT INTO {}.indexes (table_id, index_name, index_id, index_type, \ + key_schema, projection, index_status, provisioned_throughput) \ + VALUES (?, ?, ?, 'LSI', ?, ?, 'ACTIVE', ?)", + catalog_keyspace + ); + + self.session + .query_with_values( + &insert_index_cql, + cdrs_tokio::query::QueryValues::SimpleValues(vec![ + Value::from(table_id.as_str()), + Value::from(lsi.index_name.as_str()), + Value::from(index_id.as_str()), + Value::from(lsi_ks.to_string().as_str()), + Value::from(lsi_proj.to_string().as_str()), + Value::NotSet, + ]), + ) + .await + .map_err(|e| { + tracing::error!("create_table insert LSI: {e}"); + StorageError::Internal(format!("Failed to insert LSI: {}", e)) + })?; + + lsi_index_ids.push(index_id); + } + } + + // Insert tags + if let Some(tags) = &input.tags { + for tag in tags { + let insert_tag_cql = format!( + "INSERT INTO {}.tags (resource_arn, tag_key, tag_value) VALUES (?, ?, ?)", + catalog_keyspace + ); + + self.session + .query_with_values( + &insert_tag_cql, + cdrs_tokio::query_values!( + table_arn.as_str(), + tag.key.as_str(), + tag.value.as_str() + ), + ) + .await + .map_err(|e| StorageError::Internal(format!("Failed to insert tag: {}", e)))?; + } + } + + // Initialize stream shards (if enabled) + let stream_label: Option = if input + .stream_specification + .as_ref() + .is_some_and(|s| s.stream_enabled) + { + Some(self.init_stream_shards(account_id, &input.table_name, &account_keyspace, &table_id).await?) + } else { + None + }; + + // Create data table in account keyspace + self.create_data_table( + &account_keyspace, + &table_id, + &input.key_schema, + &input.attribute_definitions, + ) + .await?; + + // Create GSI data tables + if let Some(gsis) = &input.global_secondary_indexes { + for (i, gsi) in gsis.iter().enumerate() { + self.create_index_data_table( + &account_keyspace, + &gsi_index_ids[i], + &gsi.key_schema, + &input.attribute_definitions, + &input.key_schema, + &input.attribute_definitions, + ) + .await?; + } + } + + // Create LSI data tables + if let Some(lsis) = &input.local_secondary_indexes { + for (i, lsi) in lsis.iter().enumerate() { + self.create_index_data_table( + &account_keyspace, + &lsi_index_ids[i], + &lsi.key_schema, + &input.attribute_definitions, + &input.key_schema, + &input.attribute_definitions, + ) + .await?; + } + } + + // Build and return TableDescription + let (rcu, wcu) = input.provisioned_throughput.as_ref().map_or((0, 0), |pt| { + (pt.read_capacity_units, pt.write_capacity_units) + }); + + let gsis = input.global_secondary_indexes.as_ref().map(|gs| { + gs.iter() + .map(|g| GsiDescription { + index_name: g.index_name.clone(), + key_schema: g.key_schema.clone(), + projection: g.projection.clone(), + index_status: "ACTIVE".to_owned(), + provisioned_throughput: Some(ProvisionedThroughputDescription { + read_capacity_units: g + .provisioned_throughput + .as_ref() + .map_or(0, |pt| pt.read_capacity_units), + write_capacity_units: g + .provisioned_throughput + .as_ref() + .map_or(0, |pt| pt.write_capacity_units), + number_of_decreases_today: 0, + last_increase_date_time: None, + last_decrease_date_time: None, + }), + index_size_bytes: 0, + item_count: 0, + index_arn: index_arn( + &self.region, + account_id, + &input.table_name, + &g.index_name, + ), + }) + .collect() + }); + + let lsis = input.local_secondary_indexes.as_ref().map(|ls| { + ls.iter() + .map(|l| LsiDescription { + index_name: l.index_name.clone(), + key_schema: l.key_schema.clone(), + projection: l.projection.clone(), + index_size_bytes: 0, + item_count: 0, + index_arn: index_arn( + &self.region, + account_id, + &input.table_name, + &l.index_name, + ), + }) + .collect() + }); + + let billing_mode_summary = if billing_mode == BillingMode::PayPerRequest { + Some(BillingModeSummary { + billing_mode: BillingMode::PayPerRequest, + last_update_to_pay_per_request_date_time: Some(creation_timestamp as f64 / 1000.0), + }) + } else { + None + }; + + let latest_stream_arn = stream_label + .as_ref() + .map(|label| stream_arn(&self.region, account_id, &input.table_name, label)); + + let response_status = if initial_status == "ACTIVE" { + TableStatus::Active + } else { + TableStatus::Creating + }; + + // Wake the control-plane poller only when a transition was scheduled. + if schedule_activation && status_transition_at.is_some() { + self.control_plane_notify.notify_one(); + } + + Ok(TableDescription { + table_name: input.table_name, + key_schema: input.key_schema, + attribute_definitions: input.attribute_definitions, + table_status: response_status, + creation_date_time: creation_timestamp as f64 / 1000.0, + table_size_bytes: 0, + item_count: 0, + table_arn, + table_id, + provisioned_throughput: ProvisionedThroughputDescription { + read_capacity_units: rcu, + write_capacity_units: wcu, + number_of_decreases_today: 0, + last_increase_date_time: None, + last_decrease_date_time: None, + }, + billing_mode_summary, + global_secondary_indexes: gsis, + local_secondary_indexes: lsis, + stream_specification: input.stream_specification, + latest_stream_arn, + latest_stream_label: stream_label, + deletion_protection_enabled: input.deletion_protection_enabled.unwrap_or(false), + sse_description: None, + table_class_summary: None, + on_demand_throughput: None, + restore_summary: None, + vector_indexes: None, + }) + } +} diff --git a/crates/storage-cassandra/src/credential_store.rs b/crates/storage-cassandra/src/credential_store.rs new file mode 100644 index 00000000..9703e8f3 --- /dev/null +++ b/crates/storage-cassandra/src/credential_store.rs @@ -0,0 +1,288 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Database-backed credential store for SigV4 authentication. +//! +//! Implements `extenddb_auth::CredentialStore` by looking up access keys and +//! session credentials from Cassandra, decrypting secrets with AES-256-GCM. + +use std::sync::Arc; + +use async_trait::async_trait; +use cdrs_tokio::cluster::TcpConnectionManager; +use cdrs_tokio::cluster::session::Session; +use cdrs_tokio::load_balancing::RoundRobinLoadBalancingStrategy; +use cdrs_tokio::transport::TransportTcp; +use cdrs_tokio::types::IntoRustByName; +use extenddb_auth::{CredentialStore, StoredCredential}; +use extenddb_core::error::DynamoDbError; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +type CassandraSession = Session< + TransportTcp, + TcpConnectionManager, + RoundRobinLoadBalancingStrategy, +>; + +/// Decrypt a secret key from `nonce || ciphertext` using the base64-encoded encryption key. +fn decrypt_secret(encrypted: &[u8], key_b64: &str, aad: &str) -> Result { + use aes_gcm::aead::{Aead, Payload}; + use aes_gcm::{Aes256Gcm, KeyInit}; + use base64::Engine; + + if encrypted.len() < 28 { + return Err(format!( + "ciphertext too short: {} bytes (need at least 12-byte nonce + 16-byte auth tag)", + encrypted.len() + )); + } + + let key_bytes = base64::engine::general_purpose::STANDARD + .decode(key_b64) + .map_err(|e| format!("decode encryption key: {e}"))?; + + let key = aes_gcm::Key::::from_slice(&key_bytes); + let cipher = Aes256Gcm::new(key); + let nonce = aes_gcm::Nonce::from_slice(&encrypted[..12]); + + let payload_with_aad = Payload { + msg: &encrypted[12..], + aad: aad.as_bytes(), + }; + if let Ok(plaintext_bytes) = cipher.decrypt(nonce, payload_with_aad) { + return String::from_utf8(plaintext_bytes) + .map_err(|e| format!("decrypted secret is not valid UTF-8: {e}")); + } + + tracing::debug!("Decrypting secret without AAD (pre-CB-11 format) for {aad}"); + let plaintext_bytes = cipher + .decrypt(nonce, &encrypted[12..]) + .map_err(|e| format!("decrypt failed both with AAD and without AAD: {e}"))?; + + String::from_utf8(plaintext_bytes) + .map_err(|e| format!("decrypted secret is not valid UTF-8: {e}")) +} + +#[derive(Zeroize, ZeroizeOnDrop)] +pub struct CassandraCredentialStore { + #[zeroize(skip)] + session: Arc, + #[zeroize(skip)] + keyspace_prefix: String, + encryption_key: String, +} + +impl CassandraCredentialStore { + pub fn new( + session: Arc, + keyspace_prefix: String, + encryption_key: String, + ) -> Self { + Self { + session, + keyspace_prefix, + encryption_key, + } + } + + fn catalog_keyspace(&self) -> String { + format!("{}_catalog", self.keyspace_prefix) + } +} + +#[async_trait] +impl CredentialStore for CassandraCredentialStore { + async fn lookup_credential( + &self, + access_key_id: &str, + ) -> Result, DynamoDbError> { + if access_key_id.starts_with("AKIA") { + return self.lookup_user_credential(access_key_id).await; + } + + if access_key_id.starts_with("ASIA") { + return self.lookup_session_credential(access_key_id).await; + } + + Ok(None) + } +} + +impl CassandraCredentialStore { + async fn lookup_user_credential( + &self, + access_key_id: &str, + ) -> Result, DynamoDbError> { + let catalog_keyspace = self.catalog_keyspace(); + let query = format!( + "SELECT secret_key_encrypted, account_id, user_name, is_active \ + FROM {}.access_keys WHERE access_key_id = ?", + catalog_keyspace + ); + + let result = self + .session + .query_with_values(&query, cdrs_tokio::query_values!(access_key_id)) + .await + .map_err(|e| { + tracing::error!("Credential lookup failed for access key {access_key_id}: {e}"); + DynamoDbError::InternalServerError( + "Internal error during authentication".to_owned(), + ) + })?; + + let body = result.response_body().map_err(|e| { + tracing::error!("Credential lookup response_body failed: {e}"); + DynamoDbError::InternalServerError("Internal error during authentication".to_owned()) + })?; + + let rows = match body.into_rows() { + Some(r) if !r.is_empty() => r, + _ => return Ok(None), + }; + + let row = &rows[0]; + let encrypted_blob: cdrs_tokio::types::blob::Blob = + row.get_r_by_name("secret_key_encrypted").map_err(|e| { + tracing::error!("Failed to parse secret_key_encrypted: {e}"); + DynamoDbError::InternalServerError( + "Internal error during authentication".to_owned(), + ) + })?; + let encrypted = encrypted_blob.into_vec(); + + let account_id: String = row.get_r_by_name("account_id").map_err(|e| { + tracing::error!("Failed to parse account_id: {e}"); + DynamoDbError::InternalServerError("Internal error during authentication".to_owned()) + })?; + let user_name: String = row.get_r_by_name("user_name").map_err(|e| { + tracing::error!("Failed to parse user_name: {e}"); + DynamoDbError::InternalServerError("Internal error during authentication".to_owned()) + })?; + let is_active: bool = row.get_r_by_name("is_active").map_err(|e| { + tracing::error!("Failed to parse is_active: {e}"); + DynamoDbError::InternalServerError("Internal error during authentication".to_owned()) + })?; + + let secret_key = + decrypt_secret(&encrypted, &self.encryption_key, access_key_id).map_err(|e| { + tracing::error!("Secret key decryption failed for access key {access_key_id}: {e}"); + DynamoDbError::InternalServerError( + "Internal error during authentication".to_owned(), + ) + })?; + + Ok(Some(StoredCredential { + secret_key, + account_id, + principal_name: user_name, + session_name: None, + is_session: false, + session_token: None, + is_active, + expires_at: None, + })) + } + + async fn lookup_session_credential( + &self, + access_key_id: &str, + ) -> Result, DynamoDbError> { + let catalog_keyspace = self.catalog_keyspace(); + let query = format!( + "SELECT secret_key_encrypted, account_id, role_name, session_name, \ + session_token, expires_at \ + FROM {}.iam_sessions WHERE access_key_id = ? ALLOW FILTERING", + catalog_keyspace + ); + + let result = self + .session + .query_with_values(&query, cdrs_tokio::query_values!(access_key_id)) + .await + .map_err(|e| { + tracing::error!( + "Session credential lookup failed for access key {access_key_id}: {e}" + ); + DynamoDbError::InternalServerError( + "Internal error during authentication".to_owned(), + ) + })?; + + let body = result.response_body().map_err(|e| { + tracing::error!("Session credential lookup response_body failed: {e}"); + DynamoDbError::InternalServerError("Internal error during authentication".to_owned()) + })?; + + let rows = match body.into_rows() { + Some(r) if !r.is_empty() => r, + _ => return Ok(None), + }; + + let row = &rows[0]; + let encrypted_blob: cdrs_tokio::types::blob::Blob = + row.get_r_by_name("secret_key_encrypted").map_err(|e| { + tracing::error!("Failed to parse secret_key_encrypted: {e}"); + DynamoDbError::InternalServerError( + "Internal error during authentication".to_owned(), + ) + })?; + let encrypted = encrypted_blob.into_vec(); + + let account_id: String = row.get_r_by_name("account_id").map_err(|e| { + tracing::error!("Failed to parse account_id: {e}"); + DynamoDbError::InternalServerError("Internal error during authentication".to_owned()) + })?; + let role_name: String = row.get_r_by_name("role_name").map_err(|e| { + tracing::error!("Failed to parse role_name: {e}"); + DynamoDbError::InternalServerError("Internal error during authentication".to_owned()) + })?; + let session_name: String = row.get_r_by_name("session_name").map_err(|e| { + tracing::error!("Failed to parse session_name: {e}"); + DynamoDbError::InternalServerError("Internal error during authentication".to_owned()) + })?; + let session_token: String = row.get_r_by_name("session_token").map_err(|e| { + tracing::error!("Failed to parse session_token: {e}"); + DynamoDbError::InternalServerError("Internal error during authentication".to_owned()) + })?; + + let expires_at_ms: i64 = row.get_r_by_name("expires_at").map_err(|e| { + tracing::error!("Failed to parse expires_at: {e}"); + DynamoDbError::InternalServerError("Internal error during authentication".to_owned()) + })?; + let expires_at = + time::OffsetDateTime::from_unix_timestamp(expires_at_ms / 1000).map_err(|e| { + tracing::error!("Invalid expires_at timestamp: {e}"); + DynamoDbError::InternalServerError( + "Internal error during authentication".to_owned(), + ) + })?; + + if expires_at < time::OffsetDateTime::now_utc() { + return Err(DynamoDbError::ExpiredTokenException( + "The security token included in the request is expired".to_owned(), + )); + } + + let secret_key = + decrypt_secret(&encrypted, &self.encryption_key, access_key_id).map_err(|e| { + tracing::error!( + "Session secret key decryption failed for access key {access_key_id}: {e}" + ); + DynamoDbError::InternalServerError( + "Internal error during authentication".to_owned(), + ) + })?; + + Ok(Some(StoredCredential { + secret_key, + account_id, + principal_name: role_name, + session_name: Some(session_name), + is_session: true, + session_token: Some(session_token), + is_active: true, + expires_at: Some(expires_at), + })) + } +} diff --git a/crates/storage-cassandra/src/data/condition.rs b/crates/storage-cassandra/src/data/condition.rs new file mode 100644 index 00000000..49f180d4 --- /dev/null +++ b/crates/storage-cassandra/src/data/condition.rs @@ -0,0 +1,30 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Condition expression evaluation helpers for Cassandra backend. + +use extenddb_core::expression::{self, Expr, ExpressionMaps}; +use extenddb_core::types::AttributeValue; +use extenddb_storage::error::StorageError; +use std::collections::BTreeMap; + +/// Evaluate a condition expression against an item. +/// +/// Returns `Ok(())` if the condition passes or is `None`. +/// Returns `Err(StorageError::ConditionFailed)` if the condition fails. +/// +/// For non-existent items, pass an empty BTreeMap as the item. +pub(crate) fn check_condition( + condition: Option<&Expr>, + item: &BTreeMap, + maps: &ExpressionMaps, +) -> Result<(), StorageError> { + if let Some(cond) = condition { + let passed = expression::evaluate_condition(cond, item, maps) + .map_err(|e| StorageError::Validation(e.to_string()))?; + if !passed { + return Err(StorageError::ConditionFailed(None)); + } + } + Ok(()) +} diff --git a/crates/storage-cassandra/src/data/data_engine.rs b/crates/storage-cassandra/src/data/data_engine.rs new file mode 100644 index 00000000..f1ba41e9 --- /dev/null +++ b/crates/storage-cassandra/src/data/data_engine.rs @@ -0,0 +1,365 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! DataEngine trait implementation for Cassandra. +//! TODO: Implement data operations. + +use extenddb_core::expression::{Expr, ExpressionMaps, KeyCondition, UpdateAction}; +use extenddb_core::types::AttributeValue; +use extenddb_core::types::TableKeyInfo; +use extenddb_storage::error::StorageError; +use extenddb_storage::{DataEngine, IdempotencyKey, StreamCapture, TransactGetOp, TransactWriteOp}; +use futures::future::BoxFuture; +use std::collections::BTreeMap; + +use crate::CassandraEngine; + +type Item = BTreeMap; + +impl DataEngine for CassandraEngine { + fn put_item( + &self, + key_info: &TableKeyInfo, + item: Item, + return_old: bool, + condition: Option<&Expr>, + maps: &ExpressionMaps, + stream: Option<&StreamCapture>, + ) -> BoxFuture<'_, Result, StorageError>> { + let key_info = key_info.clone(); + let condition = condition.cloned(); + let maps = maps.clone(); + let stream = stream.cloned(); + Box::pin(async move { + self.put_item_impl( + &key_info, + item, + return_old, + condition.as_ref(), + &maps, + stream.as_ref(), + ) + .await + }) + } + + fn get_item( + &self, + key_info: &TableKeyInfo, + key: &Item, + ) -> BoxFuture<'_, Result, StorageError>> { + let key_info = key_info.clone(); + let key = key.clone(); + Box::pin(async move { self.get_item_impl(&key_info, &key).await }) + } + + fn delete_item( + &self, + key_info: &TableKeyInfo, + key: &Item, + return_old: bool, + condition: Option<&Expr>, + maps: &ExpressionMaps, + stream: Option<&StreamCapture>, + ) -> BoxFuture<'_, Result, StorageError>> { + let key_info = key_info.clone(); + let key = key.clone(); + let condition = condition.cloned(); + let maps = maps.clone(); + let stream = stream.cloned(); + Box::pin(async move { + self.delete_item_impl( + &key_info, + &key, + return_old, + condition.as_ref(), + &maps, + stream.as_ref(), + ) + .await + }) + } + + fn update_item( + &self, + key_info: &TableKeyInfo, + key: &Item, + actions: &[UpdateAction], + return_old: bool, + return_new: bool, + condition: Option<&Expr>, + maps: &ExpressionMaps, + stream: Option<&StreamCapture>, + ) -> BoxFuture<'_, Result<(Option, Option), StorageError>> { + let key_info = key_info.clone(); + let key = key.clone(); + let actions = actions.to_vec(); + let condition = condition.cloned(); + let maps = maps.clone(); + let stream = stream.cloned(); + Box::pin(async move { + self.update_item_impl( + &key_info, + &key, + &actions, + return_old, + return_new, + condition.as_ref(), + &maps, + stream.as_ref(), + ) + .await + }) + } + + fn query( + &self, + key_info: &TableKeyInfo, + key_condition: &KeyCondition, + maps: &ExpressionMaps, + forward: bool, + limit: Option, + exclusive_start_key: Option<&Item>, + index_name: Option<&str>, + ) -> BoxFuture<'_, Result<(Vec, Option), StorageError>> { + let key_info = key_info.clone(); + let key_condition = key_condition.clone(); + let maps = maps.clone(); + let exclusive_start_key = exclusive_start_key.cloned(); + let index_name = index_name.map(ToOwned::to_owned); + Box::pin(async move { + self.query_impl( + &key_info, + &key_condition, + &maps, + forward, + limit, + exclusive_start_key.as_ref(), + index_name.as_deref(), + ) + .await + }) + } + + fn scan( + &self, + key_info: &TableKeyInfo, + limit: Option, + exclusive_start_key: Option<&Item>, + segment: Option, + total_segments: Option, + index_name: Option<&str>, + ) -> BoxFuture<'_, Result<(Vec, Option), StorageError>> { + let key_info = key_info.clone(); + let exclusive_start_key = exclusive_start_key.cloned(); + let index_name = index_name.map(ToOwned::to_owned); + Box::pin(async move { + self.scan_impl( + &key_info, + limit, + exclusive_start_key.as_ref(), + segment, + total_segments, + index_name.as_deref(), + ) + .await + }) + } + + fn transact_get_items( + &self, + ops: &[TransactGetOp<'_>], + ) -> BoxFuture<'_, Result>, StorageError>> { + let owned: Vec<_> = ops + .iter() + .map(|op| (op.key_info.clone(), op.key.clone())) + .collect(); + Box::pin(async move { + let borrowed: Vec = owned + .iter() + .map(|(key_info, key)| TransactGetOp { key_info, key }) + .collect(); + self.transact_get_items_impl(&borrowed).await + }) + } + + fn transact_write_items( + &self, + ops: &[TransactWriteOp<'_>], + idempotency: Option>, + ) -> BoxFuture<'_, Result<(), StorageError>> { + let owned_ops: Vec<_> = ops + .iter() + .map(|op| match op { + TransactWriteOp::Put { + key_info, + item, + condition, + maps, + return_values_on_ccf, + stream, + } => ( + 0u8, + (*key_info).clone(), + (*item).clone(), + None::, + Vec::new(), + condition.cloned(), + None::, + (*maps).clone(), + *return_values_on_ccf, + stream.clone(), + ), + TransactWriteOp::Delete { + key_info, + key, + condition, + maps, + return_values_on_ccf, + stream, + } => ( + 1u8, + (*key_info).clone(), + (*key).clone(), + None::, + Vec::new(), + condition.cloned(), + None::, + (*maps).clone(), + *return_values_on_ccf, + stream.clone(), + ), + TransactWriteOp::Update { + key_info, + key, + actions, + condition, + maps, + return_values_on_ccf, + stream, + } => ( + 2u8, + (*key_info).clone(), + (*key).clone(), + None::, + actions.to_vec(), + condition.cloned(), + None::, + (*maps).clone(), + *return_values_on_ccf, + stream.clone(), + ), + TransactWriteOp::ConditionCheck { + key_info, + key, + condition, + maps, + return_values_on_ccf, + } => ( + 3u8, + (*key_info).clone(), + (*key).clone(), + None::, + Vec::new(), + None::, + Some((*condition).clone()), + (*maps).clone(), + *return_values_on_ccf, + None, + ), + }) + .collect(); + let idempotency = idempotency.map(|key| { + ( + key.account_id.to_owned(), + key.token.to_owned(), + key.fingerprint.to_owned(), + ) + }); + + Box::pin(async move { + let borrowed_ops: Vec = owned_ops + .iter() + .map( + |( + tag, + key_info, + item_or_key, + _, + actions, + condition, + cc_condition, + maps, + rv, + stream, + )| { + match tag { + 0 => TransactWriteOp::Put { + key_info, + item: item_or_key, + condition: condition.as_ref(), + maps, + return_values_on_ccf: *rv, + stream: stream.clone(), + }, + 1 => TransactWriteOp::Delete { + key_info, + key: item_or_key, + condition: condition.as_ref(), + maps, + return_values_on_ccf: *rv, + stream: stream.clone(), + }, + 2 => TransactWriteOp::Update { + key_info, + key: item_or_key, + actions, + condition: condition.as_ref(), + maps, + return_values_on_ccf: *rv, + stream: stream.clone(), + }, + 3 => TransactWriteOp::ConditionCheck { + key_info, + key: item_or_key, + condition: cc_condition.as_ref().unwrap(), + maps, + return_values_on_ccf: *rv, + }, + _ => unreachable!(), + } + }, + ) + .collect(); + if let Some((idempotency_account, _, _)) = idempotency.as_ref() { + let operation_account = borrowed_ops.first().map(|op| match op { + TransactWriteOp::Put { key_info, .. } + | TransactWriteOp::Delete { key_info, .. } + | TransactWriteOp::Update { key_info, .. } + | TransactWriteOp::ConditionCheck { key_info, .. } => { + key_info.account_id.as_str() + } + }); + if operation_account.is_some_and(|account| account != idempotency_account) { + return Err(StorageError::Validation( + "Idempotency account does not match transaction account".to_owned(), + )); + } + } + self.transact_write_items_impl( + &borrowed_ops, + idempotency.as_ref().map(|(account, token, fingerprint)| { + (account.as_str(), token.as_str(), fingerprint.as_str()) + }), + ) + .await + }) + } + + fn cleanup_expired_idempotency_tokens( + &self, + _cutoff_ms: i64, + ) -> BoxFuture<'_, Result> { + Box::pin(async move { Ok(0) }) + } +} diff --git a/crates/storage-cassandra/src/data/ddl.rs b/crates/storage-cassandra/src/data/ddl.rs new file mode 100644 index 00000000..dd56f6a0 --- /dev/null +++ b/crates/storage-cassandra/src/data/ddl.rs @@ -0,0 +1,503 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! DDL helpers for creating and dropping per-DynamoDB-table data tables in Cassandra. + +use cdrs_tokio::types::IntoRustByName; +use extenddb_core::types::{ + AttributeDefinition, IndexInfo, IndexType, KeySchemaElement, ScalarAttributeType, + StreamSpecification, TableKeyInfo, +}; +use extenddb_storage::error::StorageError; +use extenddb_storage::util::{sk_column, sk_column_n}; + +use crate::CassandraEngine; + +/// CQL table name for a DynamoDB table in an account keyspace. +pub(crate) fn data_table_name(table_id: &str) -> String { + format!("items_{}", table_id.replace("-", "_")) +} + +/// CQL table name for a GSI/LSI data table. +pub fn index_table_name(index_id: &str) -> String { + format!("index_{}", index_id.replace("-", "_")) +} + +/// Look up all RANGE key attribute definitions from the key schema (preserving order). +pub(crate) fn all_sort_key_info<'a>( + key_schema: &'a [KeySchemaElement], + attr_defs: &'a [AttributeDefinition], +) -> Vec<(&'a str, ScalarAttributeType)> { + key_schema + .iter() + .filter(|ks| ks.key_type == extenddb_core::types::KeyType::Range) + .filter_map(|ks| { + attr_defs + .iter() + .find(|ad| ad.attribute_name == ks.attribute_name) + .map(|ad| (ks.attribute_name.as_str(), ad.attribute_type)) + }) + .collect() +} + +impl CassandraEngine { + /// Fetch lightweight table metadata required for data operations. + /// + /// Returns `TableKeyInfo` containing key schema, attribute definitions, + /// table ID, and stream specification. Used by all `DataEngine` operations. + /// + /// # Errors + /// + /// Returns `StorageError::TableNotFound` if the table doesn't exist. + /// Returns `StorageError::TableNotActive` if the table status is not ACTIVE. + pub(crate) async fn fetch_table_key_info( + &self, + account_id: &str, + table_name: &str, + ) -> Result { + let catalog_keyspace = format!("{}_catalog", self.keyspace_prefix); + + let query = format!( + "SELECT key_schema, attribute_definitions, table_status, table_id, stream_specification \ + FROM {}.tables WHERE account_id = ? AND table_name = ?", + catalog_keyspace + ); + + let result = self + .session + .query_with_values(&query, cdrs_tokio::query_values!(account_id, table_name)) + .await + .map_err(|e| StorageError::Internal(format!("Query table: {}", e)))?; + + let body = result + .response_body() + .map_err(|e| StorageError::Internal(format!("Parse response: {}", e)))?; + + let rows = body + .into_rows() + .ok_or_else(|| StorageError::TableNotFound(table_name.to_owned()))?; + + let row = rows + .into_iter() + .next() + .ok_or_else(|| StorageError::TableNotFound(table_name.to_owned()))?; + + let ks_text: String = row + .get_r_by_name("key_schema") + .map_err(|e| StorageError::Internal(format!("Parse key_schema: {}", e)))?; + let ad_text: String = row + .get_r_by_name("attribute_definitions") + .map_err(|e| StorageError::Internal(format!("Parse attribute_definitions: {}", e)))?; + let status: String = row + .get_r_by_name("table_status") + .map_err(|e| StorageError::Internal(format!("Parse table_status: {}", e)))?; + let table_id: String = row + .get_r_by_name("table_id") + .map_err(|e| StorageError::Internal(format!("Parse table_id: {}", e)))?; + let stream_spec_text: Option = + row.get_by_name("stream_specification").ok().flatten(); + + if status != "ACTIVE" { + return Err(StorageError::TableNotActive(table_name.to_owned())); + } + + let key_schema: Vec = + serde_json::from_str(&ks_text).map_err(|e| StorageError::Internal(e.to_string()))?; + let attribute_definitions: Vec = + serde_json::from_str(&ad_text).map_err(|e| StorageError::Internal(e.to_string()))?; + + let stream_specification: Option = stream_spec_text + .as_ref() + .map(|s| serde_json::from_str(s)) + .transpose() + .map_err(|e| StorageError::Internal(e.to_string()))?; + + // Fetch all secondary indexes and split into GSI/LSI. + let all_indexes = crate::data::index::fetch_indexes_for_table( + &table_id, + &self.session_arc(), + &catalog_keyspace, + ) + .await + .unwrap_or_default(); + + let mut global_secondary_indexes: Vec = Vec::new(); + let mut local_secondary_indexes: Vec = Vec::new(); + for meta in all_indexes { + let index_type = match meta.index_type.as_str() { + "GSI" => IndexType::Gsi, + "LSI" => IndexType::Lsi, + other => { + tracing::warn!("fetch_table_key_info: unknown index type '{other}', skipping"); + continue; + } + }; + let info = IndexInfo { + index_name: meta.index_name, + index_id: meta.index_id, + index_type, + key_schema: meta.key_schema, + projection: meta.projection, + }; + match info.index_type { + IndexType::Gsi => global_secondary_indexes.push(info), + IndexType::Lsi => local_secondary_indexes.push(info), + IndexType::Vector => {} // Vector indexes not yet supported in Cassandra backend + } + } + let has_lsi = !local_secondary_indexes.is_empty(); + + Ok(TableKeyInfo { + table_name: table_name.to_owned(), + account_id: account_id.to_owned(), + table_id, + key_schema: key_schema.clone(), + base_key_schema: key_schema, + attribute_definitions, + has_lsi, + global_secondary_indexes, + local_secondary_indexes, + stream_specification, + vector_indexes: Vec::new(), + }) + } + + /// Create the per-DynamoDB-table data table in Cassandra. + /// + /// Called after catalog metadata is inserted. The DDL is dynamically + /// generated based on the key schema. + pub(crate) async fn create_data_table( + &self, + account_keyspace: &str, + table_id: &str, + key_schema: &[KeySchemaElement], + attr_defs: &[AttributeDefinition], + ) -> Result<(), StorageError> { + let ddb_table = data_table_name(table_id); + let sk_infos = all_sort_key_info(key_schema, attr_defs); + + let ddl = if sk_infos.is_empty() { + // Hash-only table (no clustering columns) + // partition_max_delete_timestamp is a regular column (not STATIC) since there's only one row per partition + format!( + "CREATE TABLE {}.{} (\ + pk text PRIMARY KEY, \ + partition_max_delete_timestamp bigint, \ + item_data text, \ + prepared_txn_id uuid, \ + prepared_txn_timestamp bigint, \ + last_committed_txn_timestamp bigint, \ + created_to_prepare boolean\ + )", + account_keyspace, ddb_table + ) + } else if sk_infos.len() == 1 { + // Single sort key - use typed columns + let sk_col = sk_column(sk_infos[0].1); + format!( + "CREATE TABLE {}.{} (\ + pk text, \ + partition_max_delete_timestamp bigint STATIC, \ + sk_s text, \ + sk_n decimal, \ + sk_b blob, \ + item_data text, \ + prepared_txn_id uuid, \ + prepared_txn_timestamp bigint, \ + last_committed_txn_timestamp bigint, \ + created_to_prepare boolean, \ + PRIMARY KEY (pk, {})\ + )", + account_keyspace, ddb_table, sk_col + ) + } else { + // Multi-part RANGE key + let mut col_defs = vec!["pk text".to_owned()]; + let mut pk_cols = vec!["pk".to_owned()]; + + for (i, &(_, sk_type)) in sk_infos.iter().enumerate() { + let col = sk_column_n(i, sk_type); + // Add all three type columns for this SK position + if i == 0 { + col_defs.push("sk_s text".to_owned()); + col_defs.push("sk_n decimal".to_owned()); + col_defs.push("sk_b blob".to_owned()); + } else { + let n = i + 1; + col_defs.push(format!("sk{}_s text", n)); + col_defs.push(format!("sk{}_n decimal", n)); + col_defs.push(format!("sk{}_b blob", n)); + } + pk_cols.push(col); + } + col_defs.push("partition_max_delete_timestamp bigint STATIC".to_owned()); + col_defs.push("item_data text".to_owned()); + col_defs.push("prepared_txn_id uuid".to_owned()); + col_defs.push("prepared_txn_timestamp bigint".to_owned()); + col_defs.push("last_committed_txn_timestamp bigint".to_owned()); + col_defs.push("created_to_prepare boolean".to_owned()); + + format!( + "CREATE TABLE {}.{} ({}, PRIMARY KEY ({}))", + account_keyspace, + ddb_table, + col_defs.join(", "), + pk_cols.join(", ") + ) + }; + + self.session + .query(&ddl) + .await + .map_err(|e| StorageError::Internal(format!("Failed to create data table: {}", e)))?; + + Ok(()) + } + + /// Drop the per-DynamoDB-table data table. + pub(crate) async fn drop_data_table( + &self, + account_keyspace: &str, + table_id: &str, + ) -> Result<(), StorageError> { + let ddb_table = data_table_name(table_id); + let ddl = format!("DROP TABLE IF EXISTS {}.{}", account_keyspace, ddb_table); + + self.session + .query(&ddl) + .await + .map_err(|e| StorageError::Internal(format!("Failed to drop data table: {}", e)))?; + + Ok(()) + } + + /// Drop a GSI/LSI data table. + pub async fn drop_index_data_table( + &self, + account_keyspace: &str, + index_id: &str, + ) -> Result<(), StorageError> { + let idx_table = index_table_name(index_id); + let ddl = format!("DROP TABLE IF EXISTS {}.{}", account_keyspace, idx_table); + + self.session.query(&ddl).await.map_err(|e| { + StorageError::Internal(format!("Failed to drop index data table: {}", e)) + })?; + + Ok(()) + } + + /// Create a GSI/LSI data table in Cassandra. + /// + /// GSI tables use (pk, sk_*, base_pk, base_sk_*) structure where: + /// - pk is the partition key (index PK) + /// - sk_* are clustering keys for ordering (index SK) + /// - base_pk, base_sk_* are clustering keys for uniqueness + /// + /// This differs from PostgreSQL where base keys come before index SK + /// in the PRIMARY KEY constraint. Cassandra needs index SK as clustering + /// keys to enable efficient range queries. + #[allow(clippy::too_many_arguments)] + pub async fn create_index_data_table( + &self, + account_keyspace: &str, + index_id: &str, + index_key_schema: &[KeySchemaElement], + attr_defs: &[AttributeDefinition], + base_key_schema: &[KeySchemaElement], + base_attr_defs: &[AttributeDefinition], + ) -> Result<(), StorageError> { + let idx_table = index_table_name(index_id); + + // Determine base table sort key columns for uniqueness + let base_sks = all_sort_key_info(base_key_schema, base_attr_defs); + // Determine index sort keys + let idx_sks = all_sort_key_info(index_key_schema, attr_defs); + + // Build column definitions + let mut col_defs = vec!["pk text".to_owned()]; + + // Index SK columns + for (i, &(_, _)) in idx_sks.iter().enumerate() { + if i == 0 { + col_defs.push("sk_s text".to_owned()); + col_defs.push("sk_n decimal".to_owned()); + col_defs.push("sk_b blob".to_owned()); + } else { + let n = i + 1; + col_defs.push(format!("sk{}_s text", n)); + col_defs.push(format!("sk{}_n decimal", n)); + col_defs.push(format!("sk{}_b blob", n)); + } + } + + // Base table key columns for uniqueness + col_defs.push("base_pk text".to_owned()); + for (i, &(_, _)) in base_sks.iter().enumerate() { + if i == 0 { + col_defs.push("base_sk_s text".to_owned()); + col_defs.push("base_sk_n decimal".to_owned()); + col_defs.push("base_sk_b blob".to_owned()); + } else { + let n = i + 1; + col_defs.push(format!("base_sk{}_s text", n)); + col_defs.push(format!("base_sk{}_n decimal", n)); + col_defs.push(format!("base_sk{}_b blob", n)); + } + } + + col_defs.push("item_data text".to_owned()); + + // Build PRIMARY KEY: ((pk), sk_*, base_pk, base_sk_*) + // Partition key is (pk), clustering keys are index SK + base keys + let mut clustering_cols = Vec::new(); + + // Add index sort keys as clustering keys (for ordering) + for (i, &(_, sk_type)) in idx_sks.iter().enumerate() { + clustering_cols.push(sk_column_n(i, sk_type)); + } + + // Add base keys as clustering keys (for uniqueness) + clustering_cols.push("base_pk".to_owned()); + for (i, &(_, sk_type)) in base_sks.iter().enumerate() { + let col = if i == 0 { + format!("base_{}", sk_column(sk_type)) + } else { + format!("base_{}", sk_column_n(i, sk_type)) + }; + clustering_cols.push(col); + } + + let primary_key = if clustering_cols.is_empty() { + // Hash-only index with no base keys - just partition key + "((pk))".to_owned() + } else { + // Partition key + clustering keys + format!("((pk), {})", clustering_cols.join(", ")) + }; + + let ddl = format!( + "CREATE TABLE {}.{} ({}, PRIMARY KEY {})", + account_keyspace, + idx_table, + col_defs.join(", "), + primary_key + ); + + self.session + .query(&ddl) + .await + .map_err(|e| StorageError::Internal(format!("Failed to create index table: {}", e)))?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use extenddb_core::types::{KeyType, ScalarAttributeType}; + + #[test] + fn test_data_table_name() { + assert_eq!(data_table_name("abc123"), "items_abc123"); + // Test UUID normalization (hyphens replaced with underscores) + assert_eq!( + data_table_name("914da501-3f2e-4b1c-a5d6-1234567890ab"), + "items_914da501_3f2e_4b1c_a5d6_1234567890ab" + ); + } + + #[test] + fn test_index_table_name() { + assert_eq!(index_table_name("idx456"), "index_idx456"); + // Test UUID normalization (hyphens replaced with underscores) + assert_eq!( + index_table_name("a1b2c3d4-e5f6-7890-abcd-ef1234567890"), + "index_a1b2c3d4_e5f6_7890_abcd_ef1234567890" + ); + } + + #[test] + fn test_all_sort_key_info_no_range() { + let key_schema = vec![KeySchemaElement { + attribute_name: "pk".to_string(), + key_type: KeyType::Hash, + }]; + let attr_defs = vec![AttributeDefinition { + attribute_name: "pk".to_string(), + attribute_type: ScalarAttributeType::S, + }]; + + let result = all_sort_key_info(&key_schema, &attr_defs); + assert!(result.is_empty()); + } + + #[test] + fn test_all_sort_key_info_single_range() { + let key_schema = vec![ + KeySchemaElement { + attribute_name: "pk".to_string(), + key_type: KeyType::Hash, + }, + KeySchemaElement { + attribute_name: "sk".to_string(), + key_type: KeyType::Range, + }, + ]; + let attr_defs = vec![ + AttributeDefinition { + attribute_name: "pk".to_string(), + attribute_type: ScalarAttributeType::S, + }, + AttributeDefinition { + attribute_name: "sk".to_string(), + attribute_type: ScalarAttributeType::N, + }, + ]; + + let result = all_sort_key_info(&key_schema, &attr_defs); + assert_eq!(result.len(), 1); + assert_eq!(result[0].0, "sk"); + assert_eq!(result[0].1, ScalarAttributeType::N); + } + + #[test] + fn test_all_sort_key_info_multi_range() { + let key_schema = vec![ + KeySchemaElement { + attribute_name: "pk".to_string(), + key_type: KeyType::Hash, + }, + KeySchemaElement { + attribute_name: "sk1".to_string(), + key_type: KeyType::Range, + }, + KeySchemaElement { + attribute_name: "sk2".to_string(), + key_type: KeyType::Range, + }, + ]; + let attr_defs = vec![ + AttributeDefinition { + attribute_name: "pk".to_string(), + attribute_type: ScalarAttributeType::S, + }, + AttributeDefinition { + attribute_name: "sk1".to_string(), + attribute_type: ScalarAttributeType::S, + }, + AttributeDefinition { + attribute_name: "sk2".to_string(), + attribute_type: ScalarAttributeType::B, + }, + ]; + + let result = all_sort_key_info(&key_schema, &attr_defs); + assert_eq!(result.len(), 2); + assert_eq!(result[0].0, "sk1"); + assert_eq!(result[0].1, ScalarAttributeType::S); + assert_eq!(result[1].0, "sk2"); + assert_eq!(result[1].1, ScalarAttributeType::B); + } +} diff --git a/crates/storage-cassandra/src/data/delete_item.rs b/crates/storage-cassandra/src/data/delete_item.rs new file mode 100644 index 00000000..e91f551c --- /dev/null +++ b/crates/storage-cassandra/src/data/delete_item.rs @@ -0,0 +1,420 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! `delete_item` implementation for the Cassandra backend. + +use cdrs_tokio::consistency::Consistency; +use cdrs_tokio::query::BatchQueryBuilder; +use cdrs_tokio::types::IntoRustByName; +use extenddb_core::expression::{Expr, ExpressionMaps}; +use extenddb_core::types::{Item, TableKeyInfo}; +use extenddb_storage::StreamCapture; +use extenddb_storage::error::StorageError; +use extenddb_storage::util::{composite_pk_to_text, parse_sk, sk_column, sk_info}; + +use super::ddl::data_table_name; +use super::index::fetch_indexes_for_table; +use super::{json_to_item, query_with_pk_sk}; +use crate::CassandraEngine; +use crate::stream_util::stream_record_statement; + +impl CassandraEngine { + /// Implementation of `DataEngine::delete_item`. + pub(crate) async fn delete_item_impl( + &self, + key_info: &TableKeyInfo, + key: &Item, + return_old: bool, + condition: Option<&Expr>, + maps: &ExpressionMaps, + stream: Option<&StreamCapture>, + ) -> Result, StorageError> { + let data_keyspace = self.account_keyspace(&key_info.account_id); + let ddb_table = data_table_name(&key_info.table_id); + + let pk_text = composite_pk_to_text(key, &key_info.key_schema)?; + + let catalog_keyspace = self.catalog_keyspace(); + let indexes = fetch_indexes_for_table(&key_info.table_id, &self.session, &catalog_keyspace).await?; + let sys_delay = if indexes.is_empty() { + 0 + } else { + self.gsi_default_delay_ms.load(std::sync::atomic::Ordering::Relaxed) + }; + + if let Some((sk_name, sk_type)) = + sk_info(&key_info.key_schema, &key_info.attribute_definitions) + { + // Table has sort key + let sk_value = key + .get(sk_name) + .ok_or_else(|| StorageError::Internal("missing sort key".to_owned()))?; + let sk = parse_sk(sk_value, sk_type)?; + let sk_col = sk_column(sk_type); + + // Always read to check prepared_txn_id for transaction conflict detection. + let select_query = format!( + "SELECT item_data, prepared_txn_id FROM {}.{} WHERE pk = ? AND {} = ?", + data_keyspace, ddb_table, sk_col + ); + + let old_result = + query_with_pk_sk(&self.session, &select_query, pk_text.as_ref(), &sk).await?; + + let body = old_result + .response_body() + .map_err(|e| StorageError::Internal(format!("Parse response: {}", e)))?; + + let (old_item_opt, has_prepared_txn) = if let Some(rows) = body.into_rows() { + if let Some(row) = rows.into_iter().next() { + let item_data: String = + crate::cassandra_util::get_column(&row, "item_data", "delete_item")?; + let prepared_txn_id: Option = + row.get_by_name("prepared_txn_id").ok().flatten(); + (Some(json_to_item(item_data)?), prepared_txn_id.is_some()) + } else { + (None, false) + } + } else { + (None, false) + }; + + // Reject if item is part of an in-flight transaction + if has_prepared_txn { + return Err(StorageError::TransactionCanceled(vec![ + extenddb_core::types::CancellationReason { + code: "TransactionConflict".to_owned(), + message: Some( + "Item is being modified by a concurrent transaction".to_owned(), + ), + item: None, + }, + ])); + } + + // Evaluate condition against existing item (or empty if doesn't exist) + if condition.is_some() { + let condition_item = if let Some(ref existing) = old_item_opt { + existing + } else { + &std::collections::BTreeMap::new() + }; + match super::check_condition(condition, condition_item, maps) { + Ok(()) => {} + Err(StorageError::ConditionFailed(_)) => { + return Err(StorageError::ConditionFailed(old_item_opt)); + } + Err(e) => return Err(e), + } + + // If condition passed but no item exists, nothing to delete + if old_item_opt.is_none() { + return Ok(None); + } + } + + // Delete the item (with index updates if needed). + let delete_cql = format!( + "DELETE FROM {}.{} WHERE pk = ? AND {} = ?", + data_keyspace, ddb_table, sk_col + ); + + // Update partition_max_delete_timestamp before the batch (must precede delete). + if old_item_opt.is_some() { + self.update_partition_max_delete_timestamp(&data_keyspace, &ddb_table, &pk_text) + .await?; + } + + let stream_stmt = old_item_opt.as_ref().and_then(|_| { + stream.and_then(|cap| { + stream_record_statement( + &data_keyspace, + &key_info.table_id, + key_info, + old_item_opt.as_ref(), + None, + cap, + &self.hlc, + self.stream_retention_seconds, + ) + }) + }); + + if indexes.is_empty() && stream_stmt.is_none() { + query_with_pk_sk(&self.session, &delete_cql, pk_text.as_ref(), &sk).await?; + } else { + use super::index::sk_to_value; + let delete_qv = cdrs_tokio::query::QueryValues::SimpleValues(vec![ + cdrs_tokio::types::value::Value::from(pk_text.as_str()), + sk_to_value(&sk), + ]); + let mut batch = BatchQueryBuilder::new() + .with_consistency(Consistency::LocalQuorum) + .add_query(delete_cql, delete_qv); + + if !indexes.is_empty() { + super::index::sync_indexes( + &mut batch, + &data_keyspace, + &key_info.key_schema, + &key_info.attribute_definitions, + &indexes, + old_item_opt.as_ref(), + None, + sys_delay, + )?; + } + + let async_enqueued = if !indexes.is_empty() { + super::index::enqueue_async_indexes( + &self.session, + &mut batch, + &data_keyspace, + key_info, + &indexes, + old_item_opt.as_ref(), + None, + sys_delay, + ).await? + } else { + 0 + }; + + if let Some(stmt) = stream_stmt { + batch = batch.add_query( + stmt, + cdrs_tokio::query::QueryValues::SimpleValues(vec![]), + ); + } + + self.session + .batch(batch.build().map_err(|e| StorageError::Internal(e.to_string()))?) + .await + .map_err(|e| StorageError::Internal(format!("Batch execution: {}", e)))?; + + if async_enqueued > 0 { + self.gsi_queue.notify_workers(); + } + } + + Ok(if return_old { old_item_opt } else { None }) + } else { + // Table has only partition key + + // Always read to check prepared_txn_id for transaction conflict detection + let select_query = format!( + "SELECT item_data, prepared_txn_id FROM {}.{} WHERE pk = ?", + data_keyspace, ddb_table + ); + + let row = crate::cassandra_util::query_optional( + &self.session, + &select_query, + cdrs_tokio::query_values!(pk_text.as_str()), + "delete_item", + ) + .await?; + + let (old_item_opt, has_prepared_txn) = if let Some(row) = row { + let item_data: String = + crate::cassandra_util::get_column(&row, "item_data", "delete_item")?; + let prepared_txn_id: Option = + row.get_by_name("prepared_txn_id").ok().flatten(); + (Some(json_to_item(item_data)?), prepared_txn_id.is_some()) + } else { + (None, false) + }; + + // Reject if item is part of an in-flight transaction + if has_prepared_txn { + return Err(StorageError::TransactionCanceled(vec![ + extenddb_core::types::CancellationReason { + code: "TransactionConflict".to_owned(), + message: Some( + "Item is being modified by a concurrent transaction".to_owned(), + ), + item: None, + }, + ])); + } + + // Evaluate condition against existing item (or empty if doesn't exist) + if condition.is_some() { + let condition_item = if let Some(ref existing) = old_item_opt { + existing + } else { + &std::collections::BTreeMap::new() + }; + match super::check_condition(condition, condition_item, maps) { + Ok(()) => {} + Err(StorageError::ConditionFailed(_)) => { + return Err(StorageError::ConditionFailed(old_item_opt)); + } + Err(e) => return Err(e), + } + + // If condition passed but no item exists, nothing to delete + if old_item_opt.is_none() { + return Ok(None); + } + } + + // Delete the item (with index updates if needed). + let delete_cql = format!("DELETE FROM {}.{} WHERE pk = ?", data_keyspace, ddb_table); + + // Update partition_max_delete_timestamp before the batch (must precede delete). + if old_item_opt.is_some() { + self.update_partition_max_delete_timestamp(&data_keyspace, &ddb_table, &pk_text) + .await?; + } + + let stream_stmt = old_item_opt.as_ref().and_then(|_| { + stream.and_then(|cap| { + stream_record_statement( + &data_keyspace, + &key_info.table_id, + key_info, + old_item_opt.as_ref(), + None, + cap, + &self.hlc, + self.stream_retention_seconds, + ) + }) + }); + + if indexes.is_empty() && stream_stmt.is_none() { + self.session + .query_with_values(&delete_cql, cdrs_tokio::query_values!(pk_text.as_str())) + .await + .map_err(|e| StorageError::Internal(format!("Delete failed: {}", e)))?; + } else { + let delete_qv = cdrs_tokio::query::QueryValues::SimpleValues(vec![ + cdrs_tokio::types::value::Value::from(pk_text.as_str()), + ]); + let mut batch = BatchQueryBuilder::new() + .with_consistency(Consistency::LocalQuorum) + .add_query(delete_cql, delete_qv); + + if !indexes.is_empty() { + super::index::sync_indexes( + &mut batch, + &data_keyspace, + &key_info.key_schema, + &key_info.attribute_definitions, + &indexes, + old_item_opt.as_ref(), + None, + sys_delay, + )?; + } + + let async_enqueued = if !indexes.is_empty() { + super::index::enqueue_async_indexes( + &self.session, + &mut batch, + &data_keyspace, + key_info, + &indexes, + old_item_opt.as_ref(), + None, + sys_delay, + ).await? + } else { + 0 + }; + + if let Some(stmt) = stream_stmt { + batch = batch.add_query( + stmt, + cdrs_tokio::query::QueryValues::SimpleValues(vec![]), + ); + } + + self.session + .batch(batch.build().map_err(|e| StorageError::Internal(e.to_string()))?) + .await + .map_err(|e| StorageError::Internal(format!("Batch execution: {}", e)))?; + + if async_enqueued > 0 { + self.gsi_queue.notify_workers(); + } + } + + Ok(if return_old { old_item_opt } else { None }) + } + } + + /// Update `partition_max_delete_timestamp` for a partition using a two-step + /// LWT approach. This records the latest delete timestamp so that stale + /// transactions cannot re-create items that were deleted after they started. + /// + /// Step 1: Try to set the value if it's currently null (first delete in partition). + /// Step 2: If already set, update only if our timestamp is higher. + async fn update_partition_max_delete_timestamp( + &self, + keyspace: &str, + table: &str, + pk: &str, + ) -> Result<(), StorageError> { + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64; + + // Step 1: Try to set if null + let query_null = format!( + "UPDATE {}.{} SET partition_max_delete_timestamp = ? WHERE pk = ? \ + IF partition_max_delete_timestamp = null", + keyspace, table + ); + + let result = self + .session + .query_with_values( + &query_null, + cdrs_tokio::query_values!(now_ms, pk), + ) + .await + .map_err(|e| { + tracing::error!("update_partition_max_delete_timestamp (null): {e}"); + StorageError::Internal("Database error".to_owned()) + })?; + + // Check if LWT was applied + let applied = result + .response_body() + .ok() + .and_then(|body| body.into_rows()) + .and_then(|rows| rows.into_iter().next()) + .and_then(|row| { + use cdrs_tokio::types::IntoRustByName; + let val: bool = row.get_r_by_name("[applied]").ok()?; + Some(val) + }) + .unwrap_or(true); // If we can't parse, assume applied + + if !applied { + // Step 2: Column already has a value - update only if ours is higher + let query_compare = format!( + "UPDATE {}.{} SET partition_max_delete_timestamp = ? WHERE pk = ? \ + IF partition_max_delete_timestamp < ?", + keyspace, table + ); + + self.session + .query_with_values( + &query_compare, + cdrs_tokio::query_values!(now_ms, pk, now_ms), + ) + .await + .map_err(|e| { + tracing::error!("update_partition_max_delete_timestamp (compare): {e}"); + StorageError::Internal("Database error".to_owned()) + })?; + // Don't check LWT result - if another delete set a higher timestamp, that's fine + } + + Ok(()) + } +} diff --git a/crates/storage-cassandra/src/data/index.rs b/crates/storage-cassandra/src/data/index.rs new file mode 100644 index 00000000..6c9e8e59 --- /dev/null +++ b/crates/storage-cassandra/src/data/index.rs @@ -0,0 +1,747 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! GSI/LSI index operations for the `Cassandra` backend. +//! +//! Handles index metadata fetching, item projection for indexes, synchronous +//! index updates within batches, and async index enqueue for deferred +//! propagation. + +use cdrs_tokio::query::BatchQueryBuilder; +use cdrs_tokio::query_values; +use cdrs_tokio::types::IntoRustByName; +use extenddb_core::types::{ + AttributeDefinition, Item, KeySchemaElement, Projection, ProjectionType, ScalarAttributeType, +}; +use extenddb_storage::error::StorageError; +use extenddb_storage::util::{ + SortKeyValue, composite_pk_to_text, parse_sk, sk_column, sk_column_n, +}; +use std::sync::Arc; + +use super::ddl::{all_sort_key_info, index_table_name}; +use crate::cassandra_util::{CassandraSession, get_column, query_rows}; + +/// Metadata for a single index, used during write-path GSI/LSI sync. +#[derive(Clone)] +pub struct IndexMeta { + pub index_name: String, + pub index_id: String, + pub index_type: String, + pub key_schema: Vec, + pub projection: Projection, + /// Per-GSI propagation delay in milliseconds. `None` means use system + /// default. `Some(0)` means synchronous. + pub propagation_delay_ms: Option, +} + +/// Fetch all index metadata for a table from the catalog. +pub async fn fetch_indexes_for_table( + table_id: &str, + session: &Arc, + catalog_keyspace: &str, +) -> Result, StorageError> { + let query = format!( + "SELECT index_name, index_id, index_type, key_schema, projection, propagation_delay_ms \ + FROM {catalog_keyspace}.indexes WHERE table_id = ?" + ); + + let rows = query_rows( + session, + &query, + query_values!(table_id), + "fetch_indexes_for_table", + ) + .await?; + + rows.into_iter() + .map(|row| { + let index_name: String = get_column(&row, "index_name", "fetch_indexes_for_table")?; + let index_id: String = get_column(&row, "index_id", "fetch_indexes_for_table")?; + let index_type: String = get_column(&row, "index_type", "fetch_indexes_for_table")?; + let key_schema_text: String = + get_column(&row, "key_schema", "fetch_indexes_for_table")?; + let projection_text: String = + get_column(&row, "projection", "fetch_indexes_for_table")?; + let propagation_delay_ms: Option = + row.get_by_name("propagation_delay_ms").ok().flatten(); + + let key_schema: Vec = serde_json::from_str(&key_schema_text) + .map_err(|e| { + tracing::error!("fetch_indexes_for_table deserialize key_schema: {e}"); + StorageError::Internal("Database error".to_owned()) + })?; + + let projection: Projection = serde_json::from_str(&projection_text).map_err(|e| { + tracing::error!("fetch_indexes_for_table deserialize projection: {e}"); + StorageError::Internal("Database error".to_owned()) + })?; + + Ok(IndexMeta { + index_name, + index_id, + index_type, + key_schema, + projection, + propagation_delay_ms, + }) + }) + .collect() +} + +/// Fetch a single index by table_id and index_name (hot path for query routing). +/// +/// Uses the PRIMARY KEY ((table_id), index_name) for efficient single-row lookup. +pub async fn fetch_index_by_name( + table_id: &str, + index_name: &str, + session: &Arc, + catalog_keyspace: &str, +) -> Result { + let query = format!( + "SELECT index_id, index_type, key_schema, projection, propagation_delay_ms \ + FROM {catalog_keyspace}.indexes WHERE table_id = ? AND index_name = ?" + ); + + let rows = query_rows( + session, + &query, + query_values!(table_id, index_name), + "fetch_index_by_name", + ) + .await?; + + let row = rows + .into_iter() + .next() + .ok_or_else(|| StorageError::IndexNotFound(format!("Index {} not found", index_name)))?; + + let index_id: String = get_column(&row, "index_id", "fetch_index_by_name")?; + let index_type: String = get_column(&row, "index_type", "fetch_index_by_name")?; + let key_schema_text: String = get_column(&row, "key_schema", "fetch_index_by_name")?; + let projection_text: String = get_column(&row, "projection", "fetch_index_by_name")?; + let propagation_delay_ms: Option = row.get_by_name("propagation_delay_ms").ok().flatten(); + + let key_schema: Vec = + serde_json::from_str(&key_schema_text).map_err(|e| { + tracing::error!("fetch_index_by_name deserialize key_schema: {e}"); + StorageError::Internal("Database error".to_owned()) + })?; + + let projection: Projection = serde_json::from_str(&projection_text).map_err(|e| { + tracing::error!("fetch_index_by_name deserialize projection: {e}"); + StorageError::Internal("Database error".to_owned()) + })?; + + Ok(IndexMeta { + index_name: index_name.to_owned(), + index_id, + index_type, + key_schema, + projection, + propagation_delay_ms, + }) +} + +/// Project an item according to an index's projection configuration. +/// +/// Returns the projected item containing only the attributes that should be +/// stored in the index table's `item_data` column. +pub(crate) fn project_item_for_index( + item: &Item, + index_ks: &[KeySchemaElement], + base_ks: &[KeySchemaElement], + projection: &Projection, +) -> Item { + match projection.projection_type { + ProjectionType::All => item.clone(), + ProjectionType::KeysOnly => { + let mut projected = Item::new(); + // Include base table keys + index keys + for ks in base_ks.iter().chain(index_ks.iter()) { + if let Some(v) = item.get(&ks.attribute_name) { + projected.insert(ks.attribute_name.clone(), v.clone()); + } + } + projected + } + ProjectionType::Include => { + // Base keys + index keys + non-key attributes + let mut projected = Item::new(); + for ks in base_ks.iter().chain(index_ks.iter()) { + if let Some(v) = item.get(&ks.attribute_name) { + projected.insert(ks.attribute_name.clone(), v.clone()); + } + } + if let Some(ref attrs) = projection.non_key_attributes { + for attr in attrs { + if let Some(v) = item.get(attr) { + projected.insert(attr.clone(), v.clone()); + } + } + } + projected + } + } +} + +/// Check if an item has all the key attributes required by an index. +pub(crate) fn item_has_index_keys(item: &Item, index_ks: &[KeySchemaElement]) -> bool { + index_ks + .iter() + .all(|ks| item.contains_key(&ks.attribute_name)) +} + +/// Compute the effective propagation delay for an index. +/// +/// Per-GSI setting overrides the system default. `Some(0)` = sync, `None` = use default. +pub(super) fn effective_delay(idx: &IndexMeta, system_default: u64) -> u64 { + match idx.propagation_delay_ms { + Some(0) => 0, + Some(ms) if ms > 0 => ms as u64, + Some(_) => system_default, // Negative values treated as "use system default". + None => system_default, + } +} + +/// Synchronously update index tables for indexes with zero propagation delay. +/// +/// Called within the same LOGGED BATCH as the base table write. +/// Only processes indexes where `effective_delay == 0`. Async indexes are +/// handled by async queue (Phase 2). +/// +/// Adds parameterized statements to the batch builder. +#[allow(clippy::too_many_arguments)] +pub fn sync_indexes( + batch: &mut BatchQueryBuilder, + account_keyspace: &str, + base_key_schema: &[KeySchemaElement], + attr_defs: &[AttributeDefinition], + indexes: &[IndexMeta], + old_item: Option<&Item>, + new_item: Option<&Item>, + system_default_delay: u64, +) -> Result<(), StorageError> { + for idx in indexes { + if idx.index_type != "LSI" && effective_delay(idx, system_default_delay) != 0 { + continue; // Async — handled after commit. LSIs are always synchronous. + } + let idx_table = index_table_name(&idx.index_id); + let idx_sks = all_sort_key_info(&idx.key_schema, attr_defs); + let base_sks = all_sort_key_info(base_key_schema, attr_defs); + + // Delete old index row if the old item had index keys + if let Some(old) = old_item { + if item_has_index_keys(old, &idx.key_schema) { + delete_index_row_multi( + batch, + account_keyspace, + &idx_table, + old, + &idx.key_schema, + base_key_schema, + &idx_sks, + &base_sks, + )?; + } + } + + // Insert new index row if the new item has index keys + if let Some(new) = new_item { + if item_has_index_keys(new, &idx.key_schema) { + let projected = + project_item_for_index(new, &idx.key_schema, base_key_schema, &idx.projection); + insert_index_row_multi( + batch, + account_keyspace, + &idx_table, + new, + &projected, + &idx.key_schema, + base_key_schema, + &idx_sks, + &base_sks, + )?; + } + } + } + Ok(()) +} + +/// Enqueue one `gsi_pending` row per async GSI into the in-progress logged +/// batch. Returns the number of rows enqueued. +/// +/// Must be called before the batch is executed so the pending rows commit +/// atomically with the base write. LSIs and delay=0 GSIs are skipped (they are +/// handled synchronously by `sync_indexes`). +#[allow(clippy::too_many_arguments)] +pub(crate) async fn enqueue_async_indexes( + session: &std::sync::Arc, + batch: &mut BatchQueryBuilder, + account_keyspace: &str, + key_info: &extenddb_core::types::TableKeyInfo, + indexes: &[IndexMeta], + old_item: Option<&extenddb_core::types::Item>, + new_item: Option<&extenddb_core::types::Item>, + system_default_delay: u64, +) -> Result { + use crate::gsi_queue::{GsiApplyContext, GsiIndexDef, jitter_delay_ms, partition_for}; + use extenddb_storage::util::composite_pk_to_text; + + let mut enqueued = 0usize; + + for idx in indexes { + if idx.index_type == "LSI" { + continue; + } + let delay = effective_delay(idx, system_default_delay); + if delay == 0 { + continue; + } + + let context = GsiApplyContext { + base_key_schema: key_info.key_schema.clone(), + attribute_definitions: key_info.attribute_definitions.clone(), + index: GsiIndexDef { + index_id: idx.index_id.clone(), + key_schema: idx.key_schema.clone(), + projection: idx.projection.clone(), + }, + }; + + let context_json = serde_json::to_string(&context) + .map_err(|e| StorageError::Internal(e.to_string()))?; + let old_json = old_item + .map(serde_json::to_string) + .transpose() + .map_err(|e| StorageError::Internal(e.to_string()))?; + let new_json = new_item + .map(serde_json::to_string) + .transpose() + .map_err(|e| StorageError::Internal(e.to_string()))?; + + let base_item = new_item.or(old_item); + let worker_partition = match base_item { + Some(item) => partition_for(&composite_pk_to_text(item, &key_info.key_schema)?), + None => 0, + }; + + // Read last_ready_at for this partition (O(1) static column lookup). + let last_ready_at = { + use cdrs_tokio::query_values; + use cdrs_tokio::types::IntoRustByName as _; + let cql = format!( + "SELECT last_ready_at FROM {account_keyspace}.gsi_pending \ + WHERE worker_partition = ? LIMIT 1" + ); + let rows = crate::cassandra_util::query_rows::( + session, + &cql, + query_values!(worker_partition), + "enqueue_async_indexes", + ) + .await?; + rows.into_iter() + .next() + .and_then(|row| row.get_by_name("last_ready_at").ok().flatten()) + .unwrap_or(0) + }; + + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64; + let jittered_ms = now_ms + jitter_delay_ms(delay) as i64; + // Clamp: ready_at must be >= last_ready_at + 1 to preserve causal ordering + // across concurrent ExtendDB instances. last_ready_at is updated atomically + // in the same logged batch via the INSERT (static columns can be set in INSERT). + let ready_at_ms = jittered_ms.max(last_ready_at + 1); + + // Include last_ready_at in the INSERT — Cassandra allows setting static columns + // directly in an INSERT, avoiding a separate UPDATE that would create a ghost row. + let insert_cql = format!( + "INSERT INTO {account_keyspace}.gsi_pending \ + (worker_partition, last_ready_at, ready_at, id, table_id, old_item, new_item, index_context) \ + VALUES (?, ?, ?, now(), ?, ?, ?, ?)" + ); + let insert_qv = cdrs_tokio::query::QueryValues::SimpleValues(vec![ + cdrs_tokio::types::value::Value::from(worker_partition), + cdrs_tokio::types::value::Value::from(ready_at_ms), + cdrs_tokio::types::value::Value::from(ready_at_ms), + cdrs_tokio::types::value::Value::from(key_info.table_id.as_str()), + match old_json { + Some(ref s) => cdrs_tokio::types::value::Value::from(s.as_str()), + None => cdrs_tokio::types::value::Value::NotSet, + }, + match new_json { + Some(ref s) => cdrs_tokio::types::value::Value::from(s.as_str()), + None => cdrs_tokio::types::value::Value::NotSet, + }, + cdrs_tokio::types::value::Value::from(context_json.as_str()), + ]); + + let old = std::mem::replace(batch, BatchQueryBuilder::new()); + *batch = old.add_query(insert_cql, insert_qv); + + enqueued += 1; + } + + Ok(enqueued) +} + +/// Delete a row from an index table using index keys and base table keys. +/// +/// Adds a parameterized DELETE statement to the batch. +/// Cassandra requires ALL PRIMARY KEY columns for DELETE. +pub(crate) fn delete_index_row_multi( + batch: &mut BatchQueryBuilder, + account_keyspace: &str, + idx_table: &str, + item: &Item, + index_ks: &[KeySchemaElement], + base_ks: &[KeySchemaElement], + idx_sks: &[(&str, ScalarAttributeType)], + base_sks: &[(&str, ScalarAttributeType)], +) -> Result<(), StorageError> { + let idx_pk_text = composite_pk_to_text(item, index_ks)?; + let base_pk_text = composite_pk_to_text(item, base_ks)?; + + let mut where_cols = vec!["pk = ?".to_owned()]; + let mut values: Vec = + vec![cdrs_tokio::types::value::Value::from(idx_pk_text.as_str())]; + + // Index sort keys (clustering) + for (i, &(sk_name, sk_type)) in idx_sks.iter().enumerate() { + let col = if i == 0 { + sk_column(sk_type).to_owned() + } else { + sk_column_n(i, sk_type) + }; + where_cols.push(format!("{col} = ?")); + if let Some(sk_val) = item.get(sk_name) { + let sk = parse_sk(sk_val, sk_type)?; + values.push(sk_to_value(&sk)); + } else { + values.push(cdrs_tokio::types::value::Value::NotSet); + } + } + + // Base partition key (clustering) + where_cols.push("base_pk = ?".to_owned()); + values.push(cdrs_tokio::types::value::Value::from(base_pk_text.as_str())); + + // Base sort keys (clustering) + for (i, &(sk_name, sk_type)) in base_sks.iter().enumerate() { + let col = if i == 0 { + format!("base_{}", sk_column(sk_type)) + } else { + format!("base_{}", sk_column_n(i, sk_type)) + }; + where_cols.push(format!("{col} = ?")); + if let Some(sk_val) = item.get(sk_name) { + let sk = parse_sk(sk_val, sk_type)?; + values.push(sk_to_value(&sk)); + } else { + values.push(cdrs_tokio::types::value::Value::NotSet); + } + } + + let cql = format!( + "DELETE FROM {}.{} WHERE {}", + account_keyspace, + idx_table, + where_cols.join(" AND ") + ); + let qv = cdrs_tokio::query::QueryValues::SimpleValues(values); + let old = std::mem::replace(batch, BatchQueryBuilder::new()); + *batch = old.add_query(cql, qv); + Ok(()) +} + +/// Insert a row into an index table with multi-part key support. +/// +/// Adds a parameterized INSERT statement to the batch. +#[allow(clippy::too_many_arguments)] +pub(crate) fn insert_index_row_multi( + batch: &mut BatchQueryBuilder, + account_keyspace: &str, + idx_table: &str, + item: &Item, + projected: &Item, + index_ks: &[KeySchemaElement], + base_ks: &[KeySchemaElement], + idx_sks: &[(&str, ScalarAttributeType)], + base_sks: &[(&str, ScalarAttributeType)], +) -> Result<(), StorageError> { + let idx_pk_text = composite_pk_to_text(item, index_ks)?; + let base_pk_text = composite_pk_to_text(item, base_ks)?; + + let item_json = + serde_json::to_string(projected).map_err(|e| StorageError::Internal(e.to_string()))?; + + let mut cols = vec!["pk".to_owned()]; + let mut values: Vec = vec![cdrs_tokio::types::value::Value::from(idx_pk_text.as_str())]; + + // Index SK values + for (i, &(sk_name, sk_type)) in idx_sks.iter().enumerate() { + cols.push(sk_column_n(i, sk_type)); + if let Some(sk_val) = item.get(sk_name) { + values.push(sk_to_value(&parse_sk(sk_val, sk_type)?)); + } else { + values.push(cdrs_tokio::types::value::Value::NotSet); + } + } + + // Base keys + cols.push("base_pk".to_owned()); + values.push(cdrs_tokio::types::value::Value::from(base_pk_text.as_str())); + + for (i, &(sk_name, sk_type)) in base_sks.iter().enumerate() { + let col = if i == 0 { + format!("base_{}", sk_column(sk_type)) + } else { + format!("base_{}", sk_column_n(i, sk_type)) + }; + cols.push(col); + if let Some(sk_val) = item.get(sk_name) { + values.push(sk_to_value(&parse_sk(sk_val, sk_type)?)); + } else { + values.push(cdrs_tokio::types::value::Value::NotSet); + } + } + + cols.push("item_data".to_owned()); + values.push(item_json.as_str().into()); + + let placeholders = vec!["?"; cols.len()].join(", "); + let cql = format!( + "INSERT INTO {}.{} ({}) VALUES ({})", + account_keyspace, + idx_table, + cols.join(", "), + placeholders + ); + let qv = cdrs_tokio::query::QueryValues::SimpleValues(values); + let old = std::mem::replace(batch, BatchQueryBuilder::new()); + *batch = old.add_query(cql, qv); + Ok(()) +} + +/// Convert a `SortKeyValue` to a cdrs_tokio bound `Value`. +pub(crate) fn sk_to_value(sk: &SortKeyValue) -> cdrs_tokio::types::value::Value { + match sk { + SortKeyValue::S(s) => s.as_str().into(), + SortKeyValue::N(n) => super::decimal_to_value(n), + SortKeyValue::B(b) => cdrs_tokio::types::value::Value::from( + cdrs_tokio::types::blob::Blob::new(b.to_vec()), + ), + } +} + +/// Delete index metadata and drop index data tables for a given table. +/// +/// Called by: +/// - `process_control_plane_transitions` when table is DELETING → deleted +/// - `update_table` when GSI is deleted via UpdateTable API +/// +/// Returns list of index IDs that were deleted (for caller logging/tracking). +pub(crate) async fn delete_indexes_for_table( + session: &Arc, + catalog_keyspace: &str, + account_keyspace: &str, + table_id: &str, + engine: &crate::CassandraEngine, +) -> Result, StorageError> { + // Collect index IDs + let index_query = format!( + "SELECT index_id FROM {}.indexes WHERE table_id = ?", + catalog_keyspace + ); + + let rows = query_rows( + session, + &index_query, + query_values!(table_id), + "delete_indexes_for_table", + ) + .await?; + + let mut index_ids = Vec::new(); + for row in rows { + let index_id: String = get_column(&row, "index_id", "delete_indexes_for_table")?; + index_ids.push(index_id); + } + + // Delete index metadata from catalog + let delete_query = format!( + "DELETE FROM {}.indexes WHERE table_id = ?", + catalog_keyspace + ); + + crate::cassandra_util::execute( + session, + &delete_query, + query_values!(table_id), + "delete_indexes_for_table", + ) + .await?; + + // Drop index data tables + for index_id in &index_ids { + engine + .drop_index_data_table(account_keyspace, index_id) + .await?; + } + + Ok(index_ids) +} + +/// Delete a specific index by name for a table. +/// +/// Called by `update_table` when a GSI is deleted via UpdateTable API. +/// +/// Returns the index_id that was deleted. +pub(crate) async fn delete_index_by_name( + session: &Arc, + catalog_keyspace: &str, + account_keyspace: &str, + table_id: &str, + index_name: &str, + engine: &crate::CassandraEngine, +) -> Result { + // Get index_id first + let query = format!( + "SELECT index_id FROM {}.indexes WHERE table_id = ? AND index_name = ?", + catalog_keyspace + ); + + let row = crate::cassandra_util::query_optional( + session, + &query, + query_values!(table_id, index_name), + "delete_index_by_name", + ) + .await? + .ok_or_else(|| StorageError::IndexNotFound(index_name.to_owned()))?; + + let index_id: String = get_column(&row, "index_id", "delete_index_by_name")?; + + // Delete from catalog + let delete_query = format!( + "DELETE FROM {}.indexes WHERE table_id = ? AND index_name = ?", + catalog_keyspace + ); + + crate::cassandra_util::execute( + session, + &delete_query, + query_values!(table_id, index_name), + "delete_index_by_name", + ) + .await?; + + // Drop index data table + engine + .drop_index_data_table(account_keyspace, &index_id) + .await?; + + Ok(index_id) +} + +#[cfg(test)] +mod tests { + use super::*; + use extenddb_core::types::{AttributeValue, KeyType, ProjectionType}; + + #[test] + fn test_project_item_keys_only() { + let mut item = Item::new(); + item.insert("pk".to_owned(), AttributeValue::S("test".to_owned())); + item.insert("sk".to_owned(), AttributeValue::N("123".to_owned())); + item.insert("data".to_owned(), AttributeValue::S("value".to_owned())); + + let base_ks = vec![ + KeySchemaElement { + attribute_name: "pk".to_owned(), + key_type: KeyType::Hash, + }, + KeySchemaElement { + attribute_name: "sk".to_owned(), + key_type: KeyType::Range, + }, + ]; + + let index_ks = vec![KeySchemaElement { + attribute_name: "gsi_pk".to_owned(), + key_type: KeyType::Hash, + }]; + + let projection = Projection { + projection_type: ProjectionType::KeysOnly, + non_key_attributes: None, + }; + + item.insert( + "gsi_pk".to_owned(), + AttributeValue::S("gsi_value".to_owned()), + ); + + let projected = project_item_for_index(&item, &index_ks, &base_ks, &projection); + + assert_eq!(projected.len(), 3); + assert!(projected.contains_key("pk")); + assert!(projected.contains_key("sk")); + assert!(projected.contains_key("gsi_pk")); + assert!(!projected.contains_key("data")); + } + + #[test] + fn test_item_has_index_keys() { + let mut item = Item::new(); + item.insert("pk".to_owned(), AttributeValue::S("test".to_owned())); + item.insert("gsi_pk".to_owned(), AttributeValue::S("value".to_owned())); + + let index_ks = vec![KeySchemaElement { + attribute_name: "gsi_pk".to_owned(), + key_type: KeyType::Hash, + }]; + + assert!(item_has_index_keys(&item, &index_ks)); + + let missing_ks = vec![KeySchemaElement { + attribute_name: "missing".to_owned(), + key_type: KeyType::Hash, + }]; + + assert!(!item_has_index_keys(&item, &missing_ks)); + } + + #[test] + fn test_effective_delay() { + let idx = IndexMeta { + index_name: "test".to_owned(), + index_id: "id".to_owned(), + index_type: "GSI".to_owned(), + key_schema: vec![], + projection: Projection { + projection_type: ProjectionType::All, + non_key_attributes: None, + }, + propagation_delay_ms: Some(0), + }; + + assert_eq!(effective_delay(&idx, 1000), 0); + + let idx_with_delay = IndexMeta { + propagation_delay_ms: Some(500), + ..idx.clone() + }; + assert_eq!(effective_delay(&idx_with_delay, 1000), 500); + + let idx_default = IndexMeta { + propagation_delay_ms: None, + ..idx + }; + assert_eq!(effective_delay(&idx_default, 1000), 1000); + } +} diff --git a/crates/storage-cassandra/src/data/mod.rs b/crates/storage-cassandra/src/data/mod.rs new file mode 100644 index 00000000..9657af56 --- /dev/null +++ b/crates/storage-cassandra/src/data/mod.rs @@ -0,0 +1,349 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Data table operations for Cassandra. + +use cdrs_tokio::cluster::TcpConnectionManager; +use cdrs_tokio::cluster::session::Session; +use cdrs_tokio::load_balancing::RoundRobinLoadBalancingStrategy; +use cdrs_tokio::transport::TransportTcp; +use extenddb_core::expression::ExpressionMaps; +use extenddb_core::types::Item; +use extenddb_storage::error::StorageError; +use extenddb_storage::util::SortKeyValue; + +mod condition; +mod data_engine; +pub mod ddl; +mod delete_item; +pub mod index; +mod put_get_item; +mod query; +mod query_helpers; +mod scan; +pub mod transaction_ledger; +mod transactions; +mod update_item; + +use condition::check_condition; +use extenddb_core::expression; + +/// Resolve an expression (placeholder) to an `AttributeValue`. +pub(crate) fn resolve_expr_to_av( + expr: &expression::Expr, + maps: &ExpressionMaps, +) -> Result { + match expr { + expression::Expr::Placeholder(name) => maps + .resolve_value(name) + .cloned() + .map_err(|e| StorageError::Validation(e.to_string())), + _ => Err(StorageError::Internal( + "expected placeholder in key condition".to_owned(), + )), + } +} + +type CassandraSession = Session< + TransportTcp, + TcpConnectionManager, + RoundRobinLoadBalancingStrategy, +>; + +/// Deserialize an `item_data` text value into an `Item`. +pub(crate) fn json_to_item(text: String) -> Result { + serde_json::from_str(&text).map_err(|e| StorageError::Internal(e.to_string())) +} + +/// Convert a DynamoDB numeric value (`BigDecimal`) into the cdrs-tokio +/// `Decimal` wire type. +/// +/// DynamoDB's `N` type is an arbitrary-precision decimal. Cassandra's `decimal` +/// type is encoded as a 4-byte `scale` followed by the two's-complement +/// `unscaled` value (a varint), representing `unscaled * 10^(-scale)`. +/// `BigDecimal::as_bigint_and_exponent()` yields exactly that pair (the exponent +/// is the scale), so the mapping is lossless for arbitrary precision. +pub(crate) fn bigdecimal_to_cql_decimal( + n: &bigdecimal::BigDecimal, +) -> cdrs_tokio::types::decimal::Decimal { + let (unscaled, scale) = n.as_bigint_and_exponent(); + cdrs_tokio::types::decimal::Decimal::new(unscaled, scale as i32) +} + +/// Bind a DynamoDB numeric value (`BigDecimal`) as a Cassandra `decimal` bound +/// parameter `Value`. +/// +/// This replaces the previous workaround that bound `N` values as strings, +/// which the Cassandra `decimal` column rejected ("Expected 0 or at least 4 +/// bytes") and which broke column-level numeric comparisons. +pub(crate) fn decimal_to_value(n: &bigdecimal::BigDecimal) -> cdrs_tokio::types::value::Value { + cdrs_tokio::types::value::Value::from(bigdecimal_to_cql_decimal(n)) +} + +/// Execute a query with pk and sort key, returning the result. +/// +/// Helper to reduce repetitive match-on-sk-type pattern. +pub(crate) async fn query_with_pk_sk( + session: &CassandraSession, + query: &str, + pk: &str, + sk: &SortKeyValue, +) -> Result { + match sk { + SortKeyValue::S(s) => { + session + .query_with_values(query, cdrs_tokio::query_values!(pk, s.as_str())) + .await + } + SortKeyValue::N(n) => { + session + .query_with_values(query, cdrs_tokio::query_values!(pk, decimal_to_value(n))) + .await + } + SortKeyValue::B(b) => { + session + .query_with_values(query, cdrs_tokio::query_values!(pk, b.to_vec())) + .await + } + } + .map_err(|e| StorageError::Internal(format!("Query failed: {}", e))) +} + +/// Execute a query with pk, sort key, and item_data, returning the result. +/// +/// Helper for INSERT/UPDATE operations. +pub(crate) async fn query_with_pk_sk_item( + session: &CassandraSession, + query: &str, + pk: &str, + sk: &SortKeyValue, + item_text: &str, +) -> Result { + match sk { + SortKeyValue::S(s) => { + session + .query_with_values(query, cdrs_tokio::query_values!(pk, s.as_str(), item_text)) + .await + } + SortKeyValue::N(n) => { + session + .query_with_values( + query, + cdrs_tokio::query_values!(pk, decimal_to_value(n), item_text), + ) + .await + } + SortKeyValue::B(b) => { + session + .query_with_values(query, cdrs_tokio::query_values!(pk, b.to_vec(), item_text)) + .await + } + } + .map_err(|e| StorageError::Internal(format!("Query failed: {}", e))) +} + +/// Execute a query with `(pk, sk, txn_id_bytes)` bound parameters. +/// +/// Used for: rollback DELETE, commit DELETE. +pub(crate) async fn query_with_pk_sk_txnid( + session: &CassandraSession, + query: &str, + pk: &str, + sk: &SortKeyValue, + txn_id: cdrs_tokio::types::value::Bytes, +) -> Result { + match sk { + SortKeyValue::S(s) => { + session + .query_with_values(query, cdrs_tokio::query_values!(pk, s.as_str(), txn_id)) + .await + } + SortKeyValue::N(n) => { + session + .query_with_values( + query, + cdrs_tokio::query_values!(pk, decimal_to_value(n), txn_id), + ) + .await + } + SortKeyValue::B(b) => { + session + .query_with_values(query, cdrs_tokio::query_values!(pk, b.to_vec(), txn_id)) + .await + } + } + .map_err(|e| StorageError::Internal(format!("Query failed: {}", e))) +} + +/// Execute a query with `(txn_id_bytes, txn_timestamp, pk, sk)` bound parameters. +/// +/// Used for: prepare UPDATE existing item. +pub(crate) async fn query_with_txnid_ts_pk_sk( + session: &CassandraSession, + query: &str, + txn_id: cdrs_tokio::types::value::Bytes, + txn_timestamp: i64, + pk: &str, + sk: &SortKeyValue, +) -> Result { + match sk { + SortKeyValue::S(s) => { + session + .query_with_values( + query, + cdrs_tokio::query_values!(txn_id, txn_timestamp, pk, s.as_str()), + ) + .await + } + SortKeyValue::N(n) => { + session + .query_with_values( + query, + cdrs_tokio::query_values!(txn_id, txn_timestamp, pk, decimal_to_value(n)), + ) + .await + } + SortKeyValue::B(b) => { + session + .query_with_values( + query, + cdrs_tokio::query_values!(txn_id, txn_timestamp, pk, b.to_vec()), + ) + .await + } + } + .map_err(|e| StorageError::Internal(format!("Query failed: {}", e))) +} + +/// Execute a query with `(pk, sk, item_text, txn_id_bytes, txn_timestamp)` bound parameters. +/// +/// Used for: prepare INSERT new item. +pub(crate) async fn query_with_pk_sk_item_txnid_ts( + session: &CassandraSession, + query: &str, + pk: &str, + sk: &SortKeyValue, + item_text: &str, + txn_id: cdrs_tokio::types::value::Bytes, + txn_timestamp: i64, +) -> Result { + match sk { + SortKeyValue::S(s) => { + session + .query_with_values( + query, + cdrs_tokio::query_values!(pk, s.as_str(), item_text, txn_id, txn_timestamp), + ) + .await + } + SortKeyValue::N(n) => { + session + .query_with_values( + query, + cdrs_tokio::query_values!( + pk, + decimal_to_value(n), + item_text, + txn_id, + txn_timestamp + ), + ) + .await + } + SortKeyValue::B(b) => { + session + .query_with_values( + query, + cdrs_tokio::query_values!(pk, b.to_vec(), item_text, txn_id, txn_timestamp), + ) + .await + } + } + .map_err(|e| StorageError::Internal(format!("Query failed: {}", e))) +} + +/// Execute a query with `(item_text, txn_timestamp, pk, sk, txn_id_bytes)` bound parameters. +/// +/// Used for: commit PUT/UPDATE. +pub(crate) async fn query_with_item_ts_pk_sk_txnid( + session: &CassandraSession, + query: &str, + item_text: &str, + txn_timestamp: i64, + pk: &str, + sk: &SortKeyValue, + txn_id: cdrs_tokio::types::value::Bytes, +) -> Result { + match sk { + SortKeyValue::S(s) => { + session + .query_with_values( + query, + cdrs_tokio::query_values!(item_text, txn_timestamp, pk, s.as_str(), txn_id), + ) + .await + } + SortKeyValue::N(n) => { + session + .query_with_values( + query, + cdrs_tokio::query_values!( + item_text, + txn_timestamp, + pk, + decimal_to_value(n), + txn_id + ), + ) + .await + } + SortKeyValue::B(b) => { + session + .query_with_values( + query, + cdrs_tokio::query_values!(item_text, txn_timestamp, pk, b.to_vec(), txn_id), + ) + .await + } + } + .map_err(|e| StorageError::Internal(format!("Query failed: {}", e))) +} + +/// Execute a SELECT query by primary key (pk only, or pk + sort key). +/// +/// Builds the appropriate query based on whether a sort key is present, +/// executes it, and returns the first row if any. +pub(crate) async fn select_by_pk( + session: &CassandraSession, + keyspace: &str, + table: &str, + columns: &str, + pk: &str, + sk: Option<&SortKeyValue>, + sk_col: Option<&str>, +) -> Result, StorageError> { + let result = if let (Some(sk), Some(sk_col)) = (sk, sk_col) { + let query = format!( + "SELECT {} FROM {}.{} WHERE pk = ? AND {} = ?", + columns, keyspace, table, sk_col + ); + query_with_pk_sk(session, &query, pk, sk).await + } else { + let query = format!( + "SELECT {} FROM {}.{} WHERE pk = ?", + columns, keyspace, table + ); + session + .query_with_values(&query, cdrs_tokio::query_values!(pk)) + .await + .map_err(|e| StorageError::Internal(format!("Query failed: {}", e))) + }?; + + let rows = result + .response_body() + .map_err(|e| StorageError::Internal(e.to_string()))? + .into_rows() + .unwrap_or_default(); + + Ok(rows.into_iter().next()) +} diff --git a/crates/storage-cassandra/src/data/put_get_item.rs b/crates/storage-cassandra/src/data/put_get_item.rs new file mode 100644 index 00000000..b87295d7 --- /dev/null +++ b/crates/storage-cassandra/src/data/put_get_item.rs @@ -0,0 +1,441 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! `put_item` and `get_item` implementations for the Cassandra backend. + +use cdrs_tokio::consistency::Consistency; +use cdrs_tokio::query::BatchQueryBuilder; +use cdrs_tokio::types::IntoRustByName; +use extenddb_core::expression::{Expr, ExpressionMaps}; +use extenddb_core::types::{Item, TableKeyInfo}; +use extenddb_storage::StreamCapture; +use extenddb_storage::error::StorageError; +use extenddb_storage::util::{ + composite_pk_to_text, parse_sk, pk_to_text, sk_column, sk_info, +}; + +use super::ddl::data_table_name; +use super::{json_to_item, query_with_pk_sk, query_with_pk_sk_item}; +use crate::CassandraEngine; +use crate::stream_util::stream_record_statement; + +impl CassandraEngine { + /// Implementation of `DataEngine::put_item`. + pub(crate) async fn put_item_impl( + &self, + key_info: &TableKeyInfo, + item: Item, + return_old: bool, + condition: Option<&Expr>, + maps: &ExpressionMaps, + stream: Option<&StreamCapture>, + ) -> Result, StorageError> { + let data_keyspace = self.account_keyspace(&key_info.account_id); + let catalog_keyspace = self.catalog_keyspace(); + let ddb_table = data_table_name(&key_info.table_id); + + let pk_text = composite_pk_to_text(&item, &key_info.key_schema)?; + + let item_json = + serde_json::to_value(&item).map_err(|e| StorageError::Internal(e.to_string()))?; + let item_text = item_json.to_string(); + + // Fetch indexes for GSI/LSI updates + let indexes = super::index::fetch_indexes_for_table( + &key_info.table_id, + &self.session, + &catalog_keyspace, + ) + .await?; + let sys_delay = if indexes.is_empty() { + 0 + } else { + self.gsi_default_delay_ms + .load(std::sync::atomic::Ordering::Relaxed) + }; + + // Always use read-then-write path to check prepared_txn_id for transaction safety. + // This ensures non-transactional writes cannot corrupt in-flight transactions. + + if let Some((sk_name, sk_type)) = + sk_info(&key_info.key_schema, &key_info.attribute_definitions) + { + // Table has sort key + let sk_value = item + .get(sk_name) + .ok_or_else(|| StorageError::Internal("missing sort key".to_owned()))?; + let sk = parse_sk(sk_value, sk_type)?; + let sk_col = sk_column(sk_type); + + // Read old item including prepared_txn_id for transaction conflict detection + let select_query = format!( + "SELECT item_data, prepared_txn_id FROM {}.{} WHERE pk = ? AND {} = ?", + data_keyspace, ddb_table, sk_col + ); + + let old_result = + query_with_pk_sk(&self.session, &select_query, pk_text.as_ref(), &sk).await?; + + let body = old_result + .response_body() + .map_err(|e| StorageError::Internal(format!("Parse response: {}", e)))?; + + let (old_item_opt, has_prepared_txn) = if let Some(rows) = body.into_rows() { + if let Some(row) = rows.into_iter().next() { + let item_data: String = row.get_r_by_name("item_data").map_err(|e| { + StorageError::Internal(format!("Parse item_data: {}", e)) + })?; + let prepared_txn_id: Option = + row.get_by_name("prepared_txn_id").ok().flatten(); + (Some(json_to_item(item_data)?), prepared_txn_id.is_some()) + } else { + (None, false) + } + } else { + (None, false) + }; + + // Reject if item is part of an in-flight transaction + if has_prepared_txn { + return Err(StorageError::TransactionCanceled(vec![ + extenddb_core::types::CancellationReason { + code: "TransactionConflict".to_owned(), + message: Some( + "Item is being modified by a concurrent transaction".to_owned(), + ), + item: None, + }, + ])); + } + + // Evaluate condition against existing item (or empty if doesn't exist) + let condition_item = if let Some(ref existing) = old_item_opt { + existing + } else { + &std::collections::BTreeMap::new() + }; + match super::check_condition(condition, condition_item, maps) { + Ok(()) => {} + Err(StorageError::ConditionFailed(_)) => { + return Err(StorageError::ConditionFailed(old_item_opt)); + } + Err(e) => return Err(e), + } + + let stream_stmt = stream.and_then(|cap| { + stream_record_statement( + &data_keyspace, + &key_info.table_id, + key_info, + old_item_opt.as_ref(), + Some(&item), + cap, + &self.hlc, + self.stream_retention_seconds, + ) + }); + + if indexes.is_empty() && stream_stmt.is_none() { + // Fast path: no batch needed. + let insert_query = format!( + "INSERT INTO {}.{} (pk, {}, item_data) VALUES (?, ?, ?)", + data_keyspace, ddb_table, sk_col + ); + query_with_pk_sk_item( + &self.session, + &insert_query, + pk_text.as_ref(), + &sk, + &item_text, + ) + .await?; + } else { + // LOGGED BATCH: item insert + optional index updates + optional stream record. + let insert_cql = format!( + "INSERT INTO {}.{} (pk, {}, item_data) VALUES (?, ?, ?)", + data_keyspace, ddb_table, sk_col + ); + let insert_qv = cdrs_tokio::query::QueryValues::SimpleValues(vec![ + cdrs_tokio::types::value::Value::from(pk_text.as_str()), + super::index::sk_to_value(&sk), + item_text.as_str().into(), + ]); + let mut batch = BatchQueryBuilder::new() + .with_consistency(Consistency::LocalQuorum) + .add_query(insert_cql, insert_qv); + + if !indexes.is_empty() { + super::index::sync_indexes( + &mut batch, + &data_keyspace, + &key_info.key_schema, + &key_info.attribute_definitions, + &indexes, + old_item_opt.as_ref(), + Some(&item), + sys_delay, + )?; + } + + let async_enqueued = if !indexes.is_empty() { + super::index::enqueue_async_indexes( + &self.session, + &mut batch, + &data_keyspace, + key_info, + &indexes, + old_item_opt.as_ref(), + Some(&item), + sys_delay, + ).await? + } else { + 0 + }; + + if let Some(stmt) = stream_stmt { + batch = batch.add_query( + stmt, + cdrs_tokio::query::QueryValues::SimpleValues(vec![]), + ); + } + + self.session + .batch(batch.build().map_err(|e| StorageError::Internal(e.to_string()))?) + .await + .map_err(|e| StorageError::Internal(format!("Batch execution: {}", e)))?; + + if async_enqueued > 0 { + self.gsi_queue.notify_workers(); + } + } + + Ok(if return_old { old_item_opt } else { None }) + } else { + // PK-only table (no sort key) + + // Read old item including prepared_txn_id for transaction conflict detection + let select_query = format!( + "SELECT item_data, prepared_txn_id FROM {}.{} WHERE pk = ?", + data_keyspace, ddb_table + ); + + let old_result = self + .session + .query_with_values( + &select_query, + cdrs_tokio::query_values!(pk_text.as_ref() as &str), + ) + .await + .map_err(|e| StorageError::Internal(format!("Select for put_item: {}", e)))?; + + let body = old_result + .response_body() + .map_err(|e| StorageError::Internal(format!("Parse response: {}", e)))?; + + let (old_item_opt, has_prepared_txn) = if let Some(rows) = body.into_rows() { + if let Some(row) = rows.into_iter().next() { + let item_data: String = row.get_r_by_name("item_data").map_err(|e| { + StorageError::Internal(format!("Parse item_data: {}", e)) + })?; + let prepared_txn_id: Option = + row.get_by_name("prepared_txn_id").ok().flatten(); + (Some(json_to_item(item_data)?), prepared_txn_id.is_some()) + } else { + (None, false) + } + } else { + (None, false) + }; + + // Reject if item is part of an in-flight transaction + if has_prepared_txn { + return Err(StorageError::TransactionCanceled(vec![ + extenddb_core::types::CancellationReason { + code: "TransactionConflict".to_owned(), + message: Some( + "Item is being modified by a concurrent transaction".to_owned(), + ), + item: None, + }, + ])); + } + + // Evaluate condition + let condition_item = if let Some(ref existing) = old_item_opt { + existing + } else { + &std::collections::BTreeMap::new() + }; + match super::check_condition(condition, condition_item, maps) { + Ok(()) => {} + Err(StorageError::ConditionFailed(_)) => { + return Err(StorageError::ConditionFailed(old_item_opt)); + } + Err(e) => return Err(e), + } + + let stream_stmt = stream.and_then(|cap| { + stream_record_statement( + &data_keyspace, + &key_info.table_id, + key_info, + old_item_opt.as_ref(), + Some(&item), + cap, + &self.hlc, + self.stream_retention_seconds, + ) + }); + + if indexes.is_empty() && stream_stmt.is_none() { + // Fast path: no batch needed. + let insert_query = format!( + "INSERT INTO {}.{} (pk, item_data) VALUES (?, ?)", + data_keyspace, ddb_table + ); + self.session + .query_with_values( + &insert_query, + cdrs_tokio::query_values!(pk_text.as_ref() as &str, item_text.as_str()), + ) + .await + .map_err(|e| StorageError::Internal(format!("Insert item: {}", e)))?; + } else { + // LOGGED BATCH: item insert + optional index updates + optional stream record. + let insert_cql = format!( + "INSERT INTO {}.{} (pk, item_data) VALUES (?, ?)", + data_keyspace, ddb_table + ); + let insert_qv = cdrs_tokio::query::QueryValues::SimpleValues(vec![ + cdrs_tokio::types::value::Value::from(pk_text.as_str()), + item_text.as_str().into(), + ]); + let mut batch = BatchQueryBuilder::new() + .with_consistency(Consistency::LocalQuorum) + .add_query(insert_cql, insert_qv); + + if !indexes.is_empty() { + super::index::sync_indexes( + &mut batch, + &data_keyspace, + &key_info.key_schema, + &key_info.attribute_definitions, + &indexes, + old_item_opt.as_ref(), + Some(&item), + sys_delay, + )?; + } + + let async_enqueued = if !indexes.is_empty() { + super::index::enqueue_async_indexes( + &self.session, + &mut batch, + &data_keyspace, + key_info, + &indexes, + old_item_opt.as_ref(), + Some(&item), + sys_delay, + ).await? + } else { + 0 + }; + + if let Some(stmt) = stream_stmt { + batch = batch.add_query( + stmt, + cdrs_tokio::query::QueryValues::SimpleValues(vec![]), + ); + } + + self.session + .batch(batch.build().map_err(|e| StorageError::Internal(e.to_string()))?) + .await + .map_err(|e| StorageError::Internal(format!("Batch execution: {}", e)))?; + + if async_enqueued > 0 { + self.gsi_queue.notify_workers(); + } + } + + Ok(if return_old { old_item_opt } else { None }) + } + } + + /// Implementation of `DataEngine::get_item`. + pub(crate) async fn get_item_impl( + &self, + key_info: &TableKeyInfo, + key: &Item, + ) -> Result, StorageError> { + let data_keyspace = self.account_keyspace(&key_info.account_id); + let ddb_table = data_table_name(&key_info.table_id); + + let pk_name = &key_info.key_schema[0].attribute_name; + let pk_value = key + .get(pk_name) + .ok_or_else(|| StorageError::Internal("missing partition key".to_owned()))?; + let pk_text = pk_to_text(pk_value)?; + + if let Some((sk_name, sk_type)) = + sk_info(&key_info.key_schema, &key_info.attribute_definitions) + { + // Table has sort key + let sk_value = key + .get(sk_name) + .ok_or_else(|| StorageError::Internal("missing sort key".to_owned()))?; + let sk = parse_sk(sk_value, sk_type)?; + let sk_col = sk_column(sk_type); + + let query = format!( + "SELECT item_data FROM {}.{} WHERE pk = ? AND {} = ?", + data_keyspace, ddb_table, sk_col + ); + + let result = query_with_pk_sk(&self.session, &query, pk_text.as_ref(), &sk).await?; + + let body = result + .response_body() + .map_err(|e| StorageError::Internal(format!("Parse response: {}", e)))?; + + if let Some(rows) = body.into_rows() { + if let Some(row) = rows.into_iter().next() { + let item_data: String = row + .get_r_by_name("item_data") + .map_err(|e| StorageError::Internal(format!("Parse item_data: {}", e)))?; + return Ok(Some(json_to_item(item_data)?)); + } + } + + Ok(None) + } else { + // PK-only table + let query = format!( + "SELECT item_data FROM {}.{} WHERE pk = ?", + data_keyspace, ddb_table + ); + + let result = self + .session + .query_with_values(&query, cdrs_tokio::query_values!(pk_text.as_ref() as &str)) + .await + .map_err(|e| StorageError::Internal(format!("Get item: {}", e)))?; + + let body = result + .response_body() + .map_err(|e| StorageError::Internal(format!("Parse response: {}", e)))?; + + if let Some(rows) = body.into_rows() { + if let Some(row) = rows.into_iter().next() { + let item_data: String = row + .get_r_by_name("item_data") + .map_err(|e| StorageError::Internal(format!("Parse item_data: {}", e)))?; + return Ok(Some(json_to_item(item_data)?)); + } + } + + Ok(None) + } + } +} diff --git a/crates/storage-cassandra/src/data/query.rs b/crates/storage-cassandra/src/data/query.rs new file mode 100644 index 00000000..b267a216 --- /dev/null +++ b/crates/storage-cassandra/src/data/query.rs @@ -0,0 +1,730 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Query implementation for Cassandra backend. + +use extenddb_core::expression::{CompareOp, ExpressionMaps, KeyCondition, SortKeyCondition}; +use extenddb_core::types::{Item, ScalarAttributeType, TableKeyInfo}; +use extenddb_storage::error::StorageError; +use extenddb_storage::util::{SortKeyValue, parse_sk, pk_to_text, sk_column, sk_info}; + +/// Extension trait for SortKeyValue to get scalar type. +trait SortKeyValueExt { + fn scalar_type(&self) -> ScalarAttributeType; +} + +impl SortKeyValueExt for SortKeyValue { + fn scalar_type(&self) -> ScalarAttributeType { + match self { + SortKeyValue::S(_) => ScalarAttributeType::S, + SortKeyValue::N(_) => ScalarAttributeType::N, + SortKeyValue::B(_) => ScalarAttributeType::B, + } + } +} + +use super::ddl::data_table_name; +use super::query_helpers::{ + query_with_pk_pk_sk, query_with_pk_sk, query_with_pk_sk_pk, query_with_pk_sk_pk_sk, query_with_pk_sk_sk, query_with_pk_sk_sk_sk, +}; +use super::{json_to_item, resolve_expr_to_av}; +use crate::CassandraEngine; +use crate::cassandra_util; + +/// Extra pagination bind values for index queries. +/// +/// Cassandra variant of PostgreSQL's PaginationBinds. Unlike PostgreSQL which uses +/// OR clauses, Cassandra requires splitting into two queries when paginating through +/// index results with base table key tie-breakers. +enum PaginationBinds { + /// Base table query or index query with no extra pagination binds needed. + None, + /// Index query where base table has no SK — only `base_pk` as tie-breaker. + BasePkOnly { pk_text: String }, + /// Index query where base table has a SK — `base_sk` as tie-breaker. + BaseSkOnly { sk: SortKeyValue }, + /// Hash-only index where base table has a SK — both `base_pk` and `base_sk`. + BasePkAndSk { pk_text: String, sk: SortKeyValue }, +} + +/// Compute upper bound for begins_with on strings. +/// Appends the maximum Unicode codepoint to create an exclusive upper bound. +fn string_upper_bound(prefix: &str) -> String { + format!("{}\u{10FFFF}", prefix) +} + +/// Compute upper bound for begins_with on binary data. +/// Increments the last byte, extending if needed for overflow. +fn binary_upper_bound(prefix: &[u8]) -> Vec { + let mut upper = prefix.to_vec(); + + // Try to increment the last byte + for i in (0..upper.len()).rev() { + if upper[i] < 255 { + upper[i] += 1; + return upper; + } + // This byte is 255, set to 0 and continue to next byte + upper[i] = 0; + } + + // All bytes were 255 - prepend a 1 byte + let mut result = vec![1]; + result.extend_from_slice(&upper); + result +} + +/// Helper to determine base table sort key info for index queries. +/// +/// Matches PostgreSQL pattern where base_sk_info is derived from base_key_schema. +/// Used for ORDER BY (sub-sort when index SKs equal) and pagination (compound keys). +fn base_sk_info( + key_info: &TableKeyInfo, + index_name: Option<&str>, +) -> Option<(String, ScalarAttributeType)> { + if index_name.is_some() { + sk_info(&key_info.base_key_schema, &key_info.attribute_definitions) + .map(|(name, ty)| (name.to_owned(), ty)) + } else { + None + } +} + +impl CassandraEngine { + /// Implementation of DataEngine::query. + #[allow(clippy::too_many_arguments)] + pub(crate) async fn query_impl( + &self, + key_info: &TableKeyInfo, + key_condition: &KeyCondition, + _maps: &ExpressionMaps, + forward: bool, + limit: Option, + exclusive_start_key: Option<&Item>, + index_name: Option<&str>, + ) -> Result<(Vec, Option), StorageError> { + // Determine table to query (base or index) + let (table_name, query_key_schema, is_lsi) = if let Some(idx_name) = index_name { + let catalog_keyspace = self.catalog_keyspace(); + let index = super::index::fetch_index_by_name( + &key_info.table_id, + idx_name, + &self.session_arc(), + &catalog_keyspace, + ) + .await?; + let is_lsi = index.index_type == "LSI"; + ( + super::ddl::index_table_name(&index.index_id), + index.key_schema, + is_lsi, + ) + } else { + ( + data_table_name(&key_info.table_id), + key_info.key_schema.clone(), + false, + ) + }; + + // Step 1: Resolve partition key value + let pk_av = resolve_expr_to_av(&key_condition.pk_value, _maps)?; + let pk_text = pk_to_text(&pk_av)?.into_owned(); + + // Step 2: Determine if there's a sort key condition + let sk_info_opt = sk_info(&query_key_schema, &key_info.attribute_definitions); + + // Step 3: Build query + let account_keyspace = self.account_keyspace(&key_info.account_id); + + let mut query = format!( + "SELECT item_data FROM {}.{} WHERE pk = ?", + account_keyspace, table_name + ); + + // Step 4: Add sort key condition if present + let sk_value_opt = if let (Some(sk_cond), Some((_, sk_type))) = + (&key_condition.sk_condition, sk_info_opt) + { + use SortKeyCondition; + use extenddb_storage::util::{parse_sk, sk_column}; + + let sk_col = sk_column(sk_type); + + match sk_cond { + SortKeyCondition::Compare { op, value, .. } => { + let op_str = match op { + CompareOp::Eq => "=", + CompareOp::Ne => "!=", + CompareOp::Lt => "<", + CompareOp::Le => "<=", + CompareOp::Gt => ">", + CompareOp::Ge => ">=", + }; + + query.push_str(&format!(" AND {} {} ?", sk_col, op_str)); + + // Resolve and parse SK value + let sk_av = resolve_expr_to_av(value, _maps)?; + let sk_val = parse_sk(&sk_av, sk_type)?; + Some((sk_col, vec![sk_val])) + } + SortKeyCondition::Between { low, high, .. } => { + query.push_str(&format!(" AND {} >= ? AND {} <= ?", sk_col, sk_col)); + + // Resolve and parse both bounds + let low_av = resolve_expr_to_av(low, _maps)?; + let high_av = resolve_expr_to_av(high, _maps)?; + let low_sk = parse_sk(&low_av, sk_type)?; + let high_sk = parse_sk(&high_av, sk_type)?; + Some((sk_col, vec![low_sk, high_sk])) + } + SortKeyCondition::BeginsWith { prefix, .. } => { + query.push_str(&format!(" AND {} >= ? AND {} < ?", sk_col, sk_col)); + + // Resolve prefix + let prefix_av = resolve_expr_to_av(prefix, _maps)?; + let prefix_sk = parse_sk(&prefix_av, sk_type)?; + + // Compute upper bound based on type + let upper_sk = match &prefix_sk { + SortKeyValue::S(s) => SortKeyValue::S(string_upper_bound(s)), + SortKeyValue::B(b) => SortKeyValue::B(binary_upper_bound(b)), + SortKeyValue::N(_) => { + return Err(StorageError::Validation( + "BeginsWith is not supported for numeric sort keys".to_owned(), + )); + } + }; + + Some((sk_col, vec![prefix_sk, upper_sk])) + } + } + } else { + None + }; + + // Step 4b: Determine pagination strategy + // For index queries with base table key tie-breakers, we need two queries + // since Cassandra doesn't support OR clauses like PostgreSQL. + let base_sk_info_val = base_sk_info(key_info, index_name); + + // Need two-query pagination if it's an index query with pagination that has + // compound keys requiring tie-breakers (index SK + base keys, or just base PK+SK) + let needs_two_query_pagination = index_name.is_some() + && exclusive_start_key.is_some() + && (base_sk_info_val.is_some() || sk_info_opt.is_some()); + + // Special case: hash-only index needs base_pk pagination but uses single query + let is_hash_only_index = index_name.is_some() && sk_info_opt.is_none(); + + let pagination_sk_opt = if let Some(start_key) = exclusive_start_key { + if let Some((_, sk_type)) = sk_info_opt { + // Table/index has sort key + let sk_name = &query_key_schema[1].attribute_name; + if let Some(start_sk_val) = start_key.get(sk_name) { + let sk_val = parse_sk(start_sk_val, sk_type)?; + if !needs_two_query_pagination { + // Simple pagination - add to query now + let sk_col = sk_column(sk_type); + let cmp = if forward { ">" } else { "<" }; + query.push_str(&format!(" AND {} {} ?", sk_col, cmp)); + } + // else: two-query logic will handle it below + Some(sk_val) + } else { + None + } + } else if is_hash_only_index { + // Hash-only index: paginate on base_pk + let base_pk_attr = &key_info.base_key_schema[0].attribute_name; + if start_key.get(base_pk_attr).is_some() { + if !needs_two_query_pagination { + // Will be handled in Query 2 section for two-query path + query.push_str(" AND base_pk > ?"); + } + None // Returning None, will use base_pk from start_key in execution + } else { + None + } + } else { + // PK-only base table with start key means no more items for this partition + return Ok((Vec::new(), None)); + } + } else { + None + }; + + // Step 5: Add ORDER BY if table has sort key + // (skipped for two-query pagination - each subquery adds its own ORDER BY/LIMIT) + if !needs_two_query_pagination { + if let Some((_, sk_type)) = sk_info_opt { + let sk_col = sk_column(sk_type); + let direction = if forward { "ASC" } else { "DESC" }; + query.push_str(&format!(" ORDER BY {} {}", sk_col, direction)); + } + + // Step 6: Add LIMIT (fetch limit + 1 to detect pagination) + // Default limit is 1,000,000 per DynamoDB behavior + let fetch_limit = limit.map_or(1_000_001, |l| l.max(0) + 1); + query.push_str(&format!(" LIMIT {}", fetch_limit)); + } + + tracing::debug!( + account_id = %key_info.account_id, + table_id = %key_info.table_id, + pk = %pk_text, + has_sk = sk_value_opt.is_some(), + forward = forward, + limit = limit, + needs_two_queries = needs_two_query_pagination, + "query: executing" + ); + + // Step 7: Execute query with appropriate parameters + let rows = if needs_two_query_pagination { + // Two-query pagination for indexes with base table key tie-breakers + // Query 1: sk = start_sk AND base_key > start_base_key (finish current SK) + // Query 2: sk > start_sk (move to next SK values) + + let start_key = exclusive_start_key.unwrap(); // Safe: needs_two_query_pagination requires this + let start_sk = pagination_sk_opt.as_ref(); // May be None for hash-only index + + tracing::debug!( + "Two-query pagination: sk_info={:?}, base_sk_info={:?}", + sk_info_opt, + base_sk_info_val + ); + + #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] + let actual_limit = limit.map_or(1_000_000_usize, |l| l.max(0) as usize); + let fetch_limit = actual_limit + 1; + + // Build pagination binds for base table keys + let pagination_binds = if let Some((ref base_sk_name, base_sk_type)) = base_sk_info_val + { + // Index with base SK tie-breaker. + // The index clustering order is (sk_*, base_pk, base_sk_*), so to + // restrict base_sk_* we also need base_pk. Capture both. + let base_pk_attr = &key_info.base_key_schema[0].attribute_name; + let base_pk = start_key + .get(base_pk_attr.as_str()) + .map(pk_to_text) + .transpose()? + .map(std::borrow::Cow::into_owned); + if let Some(base_sk_val) = start_key.get(base_sk_name.as_str()) { + let sk = parse_sk(base_sk_val, base_sk_type)?; + if let Some(pk_text) = base_pk { + PaginationBinds::BasePkAndSk { pk_text, sk } + } else { + PaginationBinds::BaseSkOnly { sk } + } + } else { + PaginationBinds::None + } + } else if sk_info_opt.is_some() { + // Index with SK but no base SK — use base_pk + let base_pk_attr = &key_info.base_key_schema[0].attribute_name; + if let Some(pk_val) = start_key.get(base_pk_attr.as_str()) { + let pk_text = pk_to_text(pk_val)?.into_owned(); + PaginationBinds::BasePkOnly { pk_text } + } else { + PaginationBinds::None + } + } else { + // Hash-only index + let base_pk_attr = &key_info.base_key_schema[0].attribute_name; + let base_pk = start_key + .get(base_pk_attr.as_str()) + .map(pk_to_text) + .transpose()? + .map(std::borrow::Cow::into_owned); + match (base_pk, &base_sk_info_val) { + (Some(pk_text), Some((sk_name, sk_type))) => { + if let Some(sk_val) = start_key.get(sk_name.as_str()) { + let sk = parse_sk(sk_val, *sk_type)?; + PaginationBinds::BasePkAndSk { pk_text, sk } + } else { + PaginationBinds::BasePkOnly { pk_text } + } + } + (Some(pk_text), None) => PaginationBinds::BasePkOnly { pk_text }, + _ => PaginationBinds::None, + } + }; + + // Build and execute queries based on pagination_binds + let mut all_rows = Vec::new(); + + // Query 1: finish current SK value (if we have base key binds) + match (&pagination_binds, sk_info_opt) { + (PaginationBinds::BaseSkOnly { sk: base_sk }, Some((_, sk_type))) + if start_sk.is_some() => + { + let start_sk = start_sk.unwrap(); // Safe: checked is_some() + let sk_col = sk_column(sk_type); + let base_sk_col = format!("base_{}", sk_column(base_sk.scalar_type())); + let base_cmp = if is_lsi && !forward { "<" } else { ">" }; + let query1 = format!( + "{} AND {} = ? AND {} {} ? ORDER BY {} {}, {} {} LIMIT {}", + query, + sk_col, + base_sk_col, + base_cmp, + sk_col, + if forward { "ASC" } else { "DESC" }, + base_sk_col, + if is_lsi && !forward { "DESC" } else { "ASC" }, + fetch_limit + ); + + let rows1 = query_with_pk_sk_sk( + &self.session_arc(), + &query1, + &pk_text, + start_sk, + base_sk, + "same_sk", + ) + .await?; + all_rows.extend(rows1); + } + ( + PaginationBinds::BasePkAndSk { + pk_text: base_pk_text, + sk: base_sk, + }, + Some((_, sk_type)), + ) if start_sk.is_some() => { + // LSI pagination: clustering order is (sk_*, base_pk, base_sk_*). + // Must restrict sk_* = start_sk AND base_pk = base_pk AND base_sk_* > base_sk. + let start_sk = start_sk.unwrap(); + let sk_col = sk_column(sk_type); + let base_sk_col = format!("base_{}", sk_column(base_sk.scalar_type())); + let base_cmp = if is_lsi && !forward { "<" } else { ">" }; + let query1 = format!( + "{} AND {} = ? AND base_pk = ? AND {} {} ? ORDER BY {} {}, base_pk {}, {} {} LIMIT {}", + query, + sk_col, + base_sk_col, + base_cmp, + sk_col, + if forward { "ASC" } else { "DESC" }, + if forward { "ASC" } else { "DESC" }, + base_sk_col, + if forward { "ASC" } else { "DESC" }, + fetch_limit + ); + + let rows1 = query_with_pk_sk_pk_sk( + &self.session_arc(), + &query1, + &pk_text, + start_sk, + base_pk_text, + base_sk, + "same_sk", + ) + .await?; + all_rows.extend(rows1); + } + ( + PaginationBinds::BasePkOnly { + pk_text: base_pk_text, + }, + Some((_, sk_type)), + ) if start_sk.is_some() => { + let start_sk = start_sk.unwrap(); + let sk_col = sk_column(sk_type); + let cmp = if forward { ">" } else { "<" }; + let dir = if forward { "ASC" } else { "DESC" }; + let query1 = format!( + "{} AND {} = ? AND base_pk {} ? ORDER BY {} {}, base_pk {} LIMIT {}", + query, sk_col, cmp, sk_col, dir, dir, fetch_limit + ); + + let rows1 = query_with_pk_sk_pk( + &self.session_arc(), + &query1, + &pk_text, + start_sk, + base_pk_text, + "same_sk", + ) + .await?; + all_rows.extend(rows1); + } + ( + PaginationBinds::BasePkOnly { + pk_text: base_pk_text, + }, + None, + ) + | ( + PaginationBinds::BasePkAndSk { + pk_text: base_pk_text, + .. + }, + None, + ) => { + // Hash-only index with base_pk pagination + let query1 = if let PaginationBinds::BasePkAndSk { sk: base_sk, .. } = + &pagination_binds + { + let base_sk_col = format!("base_{}", sk_column(base_sk.scalar_type())); + format!( + "{} AND base_pk = ? AND {} > ? ORDER BY base_pk {}, {} {} LIMIT {}", + query, + base_sk_col, + if forward { "ASC" } else { "DESC" }, + base_sk_col, + if forward { "ASC" } else { "DESC" }, + fetch_limit + ) + } else { + format!( + "{} AND base_pk = ? ORDER BY base_pk {} LIMIT {}", + query, + if forward { "ASC" } else { "DESC" }, + fetch_limit + ) + }; + + let rows1 = if let PaginationBinds::BasePkAndSk { sk: base_sk, .. } = + &pagination_binds + { + query_with_pk_pk_sk( + &self.session_arc(), + &query1, + &pk_text, + &base_pk_text, + base_sk, + "same_sk", + ) + .await? + } else { + cassandra_util::query_rows( + &self.session_arc(), + &query1, + cdrs_tokio::query_values!(pk_text.as_str(), base_pk_text.as_str()), + "same_sk", + ) + .await? + }; + all_rows.extend(rows1); + } + _ => {} + } + + // Query 2: next SK values (only if we haven't reached limit yet) + if all_rows.len() < fetch_limit && sk_info_opt.is_some() && start_sk.is_some() { + let start_sk = start_sk.unwrap(); // Safe: checked is_some() + let remaining = fetch_limit - all_rows.len(); + let query2 = if let Some((_, sk_type)) = sk_info_opt { + let sk_col = sk_column(sk_type); + let cmp = if forward { ">" } else { "<" }; + let order_clause = if let Some((_, base_sk_type)) = &base_sk_info_val { + let base_sk_col = format!("base_{}", sk_column(*base_sk_type)); + let dir = if forward { "ASC" } else { "DESC" }; + format!( + " ORDER BY {} {}, base_pk {}, {} {} LIMIT {}", + sk_col, dir, dir, base_sk_col, dir, + remaining + ) + } else { + format!( + " ORDER BY {} {}, base_pk {} LIMIT {}", + sk_col, + if forward { "ASC" } else { "DESC" }, + if forward { "ASC" } else { "DESC" }, + remaining + ) + }; + format!("{} AND {} {} ?{}", query, sk_col, cmp, order_clause) + } else { + // Hash-only index + let order_clause = if let Some((_, base_sk_type)) = &base_sk_info_val { + let base_sk_col = format!("base_{}", sk_column(*base_sk_type)); + format!( + " ORDER BY base_pk {}, {} {} LIMIT {}", + if forward { "ASC" } else { "DESC" }, + base_sk_col, + if forward { "ASC" } else { "DESC" }, + remaining + ) + } else { + format!( + " ORDER BY base_pk {} LIMIT {}", + if forward { "ASC" } else { "DESC" }, + remaining + ) + }; + format!("{} AND base_pk > ?{}", query, order_clause) + }; + + let rows2 = match &pagination_binds { + PaginationBinds::BaseSkOnly { .. } + | PaginationBinds::BasePkOnly { .. } + | PaginationBinds::BasePkAndSk { .. } + if sk_info_opt.is_some() => + { + query_with_pk_sk( + &self.session_arc(), + &query2, + &pk_text, + start_sk, + "next_sk", + ) + .await? + } + PaginationBinds::BasePkOnly { + pk_text: base_pk_text, + } + | PaginationBinds::BasePkAndSk { + pk_text: base_pk_text, + .. + } => { + cassandra_util::query_rows( + &self.session_arc(), + &query2, + cdrs_tokio::query_values!(pk_text.as_str(), base_pk_text.as_str()), + "next_sk", + ) + .await? + } + _ => Vec::new(), + }; + all_rows.extend(rows2); + } + + all_rows + } else { + // Single-query execution (existing logic) + let rows_result = match (sk_value_opt, pagination_sk_opt) { + (Some((_, sk_vals)), Some(page_sk)) => { + // Query with SK condition AND pagination + if sk_vals.len() == 1 { + // Compare condition + pagination + query_with_pk_sk_sk( + &self.session_arc(), + &query, + &pk_text, + &sk_vals[0], + &page_sk, + "query", + ) + .await? + } else { + // Between condition (2 values) + pagination + query_with_pk_sk_sk_sk( + &self.session_arc(), + &query, + &pk_text, + &sk_vals[0], + &sk_vals[1], + &page_sk, + "query", + ) + .await? + } + } + (Some((_, sk_vals)), None) => { + // Query with SK condition only (no pagination) + if sk_vals.len() == 1 { + // Compare condition + query_with_pk_sk( + &self.session_arc(), + &query, + &pk_text, + &sk_vals[0], + "query", + ) + .await? + } else { + // Between condition (2 values) + query_with_pk_sk_sk( + &self.session_arc(), + &query, + &pk_text, + &sk_vals[0], + &sk_vals[1], + "query", + ) + .await? + } + } + (None, Some(page_sk)) => { + // PK-only query with pagination + query_with_pk_sk(&self.session_arc(), &query, &pk_text, &page_sk, "query") + .await? + } + (None, None) => { + // Check if this is hash-only index with pagination + if is_hash_only_index && exclusive_start_key.is_some() { + let start_key = exclusive_start_key.unwrap(); + let base_pk_attr = &key_info.base_key_schema[0].attribute_name; + if let Some(base_pk_val) = start_key.get(base_pk_attr) { + let base_pk_text = pk_to_text(base_pk_val)?.into_owned(); + cassandra_util::query_rows( + &self.session_arc(), + &query, + cdrs_tokio::query_values!(pk_text.as_str(), base_pk_text.as_str()), + "query", + ) + .await? + } else { + // No base_pk in start key, no more results + Vec::new() + } + } else { + // PK-only query without pagination (original behavior) + cassandra_util::query_rows( + &self.session_arc(), + &query, + cdrs_tokio::query_values!(pk_text.as_str()), + "query", + ) + .await? + } + } + }; // End of single-query match + + rows_result + }; + + // Step 8: Parse results + let items: Vec = rows + .into_iter() + .map(|row| { + let json_str: String = + cassandra_util::get_column(&row, "item_data", "query parse")?; + json_to_item(json_str) + }) + .collect::, _>>()?; + + // Step 9: Enforce limit and detect pagination + #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] + let actual_limit = limit.map_or(1_000_000_usize, |l| l.max(0) as usize); + let has_more = items.len() > actual_limit; + + let final_items: Vec = items.into_iter().take(actual_limit).collect(); + + let last_key = if has_more { + // Build LastEvaluatedKey from the last returned item + final_items + .last() + .map(|item| extenddb_core::types::extract_key(item, &key_info.key_schema)) + } else { + None + }; + + tracing::debug!( + item_count = final_items.len(), + has_more = has_more, + "query: fetched items" + ); + + // Step 10: Return results with pagination key + Ok((final_items, last_key)) + } +} diff --git a/crates/storage-cassandra/src/data/query_helpers.rs b/crates/storage-cassandra/src/data/query_helpers.rs new file mode 100644 index 00000000..d91ba5ef --- /dev/null +++ b/crates/storage-cassandra/src/data/query_helpers.rs @@ -0,0 +1,236 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Query execution helpers to reduce code repetition. + +use crate::cassandra_util; +use cdrs_tokio::types::rows::Row; +use extenddb_storage::error::StorageError; +use extenddb_storage::util::SortKeyValue; +use std::sync::Arc; + +/// Execute query with PK and single SK parameter. +pub(super) async fn query_with_pk_sk( + session: &Arc, + query: &str, + pk: &str, + sk: &SortKeyValue, + label: &str, +) -> Result, StorageError> { + match sk { + SortKeyValue::S(s) => { + cassandra_util::query_rows( + session, + query, + cdrs_tokio::query_values!(pk, s.as_str()), + label, + ) + .await + } + SortKeyValue::N(n) => { + cassandra_util::query_rows( + session, + query, + cdrs_tokio::query_values!(pk, super::decimal_to_value(n)), + label, + ) + .await + } + SortKeyValue::B(b) => { + cassandra_util::query_rows( + session, + query, + cdrs_tokio::query_values!(pk, b.to_vec()), + label, + ) + .await + } + } +} + +/// Execute query with PK and two SK parameters. +pub(super) async fn query_with_pk_sk_sk( + session: &Arc, + query: &str, + pk: &str, + sk1: &SortKeyValue, + sk2: &SortKeyValue, + label: &str, +) -> Result, StorageError> { + match (sk1, sk2) { + (SortKeyValue::S(s1), SortKeyValue::S(s2)) => { + cassandra_util::query_rows( + session, + query, + cdrs_tokio::query_values!(pk, s1.as_str(), s2.as_str()), + label, + ) + .await + } + (SortKeyValue::N(n1), SortKeyValue::N(n2)) => { + cassandra_util::query_rows( + session, + query, + cdrs_tokio::query_values!( + pk, + super::decimal_to_value(n1), + super::decimal_to_value(n2) + ), + label, + ) + .await + } + (SortKeyValue::B(b1), SortKeyValue::B(b2)) => { + cassandra_util::query_rows( + session, + query, + cdrs_tokio::query_values!(pk, b1.to_vec(), b2.to_vec()), + label, + ) + .await + } + _ => Err(StorageError::Internal( + "Type mismatch in query parameters".to_owned(), + )), + } +} + +/// Execute query with index PK, index SK, base PK, and base SK parameters. +/// Used for LSI pagination: WHERE pk=? AND sk_*=? AND base_pk=? AND base_sk_*>? +pub(super) async fn query_with_pk_sk_pk_sk( + session: &Arc, + query: &str, + pk: &str, + idx_sk: &SortKeyValue, + base_pk: &str, + base_sk: &SortKeyValue, + label: &str, +) -> Result, StorageError> { + use cdrs_tokio::query_values; + match (idx_sk, base_sk) { + (SortKeyValue::S(isk), SortKeyValue::S(bsk)) => + cassandra_util::query_rows(session, query, query_values!(pk, isk.as_str(), base_pk, bsk.as_str()), label).await, + (SortKeyValue::S(isk), SortKeyValue::N(bsk)) => + cassandra_util::query_rows(session, query, query_values!(pk, isk.as_str(), base_pk, super::decimal_to_value(bsk)), label).await, + (SortKeyValue::S(isk), SortKeyValue::B(bsk)) => + cassandra_util::query_rows(session, query, query_values!(pk, isk.as_str(), base_pk, bsk.to_vec()), label).await, + (SortKeyValue::N(isk), SortKeyValue::S(bsk)) => + cassandra_util::query_rows(session, query, query_values!(pk, super::decimal_to_value(isk), base_pk, bsk.as_str()), label).await, + (SortKeyValue::N(isk), SortKeyValue::N(bsk)) => + cassandra_util::query_rows(session, query, query_values!(pk, super::decimal_to_value(isk), base_pk, super::decimal_to_value(bsk)), label).await, + (SortKeyValue::N(isk), SortKeyValue::B(bsk)) => + cassandra_util::query_rows(session, query, query_values!(pk, super::decimal_to_value(isk), base_pk, bsk.to_vec()), label).await, + (SortKeyValue::B(isk), SortKeyValue::S(bsk)) => + cassandra_util::query_rows(session, query, query_values!(pk, isk.to_vec(), base_pk, bsk.as_str()), label).await, + (SortKeyValue::B(isk), SortKeyValue::N(bsk)) => + cassandra_util::query_rows(session, query, query_values!(pk, isk.to_vec(), base_pk, super::decimal_to_value(bsk)), label).await, + (SortKeyValue::B(isk), SortKeyValue::B(bsk)) => + cassandra_util::query_rows(session, query, query_values!(pk, isk.to_vec(), base_pk, bsk.to_vec()), label).await, + } +} + +/// Execute query with index PK, index SK, and base PK parameters. +/// Used for GSI pagination: WHERE pk=? AND sk_*=? AND base_pk>? +pub(super) async fn query_with_pk_sk_pk( + session: &Arc, + query: &str, + pk: &str, + sk: &SortKeyValue, + base_pk: &str, + label: &str, +) -> Result, StorageError> { + match sk { + SortKeyValue::S(s) => cassandra_util::query_rows(session, query, cdrs_tokio::query_values!(pk, s.as_str(), base_pk), label).await, + SortKeyValue::N(n) => cassandra_util::query_rows(session, query, cdrs_tokio::query_values!(pk, super::decimal_to_value(n), base_pk), label).await, + SortKeyValue::B(b) => cassandra_util::query_rows(session, query, cdrs_tokio::query_values!(pk, b.to_vec(), base_pk), label).await, + } +} + +/// Execute query with index PK, base table pk, and an SK parameter. +pub(super) async fn query_with_pk_pk_sk( + session: &Arc, + query: &str, + pk: &str, + base_pk: &str, + sk: &SortKeyValue, + label: &str, +) -> Result, StorageError> { + match sk { + SortKeyValue::S(s) => { + cassandra_util::query_rows( + session, + query, + cdrs_tokio::query_values!(pk, base_pk, s.as_str()), + label, + ) + .await + } + SortKeyValue::N(n) => { + cassandra_util::query_rows( + session, + query, + cdrs_tokio::query_values!(pk, base_pk, super::decimal_to_value(n)), + label, + ) + .await + } + SortKeyValue::B(b) => { + cassandra_util::query_rows( + session, + query, + cdrs_tokio::query_values!(pk, base_pk, b.to_vec()), + label, + ) + .await + } + } +} + +/// Execute query with PK and three SK parameters. +pub(super) async fn query_with_pk_sk_sk_sk( + session: &Arc, + query: &str, + pk: &str, + sk1: &SortKeyValue, + sk2: &SortKeyValue, + sk3: &SortKeyValue, + label: &str, +) -> Result, StorageError> { + match (sk1, sk2, sk3) { + (SortKeyValue::S(s1), SortKeyValue::S(s2), SortKeyValue::S(s3)) => { + cassandra_util::query_rows( + session, + query, + cdrs_tokio::query_values!(pk, s1.as_str(), s2.as_str(), s3.as_str()), + label, + ) + .await + } + (SortKeyValue::N(n1), SortKeyValue::N(n2), SortKeyValue::N(n3)) => { + cassandra_util::query_rows( + session, + query, + cdrs_tokio::query_values!( + pk, + super::decimal_to_value(n1), + super::decimal_to_value(n2), + super::decimal_to_value(n3) + ), + label, + ) + .await + } + (SortKeyValue::B(b1), SortKeyValue::B(b2), SortKeyValue::B(b3)) => { + cassandra_util::query_rows( + session, + query, + cdrs_tokio::query_values!(pk, b1.to_vec(), b2.to_vec(), b3.to_vec()), + label, + ) + .await + } + _ => Err(StorageError::Internal( + "Type mismatch in query parameters".to_owned(), + )), + } +} diff --git a/crates/storage-cassandra/src/data/scan.rs b/crates/storage-cassandra/src/data/scan.rs new file mode 100644 index 00000000..bb120908 --- /dev/null +++ b/crates/storage-cassandra/src/data/scan.rs @@ -0,0 +1,403 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Scan implementation for the Cassandra backend. +//! +//! Implements `DataEngine::scan` for both base tables and secondary indexes. +//! +//! # Design +//! +//! Unlike PostgreSQL — which can express a full table scan as `ORDER BY pk, sk` +//! with a row-value comparison (`(pk, sk) > (?, ?)`) for pagination — Cassandra +//! cannot order by or range-compare the *partition key* directly, because +//! partitions are distributed by token (hash). A full table scan in Cassandra +//! therefore walks the cluster in **token order** and pagination is expressed in +//! terms of `token(pk)`. +//! +//! ## Pagination (`ExclusiveStartKey`) +//! +//! Resuming after a key `(P, S)` requires two queries, mirroring the two-query +//! pattern already used by `query_impl` for index pagination (Cassandra has no +//! `OR`): +//! +//! 1. **Finish the current partition** (only when the table/index has a sort +//! key): `WHERE pk = ? AND > ?` — returns the remaining +//! clustering rows in partition `P` after `S`. +//! 2. **Move to subsequent partitions**: `WHERE token(pk) > token(?)` — returns +//! rows in all partitions that sort after `P` in the token ring. +//! +//! Results are concatenated (query 1 then query 2), which preserves the global +//! `(token(pk), clustering...)` ordering, then truncated to the requested limit. +//! +//! All values are passed as bound parameters (`?`), matching `query_impl` and +//! ADR-0002 — never interpolated as CQL literals. +//! +//! ## Parallel scan (`Segment` / `TotalSegments`) +//! +//! The token ring (`i64::MIN..=i64::MAX` for the Murmur3 partitioner) is split +//! into `TotalSegments` contiguous ranges. Each segment restricts the scan with +//! `token(pk) >= ? AND token(pk) <= ?` (bound `bigint` token bounds). This is the +//! idiomatic Cassandra token-range split and composes with pagination by +//! additionally bounding the "next partitions" query with the segment's upper +//! token bound. +//! +//! ## Known limitation +//! +//! Token-based pagination can, in theory, skip partition keys that hash to the +//! exact same 64-bit token as the resume key (a ~1-in-2^64 event per boundary). +//! This is an inherent trade-off of stateless token-range scanning and matches +//! the "token range pagination" approach approved in the workplan. + +use cdrs_tokio::query::QueryValues; +use cdrs_tokio::types::value::Value; +use extenddb_core::types::{Item, ScalarAttributeType, TableKeyInfo}; +use extenddb_storage::error::StorageError; +use extenddb_storage::util::{SortKeyValue, composite_pk_to_text, parse_sk, sk_column, sk_info}; +use std::sync::Arc; + +use super::ddl::{data_table_name, index_table_name}; +use super::index::fetch_index_by_name; +use super::json_to_item; +use crate::cassandra_util::{self, CassandraSession}; + +/// Compute the inclusive `[lower, upper]` token bounds for a parallel-scan +/// segment, or `None` when the whole ring should be scanned. +/// +/// Returns `None` when segment/total are absent or `total_segments == 1` +/// (single segment covers the entire ring, so no token predicate is needed). +fn segment_token_bounds(segment: Option, total_segments: Option) -> Option<(i64, i64)> { + let (seg, total) = match (segment, total_segments) { + (Some(s), Some(t)) if t > 1 && s >= 0 && s < t => (s as i128, t as i128), + _ => return None, + }; + + let min = i64::MIN as i128; + let max = i64::MAX as i128; + let ring = max - min + 1; // 2^64 + let span = ring / total; + + let lower = min + span * seg; + let upper = if seg == total - 1 { + max + } else { + min + span * (seg + 1) - 1 + }; + + Some((lower as i64, upper as i64)) +} + +/// Convert a parsed sort-key value into a bound parameter `Value`, matching the +/// per-type binding used by `query_impl` so scan pagination binds values +/// consistently with the query path. +fn sk_to_value(sk: &SortKeyValue) -> Value { + match sk { + SortKeyValue::S(s) => Value::from(s.as_str()), + SortKeyValue::N(n) => super::decimal_to_value(n), + SortKeyValue::B(b) => Value::from(b.clone()), + } +} + +/// Execute a scan query that has no bind parameters and return the rows. +/// +/// Used for the first page (no `ExclusiveStartKey`), where the only predicates +/// are literal token-range bounds. +async fn rows_no_values( + session: &Arc, + query: &str, +) -> Result, StorageError> { + let result = session + .query(query) + .await + .map_err(|e| StorageError::Internal(format!("scan query failed: {e}")))?; + let body = result + .response_body() + .map_err(|e| StorageError::Internal(format!("scan parse failed: {e}")))?; + Ok(body.into_rows().unwrap_or_default()) +} + +impl crate::CassandraEngine { + /// Implementation of `DataEngine::scan`. + /// + /// Returns up to `limit` items (the engine layer applies any + /// `FilterExpression`, `ProjectionExpression`, and the 1 MB cap) along with + /// a `LastEvaluatedKey` when more items remain. + pub(crate) async fn scan_impl( + &self, + key_info: &TableKeyInfo, + limit: Option, + exclusive_start_key: Option<&Item>, + segment: Option, + total_segments: Option, + index_name: Option<&str>, + ) -> Result<(Vec, Option), StorageError> { + let session = self.session_arc(); + + // Resolve the Cassandra table to scan and the key schema that describes + // its `pk`/`sk` columns (index key schema for index scans). + let (table_name, scan_key_schema) = if let Some(idx_name) = index_name { + let index = fetch_index_by_name( + &key_info.table_id, + idx_name, + &session, + &self.catalog_keyspace(), + ) + .await?; + (index_table_name(&index.index_id), index.key_schema) + } else { + ( + data_table_name(&key_info.table_id), + key_info.key_schema.clone(), + ) + }; + + let account_keyspace = self.account_keyspace(&key_info.account_id); + let select_prefix = format!("SELECT item_data FROM {account_keyspace}.{table_name}"); + + // Sort-key info for the scanned table/index, and (for index scans) the + // base table's sort key — both are clustering columns on the index table. + let sk_info_opt = sk_info(&scan_key_schema, &key_info.attribute_definitions); + let base_sk_info_opt = if index_name.is_some() { + sk_info(&key_info.base_key_schema, &key_info.attribute_definitions) + } else { + None + }; + + let token_bounds = segment_token_bounds(segment, total_segments); + let token_upper = token_bounds.map(|(_, hi)| hi); + + #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] + let actual_limit = limit.map_or(1_000_000_usize, |l| l.max(0) as usize); + let fetch_limit = actual_limit + 1; // fetch one extra to detect more pages + + tracing::debug!( + account_id = %key_info.account_id, + table_id = %key_info.table_id, + index = ?index_name, + segment = ?segment, + total_segments = ?total_segments, + paginating = exclusive_start_key.is_some(), + "scan: executing" + ); + + let rows = if let Some(start_key) = exclusive_start_key { + self.scan_paginated( + &session, + &select_prefix, + &scan_key_schema, + key_info, + sk_info_opt, + base_sk_info_opt, + index_name.is_some(), + start_key, + token_upper, + fetch_limit, + ) + .await? + } else { + // First page: full scan within the (optional) segment token range. + let mut query = select_prefix.clone(); + let mut values: Vec = Vec::new(); + if let Some((lo, hi)) = token_bounds { + query.push_str(" WHERE token(pk) >= ? AND token(pk) <= ?"); + values.push(Value::from(lo)); + values.push(Value::from(hi)); + } + query.push_str(&format!(" LIMIT {fetch_limit}")); + if values.is_empty() { + // Unrestricted full scan — no bind parameters. + rows_no_values(&session, &query).await? + } else { + cassandra_util::query_rows::( + &session, + &query, + QueryValues::SimpleValues(values), + "scan_first_page", + ) + .await? + } + }; + + // Parse item_data JSON into items. + let items: Vec = rows + .into_iter() + .map(|row| { + let json_str: String = cassandra_util::get_column(&row, "item_data", "scan parse")?; + json_to_item(json_str) + }) + .collect::, _>>()?; + + // Enforce the limit and derive the LastEvaluatedKey from the last item. + let has_more = items.len() > actual_limit; + let final_items: Vec = items.into_iter().take(actual_limit).collect(); + + let last_key = if has_more { + final_items + .last() + .map(|item| extenddb_core::types::extract_key(item, &scan_key_schema)) + } else { + None + }; + + tracing::debug!( + item_count = final_items.len(), + has_more = has_more, + "scan: fetched items" + ); + + Ok((final_items, last_key)) + } + + /// Token-based two-query pagination for `scan_impl`. + #[allow(clippy::too_many_arguments)] + async fn scan_paginated( + &self, + session: &Arc, + select_prefix: &str, + scan_key_schema: &[extenddb_core::types::KeySchemaElement], + key_info: &TableKeyInfo, + sk_info_opt: Option<(&str, ScalarAttributeType)>, + base_sk_info_opt: Option<(&str, ScalarAttributeType)>, + is_index: bool, + start_key: &Item, + token_upper: Option, + fetch_limit: usize, + ) -> Result, StorageError> { + // Partition key text of the resume row (single or composite HASH key). + let pk_text = composite_pk_to_text(start_key, scan_key_schema)?; + + let mut all_rows = Vec::new(); + + // "Next partitions" upper token bound (bound parameter, if segmented). + let token_clause: &str = if token_upper.is_some() { + " AND token(pk) <= ?" + } else { + "" + }; + + // ── Query 1: finish the resume partition (only if there are clustering + // columns to advance past — i.e. the table/index has a sort key, or an + // index carries base-table key clustering columns). ────────────────── + let q1_clustering = self.build_resume_clustering( + key_info, + sk_info_opt, + base_sk_info_opt, + is_index, + start_key, + )?; + + if let Some((clustering_predicate, clustering_binds)) = q1_clustering { + let query1 = format!( + "{select_prefix} WHERE pk = ? AND {clustering_predicate} LIMIT {fetch_limit}" + ); + let mut values: Vec = Vec::with_capacity(1 + clustering_binds.len()); + values.push(Value::from(pk_text.as_str())); + values.extend(clustering_binds); + let rows1 = cassandra_util::query_rows::( + session, + &query1, + QueryValues::SimpleValues(values), + "scan_finish_partition", + ) + .await?; + all_rows.extend(rows1); + } + + // ── Query 2: subsequent partitions in token order. ────────────────────── + if all_rows.len() < fetch_limit { + let remaining = fetch_limit - all_rows.len(); + let query2 = format!( + "{select_prefix} WHERE token(pk) > token(?){token_clause} LIMIT {remaining}" + ); + let mut values: Vec = vec![Value::from(pk_text.as_str())]; + if let Some(hi) = token_upper { + values.push(Value::from(hi)); + } + let rows2 = cassandra_util::query_rows::( + session, + &query2, + QueryValues::SimpleValues(values), + "scan_next_partitions", + ) + .await?; + all_rows.extend(rows2); + } + + Ok(all_rows) + } + + /// Build the clustering-comparison predicate for query 1 of pagination + /// (the part after `pk = ? AND`), plus the bound values it references. + /// + /// Returns `None` when the partition has no clustering columns to advance + /// past (hash-only base table), in which case query 1 is skipped. + /// + /// - Base table with sort key: `sk_col > ?`. + /// - Index scan: a multi-column clustering tuple comparison + /// `(idx_sk?, base_pk, base_sk?) > (?, …)`. + /// + /// All comparison values are returned as bound parameters (never CQL + /// literals), matching `query_impl`. + fn build_resume_clustering( + &self, + key_info: &TableKeyInfo, + sk_info_opt: Option<(&str, ScalarAttributeType)>, + base_sk_info_opt: Option<(&str, ScalarAttributeType)>, + is_index: bool, + start_key: &Item, + ) -> Result)>, StorageError> { + if !is_index { + // Base table: single clustering column (the sort key), if any. + let Some((sk_name, sk_type)) = sk_info_opt else { + return Ok(None); // hash-only base table → no query 1 + }; + let sk_av = start_key.get(sk_name).ok_or_else(|| { + StorageError::Validation( + "The provided starting key is invalid: missing sort key".to_owned(), + ) + })?; + let sk_val = parse_sk(sk_av, sk_type)?; + let col = sk_column(sk_type); + return Ok(Some((format!("{col} > ?"), vec![sk_to_value(&sk_val)]))); + } + + // Index scan: clustering = (index sort key?, base_pk, base sort key?). + let mut cols: Vec = Vec::new(); + let mut binds: Vec = Vec::new(); + + if let Some((idx_sk_name, idx_sk_type)) = sk_info_opt { + let av = start_key.get(idx_sk_name).ok_or_else(|| { + StorageError::Validation( + "The provided starting key is invalid: missing index sort key".to_owned(), + ) + })?; + cols.push(sk_column(idx_sk_type).to_owned()); + binds.push(sk_to_value(&parse_sk(av, idx_sk_type)?)); + } + + // base_pk clustering column (always present on index tables). + let base_pk_text = composite_pk_to_text(start_key, &key_info.base_key_schema)?; + cols.push("base_pk".to_owned()); + binds.push(Value::from(base_pk_text)); + + if let Some((base_sk_name, base_sk_type)) = base_sk_info_opt { + if let Some(av) = start_key.get(base_sk_name) { + cols.push(format!("base_{}", sk_column(base_sk_type))); + binds.push(sk_to_value(&parse_sk(av, base_sk_type)?)); + } + } + + Ok(Some((format_clustering_comparison(&cols), binds))) + } +} + +/// Render a clustering "greater-than" predicate with bound-parameter +/// placeholders. Uses a plain `col > ?` comparison for a single column and the +/// multi-column tuple form `(c1, c2) > (?, ?)` for two or more columns. +fn format_clustering_comparison(cols: &[String]) -> String { + if cols.len() == 1 { + format!("{} > ?", cols[0]) + } else { + let placeholders = vec!["?"; cols.len()].join(", "); + format!("({}) > ({})", cols.join(", "), placeholders) + } +} diff --git a/crates/storage-cassandra/src/data/transaction_ledger.rs b/crates/storage-cassandra/src/data/transaction_ledger.rs new file mode 100644 index 00000000..bc4fbaf7 --- /dev/null +++ b/crates/storage-cassandra/src/data/transaction_ledger.rs @@ -0,0 +1,319 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Transaction ledger access functions for DynamoDB transactions. + +use cdrs_tokio::types::IntoRustByName; +use cdrs_tokio::types::value::Bytes; +use extenddb_storage::error::StorageError; +use uuid::Uuid; + +use crate::CassandraEngine; +use crate::cassandra_util::{get_column, query_optional, query_rows}; + +/// Transaction states (from DynamoDB paper). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TransactionState { + Preparing, + Committing, + Cancelling, +} + +impl TransactionState { + pub fn as_str(&self) -> &'static str { + match self { + Self::Preparing => "PREPARING", + Self::Committing => "COMMITTING", + Self::Cancelling => "CANCELLING", + } + } + + pub fn from_str(s: &str) -> Option { + match s { + "PREPARING" => Some(Self::Preparing), + "COMMITTING" => Some(Self::Committing), + "CANCELLING" => Some(Self::Cancelling), + _ => None, + } + } +} + +/// A single operation stored in the transaction ledger blob. +/// +/// This is the serializable, owned projection of what the PREPARE phase +/// computed. It is distinct from `TransactWriteOp`, which is a borrowed, +/// request-scoped type holding parsed expressions and references that cannot +/// survive a process restart. +/// +/// By the time a `LedgerOp` is created, all conditions have been evaluated +/// and update actions applied. It therefore contains only the minimal data +/// needed to either resume a COMMIT (write `item_data` to the base table and +/// clear the transaction marker) or execute a ROLLBACK (delete the row if +/// `created_to_prepare` is set on it, otherwise clear the transaction marker). +/// No expressions, key schemas, or attribute definitions are required. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct LedgerOp { + /// Operation type: "PUT", "UPDATE", "DELETE", "CHECK" + pub op: String, + /// DynamoDB table ID (not table name) + pub table_id: String, + /// Composite partition key text (as stored in Cassandra `pk` column) + pub pk: String, + /// Sort key column name ("sk_s", "sk_n", "sk_b"), if table has a sort key + pub sk_col: Option, + /// Sort key value serialized as a JSON string, if table has a sort key + pub sk_val: Option, + /// Final item data JSON for PUT/UPDATE (the post-mutation state to write on + /// COMMIT). None for DELETE and CHECK, which require no item data. + pub item_data: Option, +} + +/// Transaction ledger entry. +#[derive(Debug, Clone)] +pub struct LedgerEntry { + pub txn_id: Uuid, + pub state: String, + pub started_at: i64, + pub client_token: Option, + pub request_fingerprint: Option, + pub items_blob: String, +} + +impl LedgerEntry { + /// Parse `items_blob` into the list of `LedgerOp`s. + pub fn parse_ops(&self) -> Result, StorageError> { + serde_json::from_str(&self.items_blob) + .map_err(|e| StorageError::Internal(format!("parse ledger ops: {e}"))) + } +} + +impl CassandraEngine { + /// Write a new transaction ledger entry. + /// + /// Returns an error if the transaction ID already exists (LWT failure). + pub async fn write_ledger_entry( + &self, + keyspace: &str, + txn_id: Uuid, + state: TransactionState, + started_at: i64, + client_token: Option<&str>, + request_fingerprint: Option<&str>, + items_blob: &str, + ) -> Result<(), StorageError> { + let query = format!( + "INSERT INTO {}.transaction_ledger \ + (txn_id, state, started_at, client_token, request_fingerprint, items_blob) \ + VALUES (?, ?, ?, ?, ?, ?) IF NOT EXISTS", + keyspace + ); + + let result = self + .session + .query_with_values( + &query, + cdrs_tokio::query_values!( + Bytes::new(txn_id.as_bytes().to_vec()), + state.as_str(), + started_at, + client_token, + request_fingerprint, + items_blob + ), + ) + .await + .map_err(|e| { + tracing::error!("write_ledger_entry: {e}"); + StorageError::Internal("Database error".to_owned()) + })?; + + let body = result.response_body().map_err(|e| { + tracing::error!("write_ledger_entry response_body: {e}"); + StorageError::Internal("Database error".to_owned()) + })?; + + if let Some(rows) = body.into_rows() { + if let Some(row) = rows.first() { + let applied: bool = get_column(&row, "[applied]", "write_ledger_entry")?; + if !applied { + tracing::error!("write_ledger_entry: transaction ID already exists"); + return Err(StorageError::Internal( + "Transaction ID already exists".to_owned(), + )); + } + } + } + + Ok(()) + } + + /// Update ledger state. + pub async fn update_ledger_state( + &self, + keyspace: &str, + txn_id: Uuid, + new_state: TransactionState, + ) -> Result<(), StorageError> { + let query = format!( + "UPDATE {}.transaction_ledger SET state = ? WHERE txn_id = ?", + keyspace + ); + + self.session + .query_with_values( + &query, + cdrs_tokio::query_values!( + new_state.as_str(), + Bytes::new(txn_id.as_bytes().to_vec()) + ), + ) + .await + .map_err(|e| { + tracing::error!("update_ledger_state: {e}"); + StorageError::Internal("Database error".to_owned()) + })?; + + Ok(()) + } + + /// Update the items blob in the ledger (called after PREPARE succeeds, before COMMITTING). + /// + /// This stores the full `LedgerOp` list so the recovery worker has everything + /// it needs to resume a COMMIT or execute a ROLLBACK without any in-memory state. + pub async fn update_ledger_blob( + &self, + keyspace: &str, + txn_id: Uuid, + ops: &[LedgerOp], + ) -> Result<(), StorageError> { + let blob = serde_json::to_string(ops) + .map_err(|e| StorageError::Internal(format!("serialize ledger ops: {e}")))?; + let query = format!( + "UPDATE {}.transaction_ledger SET items_blob = ? WHERE txn_id = ?", + keyspace + ); + self.session + .query_with_values( + &query, + cdrs_tokio::query_values!(blob, Bytes::new(txn_id.as_bytes().to_vec())), + ) + .await + .map_err(|e| { + tracing::error!("update_ledger_blob: {e}"); + StorageError::Internal("Database error".to_owned()) + })?; + Ok(()) + } + + /// Read a transaction ledger entry by ID. + pub async fn read_ledger_entry( + &self, + keyspace: &str, + txn_id: Uuid, + ) -> Result, StorageError> { + let query = format!( + "SELECT txn_id, state, started_at, client_token, request_fingerprint, items_blob \ + FROM {}.transaction_ledger WHERE txn_id = ?", + keyspace + ); + + let row = query_optional::( + &self.session, + &query, + cdrs_tokio::query_values!(Bytes::new(txn_id.as_bytes().to_vec())), + "read_ledger_entry", + ) + .await?; + + let Some(row) = row else { + return Ok(None); + }; + + let txn_id: Uuid = get_column(&row, "txn_id", "read_ledger_entry")?; + + let state: String = get_column(&row, "state", "read_ledger_entry")?; + let started_at: i64 = get_column(&row, "started_at", "read_ledger_entry")?; + let client_token: Option = row.get_by_name("client_token").ok().flatten(); + let request_fingerprint: Option = + row.get_by_name("request_fingerprint").ok().flatten(); + let items_blob: String = get_column(&row, "items_blob", "read_ledger_entry")?; + + Ok(Some(LedgerEntry { + txn_id, + state, + started_at, + client_token, + request_fingerprint, + items_blob, + })) + } + + /// Scan for old transactions (for recovery worker). + pub async fn scan_old_transactions( + &self, + keyspace: &str, + cutoff_timestamp: i64, + ) -> Result, StorageError> { + let query = format!( + "SELECT txn_id, state, started_at, client_token, request_fingerprint, items_blob \ + FROM {}.transaction_ledger WHERE started_at < ? ALLOW FILTERING", + keyspace + ); + + let rows = query_rows::( + &self.session, + &query, + cdrs_tokio::query_values!(cutoff_timestamp), + "scan_old_transactions", + ) + .await?; + + let mut entries = Vec::new(); + for row in rows { + let txn_id: Uuid = get_column(&row, "txn_id", "scan_old_transactions")?; + + let state: String = get_column(&row, "state", "scan_old_transactions")?; + let started_at: i64 = get_column(&row, "started_at", "scan_old_transactions")?; + let client_token: Option = row.get_by_name("client_token").ok().flatten(); + let request_fingerprint: Option = + row.get_by_name("request_fingerprint").ok().flatten(); + let items_blob: String = get_column(&row, "items_blob", "scan_old_transactions")?; + + entries.push(LedgerEntry { + txn_id, + state, + started_at, + client_token, + request_fingerprint, + items_blob, + }); + } + + Ok(entries) + } + + /// Delete a transaction from the ledger. + pub async fn delete_ledger_entry( + &self, + keyspace: &str, + txn_id: Uuid, + ) -> Result<(), StorageError> { + let query = format!( + "DELETE FROM {}.transaction_ledger WHERE txn_id = ?", + keyspace + ); + + self.session + .query_with_values( + &query, + cdrs_tokio::query_values!(Bytes::new(txn_id.as_bytes().to_vec())), + ) + .await + .map_err(|e| { + tracing::error!("delete_ledger_entry: {e}"); + StorageError::Internal("Database error".to_owned()) + })?; + + Ok(()) + } +} diff --git a/crates/storage-cassandra/src/data/transactions.rs b/crates/storage-cassandra/src/data/transactions.rs new file mode 100644 index 00000000..f95cf5b4 --- /dev/null +++ b/crates/storage-cassandra/src/data/transactions.rs @@ -0,0 +1,1529 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! DynamoDB transaction implementations for Cassandra backend. + +use cdrs_tokio::frame::Envelope; +use cdrs_tokio::types::IntoRustByName; +use cdrs_tokio::types::value::Bytes; +use extenddb_core::expression::{self, ExpressionMaps}; +use extenddb_core::types::{ + AttributeValue, CancellationReason, Item, ReturnValuesOnConditionCheckFailure, TableKeyInfo, +}; +use extenddb_core::validation; +use extenddb_storage::TransactGetOp; +use extenddb_storage::TransactWriteOp; +use extenddb_storage::error::StorageError; +use extenddb_storage::util::{SortKeyValue, composite_pk_to_text, parse_sk, sk_column, sk_info}; +use uuid::Uuid; + +use crate::CassandraEngine; +use crate::cassandra_util::{get_column, query_optional}; +use crate::data::ddl::data_table_name; +use crate::data::transaction_ledger::{LedgerOp, TransactionState}; +use crate::data::{ + query_with_item_ts_pk_sk_txnid, query_with_pk_sk_item_txnid_ts, query_with_pk_sk_txnid, + query_with_txnid_ts_pk_sk, select_by_pk, +}; + +/// Create a transaction conflict cancellation reason. +fn transaction_conflict_reason() -> CancellationReason { + CancellationReason { + code: "TransactionConflict".to_owned(), + message: Some("Transaction is in use by another operation".to_owned()), + item: None, + } +} + +impl CassandraEngine { + /// Implementation of `DataEngine::transact_get_items`. + /// + /// Uses a two-phase read protocol to ensure serializability: + /// 1. Read all items with timestamps and prepared_txn_id + /// 2. Verify timestamps and prepared_txn_id haven't changed + pub(crate) async fn transact_get_items_impl( + &self, + ops: &[TransactGetOp<'_>], + ) -> Result>, StorageError> { + // T2.1: Request parsing and validation + // Validate key types before reading + let mut reasons: Vec = Vec::with_capacity(ops.len()); + let mut any_failed = false; + for op in ops { + match validation::validate_key_only( + op.key, + &op.key_info.key_schema, + &op.key_info.attribute_definitions, + ) { + Ok(()) => reasons.push(CancellationReason::none()), + Err(e) => { + any_failed = true; + reasons.push(CancellationReason::validation_error(e.to_string())); + } + } + } + if any_failed { + return Err(StorageError::TransactionCanceled(reasons)); + } + + // Verify all operations are in the same account (same keyspace) + if ops.len() > 1 { + let first_account = &ops[0].key_info.account_id; + for op in &ops[1..] { + if &op.key_info.account_id != first_account { + return Err(StorageError::Validation( + "Cross-account transactions not supported".to_owned(), + )); + } + } + } + + // T2.2: Two-phase read protocol + // Phase 1: Read all items with timestamps + let mut phase1_results = Vec::with_capacity(ops.len()); + for op in ops { + let result = self.read_item_with_metadata(op).await?; + phase1_results.push(result); + } + + // Check for prepared transactions in Phase 1 data + for (i, result) in phase1_results.iter().enumerate() { + if let Some((_, _, prepared_txn_id)) = result { + if prepared_txn_id.is_some() { + // Item is in a prepared transaction - conflict + let mut reasons = vec![CancellationReason::none(); ops.len()]; + reasons[i] = transaction_conflict_reason(); + return Err(StorageError::TransactionCanceled(reasons)); + } + } + } + + // Phase 2: Verify timestamps haven't changed + for (i, op) in ops.iter().enumerate() { + let phase1_metadata = &phase1_results[i]; + let phase2_metadata = self.read_item_metadata_only(op).await?; + + // Compare timestamps and prepared_txn_id + match (phase1_metadata, &phase2_metadata) { + (Some((_, ts1, prep1)), Some((ts2, prep2))) => { + if ts1 != ts2 || prep1 != prep2 { + // Timestamp changed or transaction started + let mut reasons = vec![CancellationReason::none(); ops.len()]; + reasons[i] = transaction_conflict_reason(); + return Err(StorageError::TransactionCanceled(reasons)); + } + } + (None, Some(_)) => { + // Item was created between phases + let mut reasons = vec![CancellationReason::none(); ops.len()]; + reasons[i] = transaction_conflict_reason(); + return Err(StorageError::TransactionCanceled(reasons)); + } + _ => { + // Both None or phase2 is None (item deleted) - acceptable + } + } + } + + // All verifications passed - return Phase 1 item data + Ok(phase1_results + .into_iter() + .map(|r| r.map(|(item, _, _)| item)) + .collect()) + } + + /// Read an item with full metadata for Phase 1. + /// + /// Returns: Option<(Item, last_committed_txn_timestamp, prepared_txn_id)> + async fn read_item_with_metadata( + &self, + op: &TransactGetOp<'_>, + ) -> Result)>, StorageError> { + let keyspace = self.account_keyspace(&op.key_info.account_id); + let table = data_table_name(&op.key_info.table_id); + let pk_text = composite_pk_to_text(op.key, &op.key_info.key_schema)?; + let (sk, sk_col) = resolve_sk_get(op)?; + + let Some(row) = select_by_pk( + &self.session, + &keyspace, + &table, + "item_data, last_committed_txn_timestamp, prepared_txn_id", + pk_text.as_str(), + sk.as_ref(), + sk_col.as_deref(), + ) + .await? + else { + return Ok(None); + }; + + let item_data: String = get_column(&row, "item_data", "read_item_with_metadata")?; + let item: Item = + serde_json::from_str(&item_data).map_err(|e| StorageError::Internal(e.to_string()))?; + let last_committed: Option = row + .get_by_name("last_committed_txn_timestamp") + .ok() + .flatten(); + let prepared_txn_id: Option = row.get_by_name("prepared_txn_id").ok().flatten(); + Ok(Some((item, last_committed.unwrap_or(0), prepared_txn_id))) + } + + async fn read_item_metadata_only( + &self, + op: &TransactGetOp<'_>, + ) -> Result)>, StorageError> { + let keyspace = self.account_keyspace(&op.key_info.account_id); + let table = data_table_name(&op.key_info.table_id); + let pk_text = composite_pk_to_text(op.key, &op.key_info.key_schema)?; + let (sk, sk_col) = resolve_sk_get(op)?; + + let Some(row) = select_by_pk( + &self.session, + &keyspace, + &table, + "last_committed_txn_timestamp, prepared_txn_id", + pk_text.as_str(), + sk.as_ref(), + sk_col.as_deref(), + ) + .await? + else { + return Ok(None); + }; + + let last_committed: Option = row + .get_by_name("last_committed_txn_timestamp") + .ok() + .flatten(); + let prepared_txn_id: Option = row.get_by_name("prepared_txn_id").ok().flatten(); + Ok(Some((last_committed.unwrap_or(0), prepared_txn_id))) + } + + /// Implementation of `DataEngine::transact_write_items`. + /// + /// Implements a two-phase commit protocol using Lightweight Transactions (LWT): + /// 1. PREPARE: Mark all items with transaction ID using LWT + /// 2. COMMIT/ROLLBACK: Apply or revert changes atomically + /// Implementation of TransactWriteItems. + pub(crate) async fn transact_write_items_impl( + &self, + ops: &[TransactWriteOp<'_>], + idempotency: Option<(&str, &str, &str)>, + ) -> Result<(), StorageError> { + // T4.1: Request parsing and ledger creation + + // Validate: all operations in same account (same keyspace) + if ops.is_empty() { + return Ok(()); + } + + let first_account = transact_write_op_account_id(&ops[0]); + for op in &ops[1..] { + if transact_write_op_account_id(op) != first_account { + return Err(StorageError::Validation( + "Cross-account transactions not supported".to_owned(), + )); + } + } + + let account_keyspace = self.account_keyspace(first_account); + + // Check the account-scoped idempotency token before creating the ledger. + if let Some((idempotency_account, token, fingerprint)) = idempotency { + if idempotency_account != first_account { + return Err(StorageError::Validation( + "Idempotency account does not match transaction account".to_owned(), + )); + } + self.check_idempotency_token( + &self.catalog_keyspace(), + idempotency_account, + token, + fingerprint, + ) + .await?; + } + + // Generate unique transaction ID + let txn_id = Uuid::new_v4(); + let started_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64; + + // Build initial ledger ops (pk/sk known; item_data for UPDATE filled in after PREPARE) + let mut ledger_ops = Self::initial_ledger_ops(ops)?; + let initial_blob = serde_json::to_string(&ledger_ops) + .map_err(|e| StorageError::Internal(format!("serialize ledger ops: {e}")))?; + + // Write ledger entry with state='preparing' + self.write_ledger_entry( + &account_keyspace, + txn_id, + TransactionState::Preparing, + started_at, + idempotency.map(|(_, token, _)| token), + idempotency.map(|(_, _, fingerprint)| fingerprint), + &initial_blob, + ) + .await?; + + // T4.2: PREPARE phase - returns computed item_data for UPDATE ops + let prepare_result = self.execute_prepare_phase(ops, txn_id, started_at).await; + + match prepare_result { + Ok(computed_items) => { + // Fill in item_data for UPDATE ops now that PREPARE has computed them + for (ledger_op, computed) in ledger_ops.iter_mut().zip(computed_items.iter()) { + if let Some(data) = computed { + ledger_op.item_data = Some(data.clone()); + } + } + // Update ledger blob with full data before transitioning to COMMITTING + self.update_ledger_blob(&account_keyspace, txn_id, &ledger_ops) + .await?; + // T4.3: COMMIT phase + self.execute_commit_phase(&account_keyspace, ops, txn_id, started_at) + .await + } + Err(reasons) => { + // T4.4: ROLLBACK phase + tracing::debug!("transact_write: PREPARE failed ({} reasons), rolling back txn {txn_id}", reasons.len()); + self.execute_rollback_phase(&account_keyspace, ops, txn_id) + .await?; + Err(StorageError::TransactionCanceled(reasons)) + } + } + } + + /// Execute PREPARE phase: validate conditions and mark items with transaction ID. + /// + /// Returns `Ok(computed_items)` where each entry is `Some(item_data_json)` for + /// UPDATE ops (the post-mutation state) and `None` for all other op types. + async fn execute_prepare_phase( + &self, + ops: &[TransactWriteOp<'_>], + txn_id: Uuid, + txn_timestamp: i64, + ) -> Result>, Vec> { + let mut reasons: Vec = Vec::with_capacity(ops.len()); + let mut computed: Vec> = Vec::with_capacity(ops.len()); + let mut any_failed = false; + + for op in ops { + match self.prepare_single_operation(op, txn_id, txn_timestamp).await { + Ok(item_data) => { + reasons.push(CancellationReason::none()); + computed.push(item_data); + } + Err(r) => { + any_failed = true; + reasons.push(r); + computed.push(None); + } + } + } + + if any_failed { Err(reasons) } else { Ok(computed) } + } + + /// Prepare a single transactional operation. + /// + /// Returns `Ok(Some(item_data_json))` for UPDATE (the post-mutation state), + /// `Ok(None)` for PUT/DELETE/CHECK, or `Err(reason)` on failure. + async fn prepare_single_operation( + &self, + op: &TransactWriteOp<'_>, + txn_id: Uuid, + txn_timestamp: i64, + ) -> Result, CancellationReason> { + match op { + TransactWriteOp::Put { + key_info, + item, + condition, + maps, + return_values_on_ccf, + .. + } => { + // Validate item keys + validation::validate_item_keys( + item, + &key_info.key_schema, + &key_info.attribute_definitions, + ) + .map_err(|e| CancellationReason::validation_error(e.to_string()))?; + + // Read existing item + let existing = self + .fetch_item_for_transaction(key_info, item) + .await + .map_err(|e| CancellationReason::validation_error(e.to_string()))?; + + tracing::debug!( + "prepare Put: table={} pk_keys={:?} existing={}", + key_info.table_name, + item.keys().collect::>(), + existing.is_some() + ); + + // Evaluate condition + let empty = Item::new(); + eval_condition( + *condition, + existing.as_ref().unwrap_or(&empty), + maps, + *return_values_on_ccf, + existing.as_ref(), + )?; + + // For new items (PUT), check partition_max_delete_timestamp + if existing.is_none() { + self.check_partition_max_delete_timestamp(key_info, item, txn_timestamp) + .await?; + } + + // Execute PREPARE + self.prepare_item(key_info, item, txn_id, txn_timestamp, existing.is_none()) + .await?; + Ok(None) + } + TransactWriteOp::Delete { + key_info, + key, + condition, + maps, + return_values_on_ccf, + .. + } => { + // Validate key + validation::validate_key_only( + key, + &key_info.key_schema, + &key_info.attribute_definitions, + ) + .map_err(|e| CancellationReason::validation_error(e.to_string()))?; + + // Read existing item + let existing = self + .fetch_item_for_transaction(key_info, key) + .await + .map_err(|e| CancellationReason::validation_error(e.to_string()))?; + + // Evaluate condition + let empty = Item::new(); + eval_condition( + *condition, + existing.as_ref().unwrap_or(&empty), + maps, + *return_values_on_ccf, + existing.as_ref(), + )?; + + // Execute PREPARE + self.prepare_item(key_info, key, txn_id, txn_timestamp, false) + .await?; + Ok(None) + } + TransactWriteOp::Update { + key_info, + key, + actions, + condition, + maps, + return_values_on_ccf, + .. + } => { + // Validate key + validation::validate_key_only( + key, + &key_info.key_schema, + &key_info.attribute_definitions, + ) + .map_err(|e| CancellationReason::validation_error(e.to_string()))?; + + // Read existing item + let existing = self + .fetch_item_for_transaction(key_info, key) + .await + .map_err(|e| CancellationReason::validation_error(e.to_string()))?; + + // Create item from key if not exists + let mut item = existing.clone().unwrap_or_else(|| (*key).clone()); + + // Evaluate condition against empty item if non-existent + let condition_item = if existing.is_some() { + &item + } else { + &std::collections::BTreeMap::new() + }; + eval_condition( + *condition, + condition_item, + maps, + *return_values_on_ccf, + existing.as_ref(), + )?; + + // Apply update actions + expression::apply_update_validated(actions, &mut item, maps, &[], &[]) + .map_err(|e| CancellationReason::validation_error(e.to_string()))?; + + // Validate item size + const MAX_ITEM_SIZE_BYTES: usize = 400 * 1024; + validation::validate_item_size(&item, MAX_ITEM_SIZE_BYTES) + .map_err(|e| CancellationReason::validation_error(e.to_string()))?; + + // For new items, check partition_max_delete_timestamp + if existing.is_none() { + self.check_partition_max_delete_timestamp(key_info, &item, txn_timestamp) + .await?; + } + + // Execute PREPARE + self.prepare_item(key_info, key, txn_id, txn_timestamp, existing.is_none()) + .await?; + // Return the computed final item so the caller can update the ledger blob + let item_json = serde_json::to_string(&item) + .map_err(|e| CancellationReason::validation_error(e.to_string()))?; + Ok(Some(item_json)) + } + TransactWriteOp::ConditionCheck { + key_info, + key, + condition, + maps, + return_values_on_ccf, + } => { + // Validate key + validation::validate_key_only( + key, + &key_info.key_schema, + &key_info.attribute_definitions, + ) + .map_err(|e| CancellationReason::validation_error(e.to_string()))?; + + // Read existing item + let existing = self + .fetch_item_for_transaction(key_info, key) + .await + .map_err(|e| CancellationReason::validation_error(e.to_string()))?; + + // Evaluate condition + let empty = Item::new(); + let check_against = existing.as_ref().unwrap_or(&empty); + eval_condition( + Some(condition), + check_against, + maps, + *return_values_on_ccf, + existing.as_ref(), + )?; + + // ConditionCheck doesn't prepare any item + Ok(None) + } + } + } + + /// Execute COMMIT phase: apply changes and update ledger. + async fn execute_commit_phase( + &self, + account_keyspace: &str, + ops: &[TransactWriteOp<'_>], + txn_id: Uuid, + txn_timestamp: i64, + ) -> Result<(), StorageError> { + // Update ledger state to 'committing' + self.update_ledger_state(account_keyspace, txn_id, TransactionState::Committing) + .await?; + + // TODO: Execute COMMIT operations for each item in parallel + // For now, sequential execution + for op in ops { + self.commit_single_operation(op, txn_id, txn_timestamp) + .await?; + } + + // Delete transaction from ledger + self.delete_ledger_entry(account_keyspace, txn_id).await?; + + Ok(()) + } + + /// Execute ROLLBACK phase: clean up prepared items. + async fn execute_rollback_phase( + &self, + account_keyspace: &str, + ops: &[TransactWriteOp<'_>], + txn_id: Uuid, + ) -> Result<(), StorageError> { + // Update ledger state to 'rollback' (uses CANCELLING state) + self.update_ledger_state(account_keyspace, txn_id, TransactionState::Cancelling) + .await?; + + // TODO: Execute ROLLBACK operations for each item in parallel + // For now, sequential execution + for op in ops { + if let Err(e) = self.rollback_single_operation(op, txn_id).await { + tracing::error!("execute_rollback_phase: rollback_single_operation failed: {e}"); + // Continue rolling back other ops even if one fails, then return the error. + // For now, return immediately to preserve existing behaviour. + return Err(e); + } + } + + // Delete transaction from ledger + self.delete_ledger_entry(account_keyspace, txn_id).await?; + + Ok(()) + } +} + +/// Evaluate a condition expression, returning a CancellationReason on failure. +fn eval_condition( + condition: Option<&extenddb_core::expression::Expr>, + item: &std::collections::BTreeMap, + maps: &ExpressionMaps, + return_values_on_ccf: ReturnValuesOnConditionCheckFailure, + existing: Option<&Item>, +) -> Result<(), CancellationReason> { + if let Some(cond) = condition { + let passed = expression::evaluate_condition(cond, item, maps) + .map_err(|e| CancellationReason::validation_error(e.to_string()))?; + if !passed { + let item_to_return = + if return_values_on_ccf == ReturnValuesOnConditionCheckFailure::AllOld { + existing.cloned() + } else { + None + }; + return Err(CancellationReason::condition_check_failed_with_item( + item_to_return, + )); + } + } + Ok(()) +} + +/// Extract account_id from a TransactWriteOp. +fn transact_write_op_account_id<'a>(op: &'a TransactWriteOp<'_>) -> &'a str { + match op { + TransactWriteOp::Put { key_info, .. } + | TransactWriteOp::Delete { key_info, .. } + | TransactWriteOp::Update { key_info, .. } + | TransactWriteOp::ConditionCheck { key_info, .. } => &key_info.account_id, + } +} + +impl CassandraEngine { + /// Serialize TransactWriteOps to JSON for ledger storage. + /// Build initial `LedgerOp`s from the request ops. + /// + /// Written to the ledger before PREPARE starts. Contains pk/sk so a crash + /// during PREPARE can be rolled back. `item_data` for UPDATE is `None` here + /// and filled in after PREPARE succeeds (before transitioning to COMMITTING). + fn initial_ledger_ops(ops: &[TransactWriteOp<'_>]) -> Result, StorageError> { + ops.iter() + .map(|op| { + let (op_type, key_info, key) = match op { + TransactWriteOp::Put { key_info, item, .. } => ("PUT", *key_info, *item), + TransactWriteOp::Delete { key_info, key, .. } => ("DELETE", *key_info, *key), + TransactWriteOp::Update { key_info, key, .. } => ("UPDATE", *key_info, *key), + TransactWriteOp::ConditionCheck { key_info, key, .. } => { + ("CHECK", *key_info, *key) + } + }; + let pk = composite_pk_to_text(key, &key_info.key_schema)?; + let (sk_col, sk_val) = + if let Some((sk_name, sk_type)) = + sk_info(&key_info.key_schema, &key_info.attribute_definitions) + { + let sk_value = key.get(sk_name).ok_or_else(|| { + StorageError::Internal("missing sort key".to_owned()) + })?; + let sk = parse_sk(sk_value, sk_type)?; + let col = sk_column(sk_type).to_owned(); + // Store as the text representation used in Cassandra queries. + // sk_col ("sk_s"/"sk_n"/"sk_b") encodes the type; no separate type tag needed. + let val = match &sk { + SortKeyValue::S(s) => s.clone(), + SortKeyValue::N(n) => n.to_string(), + SortKeyValue::B(b) => { + use base64::Engine as _; + base64::engine::general_purpose::STANDARD.encode(b) + } + }; + (Some(col), Some(val)) + } else { + (None, None) + }; + // For PUT, item_data is known immediately. + // For UPDATE, item_data is filled in after PREPARE (update_ledger_blob). + // For DELETE and CHECK, item_data is never needed. + let item_data = if op_type == "PUT" { + let item = match op { + TransactWriteOp::Put { item, .. } => *item, + _ => unreachable!(), + }; + Some( + serde_json::to_string(item) + .map_err(|e| StorageError::Internal(e.to_string()))?, + ) + } else { + None + }; + Ok(LedgerOp { + op: op_type.to_owned(), + table_id: key_info.table_id.clone(), + pk, + sk_col, + sk_val, + item_data, + }) + }) + .collect() + } + + /// Check and reserve an account-scoped idempotency token, scoped to `account_id`. + async fn check_idempotency_token( + &self, + keyspace: &str, + account_id: &str, + token: &str, + fingerprint: &str, + ) -> Result<(), StorageError> { + let select_query = format!( + "SELECT fingerprint FROM {}.idempotency_tokens_by_account WHERE account_id = ? AND \"token\" = ?", + keyspace + ); + + let row = query_optional::( + &self.session, + &select_query, + cdrs_tokio::query_values!(account_id, token), + "check_idempotency_token", + ) + .await?; + + if let Some(row) = row { + let stored_fp: String = get_column(&row, "fingerprint", "check_idempotency_token")?; + return if stored_fp == fingerprint { + Err(StorageError::IdempotentReplay) + } else { + Err(StorageError::IdempotentMismatch) + }; + } + + // Insert with LWT to handle concurrent requests racing on the same token. + let insert_query = format!( + "INSERT INTO {}.idempotency_tokens_by_account (account_id, \"token\", fingerprint, created_at) \ + VALUES (?, ?, ?, ?) IF NOT EXISTS", + keyspace + ); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64; + + let result = self + .session + .query_with_values( + &insert_query, + cdrs_tokio::query_values!(account_id, token, fingerprint, now), + ) + .await + .map_err(|e| { + tracing::error!("check_idempotency_token insert: {e}"); + StorageError::Internal("Database error".to_owned()) + })?; + + // If the LWT was not applied, a concurrent request won the race. + // Read back what was stored and return the appropriate error. + use cdrs_tokio::types::IntoRustByName; + let applied: bool = result + .response_body() + .ok() + .and_then(|b| b.into_rows()) + .and_then(|mut rows| rows.drain(..).next()) + .and_then(|row| row.get_r_by_name("[applied]").ok()) + .unwrap_or(true); + + if !applied { + let row = query_optional::( + &self.session, + &select_query, + cdrs_tokio::query_values!(account_id, token), + "check_idempotency_token recheck", + ) + .await?; + if let Some(row) = row { + let stored_fp: String = + get_column(&row, "fingerprint", "check_idempotency_token recheck")?; + return if stored_fp == fingerprint { + Err(StorageError::IdempotentReplay) + } else { + Err(StorageError::IdempotentMismatch) + }; + } + } + + Ok(()) + } + + /// Fetch an item for transaction (reads item_data and prepared_txn_id). + async fn fetch_item_for_transaction( + &self, + key_info: &TableKeyInfo, + key: &Item, + ) -> Result, StorageError> { + let keyspace = self.account_keyspace(&key_info.account_id); + let table = data_table_name(&key_info.table_id); + let pk_text = composite_pk_to_text(key, &key_info.key_schema)?; + let (sk, sk_col) = resolve_sk(key_info, key)?; + + let Some(row) = select_by_pk( + &self.session, + &keyspace, + &table, + "item_data", + pk_text.as_str(), + sk.as_ref(), + sk_col.as_deref(), + ) + .await? + else { + return Ok(None); + }; + + let item_data: String = get_column(&row, "item_data", "fetch_item_for_transaction")?; + Ok(Some( + serde_json::from_str(&item_data).map_err(|e| StorageError::Internal(e.to_string()))?, + )) + } + + /// Check partition_max_delete_timestamp for new items. + async fn check_partition_max_delete_timestamp( + &self, + key_info: &TableKeyInfo, + key: &Item, + txn_timestamp: i64, + ) -> Result<(), CancellationReason> { + let account_keyspace = self.account_keyspace(&key_info.account_id); + let table_name = data_table_name(&key_info.table_id); + + let pk_text = composite_pk_to_text(key, &key_info.key_schema) + .map_err(|e| CancellationReason::validation_error(e.to_string()))?; + + let query = format!( + "SELECT partition_max_delete_timestamp FROM {}.{} WHERE pk = ? LIMIT 1", + account_keyspace, table_name + ); + + let result = self + .session + .query_with_values(&query, cdrs_tokio::query_values!(pk_text.as_str())) + .await + .map_err(|e| { + tracing::error!("check_partition_max_delete_timestamp: {e}"); + CancellationReason::validation_error("Database error".to_owned()) + })?; + + let body = result.response_body().map_err(|e| { + tracing::error!("check_partition_max_delete_timestamp response_body: {e}"); + CancellationReason::validation_error("Database error".to_owned()) + })?; + + let rows = body.into_rows().unwrap_or_default(); + if let Some(row) = rows.into_iter().next() { + let max_ts: Option = row + .get_by_name("partition_max_delete_timestamp") + .ok() + .flatten(); + if let Some(max_ts) = max_ts { + if txn_timestamp <= max_ts { + return Err(CancellationReason::validation_error( + "Item was deleted at a later timestamp".to_owned(), + )); + } + } + } + + Ok(()) + } + + /// PREPARE an item: mark with transaction ID using LWT. + /// + /// For existing items: UPDATE with IF prepared_txn_id = null + /// For new items: INSERT with IF NOT EXISTS + created_to_prepare=true + async fn prepare_item( + &self, + key_info: &TableKeyInfo, + key: &Item, + txn_id: Uuid, + txn_timestamp: i64, + is_new_item: bool, + ) -> Result<(), CancellationReason> { + use cdrs_tokio::types::value::Bytes; + + let keyspace = self.account_keyspace(&key_info.account_id); + let ddb_table = super::ddl::data_table_name(&key_info.table_id); + let pk_text = composite_pk_to_text(key, &key_info.key_schema) + .map_err(|e| CancellationReason::validation_error(e.to_string()))?; + + let txn_id_bytes = Bytes::new(txn_id.as_bytes().to_vec()); + + let result = if let Some((sk_name, sk_type)) = sk_info(&key_info.key_schema, &key_info.attribute_definitions) { + let sk_value = key.get(sk_name).ok_or_else(|| { + CancellationReason::validation_error("missing sort key".to_owned()) + })?; + let sk = parse_sk(sk_value, sk_type) + .map_err(|e| CancellationReason::validation_error(e.to_string()))?; + let sk_col = sk_column(sk_type); + + if is_new_item { + let item_text = serde_json::to_value(key) + .map_err(|e| CancellationReason::validation_error(e.to_string()))? + .to_string(); + let query = format!( + "INSERT INTO {}.{} (pk, {}, item_data, prepared_txn_id, prepared_txn_timestamp, created_to_prepare) \ + VALUES (?, ?, ?, ?, ?, true) IF NOT EXISTS", + keyspace, ddb_table, sk_col + ); + query_with_pk_sk_item_txnid_ts(&self.session, &query, pk_text.as_str(), &sk, &item_text, txn_id_bytes, txn_timestamp).await + } else { + let query = format!( + "UPDATE {}.{} SET prepared_txn_id = ?, prepared_txn_timestamp = ? \ + WHERE pk = ? AND {} = ? IF prepared_txn_id = null", + keyspace, ddb_table, sk_col + ); + query_with_txnid_ts_pk_sk(&self.session, &query, txn_id_bytes, txn_timestamp, pk_text.as_str(), &sk).await + } + } else if is_new_item { + let item_text = serde_json::to_value(key) + .map_err(|e| CancellationReason::validation_error(e.to_string()))? + .to_string(); + let query = format!( + "INSERT INTO {}.{} (pk, item_data, prepared_txn_id, prepared_txn_timestamp, created_to_prepare) \ + VALUES (?, ?, ?, ?, true) IF NOT EXISTS", + keyspace, ddb_table + ); + self.session + .query_with_values(&query, cdrs_tokio::query_values!(pk_text.as_str(), item_text, txn_id_bytes, txn_timestamp)) + .await + .map_err(|e| StorageError::Internal(e.to_string())) + } else { + let query = format!( + "UPDATE {}.{} SET prepared_txn_id = ?, prepared_txn_timestamp = ? \ + WHERE pk = ? IF prepared_txn_id = null", + keyspace, ddb_table + ); + self.session + .query_with_values(&query, cdrs_tokio::query_values!(txn_id_bytes, txn_timestamp, pk_text.as_str())) + .await + .map_err(|e| StorageError::Internal(e.to_string())) + } + .map_err(|e| { + tracing::error!("prepare_item: {e}"); + CancellationReason::validation_error("Database error".to_owned()) + })?; + + check_lwt_applied(&result, "prepare_item")?; + Ok(()) + } + + /// COMMIT a single operation: apply changes. + /// + /// For Put/Update: Write final item_data, clear prepared_txn_id, set last_committed_txn_timestamp + /// For Delete: Update partition_max_delete_timestamp, then delete the item + async fn commit_single_operation( + &self, + op: &TransactWriteOp<'_>, + txn_id: Uuid, + txn_timestamp: i64, + ) -> Result<(), StorageError> { + use cdrs_tokio::types::value::Bytes; + + let txn_id_bytes = Bytes::new(txn_id.as_bytes().to_vec()); + + match op { + TransactWriteOp::Put { key_info, item, .. } => { + self.commit_put_or_update(key_info, item, txn_id_bytes.clone(), txn_timestamp) + .await + } + TransactWriteOp::Update { + key_info, + key, + actions, + maps, + .. + } => { + // Re-fetch and re-apply update (idempotent) + let existing = self.fetch_item_for_transaction(key_info, key).await?; + let mut final_item = existing.unwrap_or_else(|| (*key).clone()); + expression::apply_update_validated(actions, &mut final_item, maps, &[], &[]) + .map_err(|e| StorageError::Internal(e.to_string()))?; + + self.commit_put_or_update(key_info, &final_item, txn_id_bytes, txn_timestamp) + .await + } + TransactWriteOp::Delete { key_info, key, .. } => { + let keyspace = self.account_keyspace(&key_info.account_id); + let ddb_table = super::ddl::data_table_name(&key_info.table_id); + let pk_text = composite_pk_to_text(key, &key_info.key_schema)?; + + // Step 1: Update partition_max_delete_timestamp + // First, try to update if it's null (most common case - first delete in partition) + let update_max_ts_null_query = format!( + "UPDATE {}.{} SET partition_max_delete_timestamp = ? WHERE pk = ? \ + IF partition_max_delete_timestamp = null", + keyspace, ddb_table + ); + + let result = self + .session + .query_with_values( + &update_max_ts_null_query, + cdrs_tokio::query_values!(txn_timestamp, pk_text.as_str()), + ) + .await + .map_err(|e| { + tracing::error!( + "commit_single_operation (delete update partition_max): {e}" + ); + StorageError::Internal("Database error".to_owned()) + })?; + + // If that failed (column already has a value), try updating only if our timestamp is higher + if !check_lwt_applied(&result, "partition_max update").is_ok() { + let update_max_ts_query = format!( + "UPDATE {}.{} SET partition_max_delete_timestamp = ? WHERE pk = ? \ + IF partition_max_delete_timestamp < ?", + keyspace, ddb_table + ); + + let _result2 = self + .session + .query_with_values( + &update_max_ts_query, + cdrs_tokio::query_values!( + txn_timestamp, + pk_text.as_str(), + txn_timestamp + ), + ) + .await + .map_err(|e| { + tracing::error!( + "commit_single_operation (delete update partition_max compare): {e}" + ); + StorageError::Internal("Database error".to_owned()) + })?; + // We don't check LWT here - if another transaction set a higher timestamp, that's fine + } + + // Step 2: Delete the item with LWT check + let result = if let Some((sk_name, sk_type)) = + sk_info(&key_info.key_schema, &key_info.attribute_definitions) + { + let sk_value = key + .get(sk_name) + .ok_or_else(|| StorageError::Internal("missing sort key".to_owned()))?; + let sk = parse_sk(sk_value, sk_type)?; + let sk_col = sk_column(sk_type); + let delete_query = format!( + "DELETE FROM {}.{} WHERE pk = ? AND {} = ? IF prepared_txn_id = ?", + keyspace, ddb_table, sk_col + ); + query_with_pk_sk_txnid( + &self.session, + &delete_query, + pk_text.as_str(), + &sk, + txn_id_bytes, + ) + .await + } else { + let delete_query = format!( + "DELETE FROM {}.{} WHERE pk = ? IF prepared_txn_id = ?", + keyspace, ddb_table + ); + self.session + .query_with_values( + &delete_query, + cdrs_tokio::query_values!(pk_text.as_str(), txn_id_bytes), + ) + .await + .map_err(|e| StorageError::Internal(e.to_string())) + } + .map_err(|e| { + tracing::error!("commit_single_operation (delete): {e}"); + StorageError::Internal("Database error".to_owned()) + })?; + + check_lwt_applied(&result, "commit_single_operation").map_err(|_| { + StorageError::Internal("Transaction conflict during commit".to_owned()) + })?; + Ok(()) + } + TransactWriteOp::ConditionCheck { .. } => Ok(()), + } + } + + /// Helper to commit a Put or Update operation. + async fn commit_put_or_update( + &self, + key_info: &TableKeyInfo, + final_item: &Item, + txn_id_bytes: Bytes, + txn_timestamp: i64, + ) -> Result<(), StorageError> { + let keyspace = self.account_keyspace(&key_info.account_id); + let ddb_table = super::ddl::data_table_name(&key_info.table_id); + let pk_text = composite_pk_to_text(final_item, &key_info.key_schema)?; + + let item_json = + serde_json::to_value(final_item).map_err(|e| StorageError::Internal(e.to_string()))?; + let item_text = item_json.to_string(); + + let result = if let Some((sk_name, sk_type)) = sk_info(&key_info.key_schema, &key_info.attribute_definitions) { + let sk_value = final_item.get(sk_name) + .ok_or_else(|| StorageError::Internal("missing sort key".to_owned()))?; + let sk = parse_sk(sk_value, sk_type)?; + let sk_col = sk_column(sk_type); + + let query = format!( + "UPDATE {}.{} SET item_data = ?, prepared_txn_id = NULL, last_committed_txn_timestamp = ? \ + WHERE pk = ? AND {} = ? IF prepared_txn_id = ?", + keyspace, ddb_table, sk_col + ); + query_with_item_ts_pk_sk_txnid(&self.session, &query, &item_text, txn_timestamp, pk_text.as_str(), &sk, txn_id_bytes).await + } else { + let query = format!( + "UPDATE {}.{} SET item_data = ?, prepared_txn_id = NULL, last_committed_txn_timestamp = ? \ + WHERE pk = ? IF prepared_txn_id = ?", + keyspace, ddb_table + ); + self.session + .query_with_values(&query, cdrs_tokio::query_values!(item_text, txn_timestamp, pk_text.as_str(), txn_id_bytes)) + .await + .map_err(|e| StorageError::Internal(e.to_string())) + } + .map_err(|e| { + tracing::error!("commit_put_or_update: {e}"); + StorageError::Internal("Database error".to_owned()) + })?; + + check_lwt_applied(&result, "commit_put_or_update") + .map_err(|_| StorageError::Internal("Transaction conflict during commit".to_owned()))?; + Ok(()) + } + + /// ROLLBACK a single operation: clean up prepared state. + /// + /// For items created during PREPARE (created_to_prepare=true): DELETE the item + /// For existing items: Clear prepared_txn_id to restore unprepared state + async fn rollback_single_operation( + &self, + op: &TransactWriteOp<'_>, + txn_id: Uuid, + ) -> Result<(), StorageError> { + use cdrs_tokio::types::value::Bytes; + + // ConditionCheck doesn't prepare anything, so nothing to rollback + if matches!(op, TransactWriteOp::ConditionCheck { .. }) { + return Ok(()); + } + + let key_info = match op { + TransactWriteOp::Put { key_info, .. } + | TransactWriteOp::Delete { key_info, .. } + | TransactWriteOp::Update { key_info, .. } + | TransactWriteOp::ConditionCheck { key_info, .. } => key_info, + }; + + let key = match op { + TransactWriteOp::Put { item, .. } => *item, + TransactWriteOp::Delete { key, .. } | TransactWriteOp::Update { key, .. } => *key, + TransactWriteOp::ConditionCheck { key, .. } => *key, + }; + + let keyspace = self.account_keyspace(&key_info.account_id); + let ddb_table = super::ddl::data_table_name(&key_info.table_id); + let pk_text = composite_pk_to_text(key, &key_info.key_schema)?; + let txn_id_bytes = Bytes::new(txn_id.as_bytes().to_vec()); + + // We need to check if this item was created during PREPARE (created_to_prepare=true) + // or if it was an existing item. Fetch the item to check. + let existing = self.fetch_item_for_transaction(key_info, key).await?; + + // If item doesn't exist, it's already been cleaned up (idempotent) + if existing.is_none() { + return Ok(()); + } + + // Try DELETE first (for items created during PREPARE with created_to_prepare=true). + // If that fails, fall back to clearing prepared_txn_id (for pre-existing items). + let delete_result = if let Some((sk_name, sk_type)) = sk_info(&key_info.key_schema, &key_info.attribute_definitions) { + let sk_value = key.get(sk_name) + .ok_or_else(|| StorageError::Internal("missing sort key".to_owned()))?; + let sk = parse_sk(sk_value, sk_type)?; + let sk_col = sk_column(sk_type); + + let delete_query = format!( + "DELETE FROM {}.{} WHERE pk = ? AND {} = ? IF prepared_txn_id = ? AND created_to_prepare = true", + keyspace, ddb_table, sk_col + ); + let update_query = format!( + "UPDATE {}.{} SET prepared_txn_id = null WHERE pk = ? AND {} = ? IF prepared_txn_id = ?", + keyspace, ddb_table, sk_col + ); + + let dr = query_with_pk_sk_txnid(&self.session, &delete_query, pk_text.as_str(), &sk, txn_id_bytes.clone()) + .await + .map_err(|e| { tracing::error!("rollback delete: {e}"); e })?; + if check_lwt_applied(&dr, "rollback delete").is_ok() { + return Ok(()); + } + query_with_pk_sk_txnid(&self.session, &update_query, pk_text.as_str(), &sk, txn_id_bytes).await + } else { + let delete_query = format!( + "DELETE FROM {}.{} WHERE pk = ? IF prepared_txn_id = ? AND created_to_prepare = true", + keyspace, ddb_table + ); + let update_query = format!( + "UPDATE {}.{} SET prepared_txn_id = null WHERE pk = ? IF prepared_txn_id = ?", + keyspace, ddb_table + ); + + let dr = self.session + .query_with_values(&delete_query, cdrs_tokio::query_values!(pk_text.as_str(), txn_id_bytes.clone())) + .await + .map_err(|e| StorageError::Internal(e.to_string())) + .map_err(|e| { tracing::error!("rollback delete: {e}"); e })?; + if check_lwt_applied(&dr, "rollback delete").is_ok() { + return Ok(()); + } + self.session + .query_with_values(&update_query, cdrs_tokio::query_values!(pk_text.as_str(), txn_id_bytes)) + .await + .map_err(|e| StorageError::Internal(e.to_string())) + } + .map_err(|e| { + tracing::error!("rollback_single_operation: {e}"); + StorageError::Internal("Database error".to_owned()) + })?; + + check_lwt_applied(&delete_result, "rollback update").map_err(|_| { + StorageError::Internal("Transaction conflict during rollback".to_owned()) + })?; + Ok(()) + } + + /// Recover a single stale transaction found by the recovery worker. + /// + /// Reads the ledger entry, parses `LedgerOp`s, then resumes COMMIT or + /// executes ROLLBACK depending on the recorded state. Errors during + /// individual item operations are logged but do not abort recovery of the + /// remaining items; the ledger entry is deleted only when all items have + /// been processed successfully. + pub(crate) async fn recover_transaction( + &self, + keyspace: &str, + txn_id: Uuid, + ) -> Result<(), StorageError> { + let entry = match self.read_ledger_entry(keyspace, txn_id).await? { + Some(e) => e, + None => return Ok(()), // already cleaned up + }; + + let ops = entry.parse_ops()?; + let state = TransactionState::from_str(&entry.state); + let txn_timestamp = entry.started_at; + + match state { + Some(TransactionState::Committing) => { + for op in &ops { + if let Err(e) = self.recover_commit_op(keyspace, op, txn_id, txn_timestamp).await { + tracing::error!("recover_transaction commit op {txn_id}: {e}"); + return Err(e); + } + } + } + // PREPARING or CANCELLING (or unknown) → rollback + _ => { + for op in &ops { + if let Err(e) = self.recover_rollback_op(keyspace, op, txn_id).await { + tracing::error!("recover_transaction rollback op {txn_id}: {e}"); + return Err(e); + } + } + } + } + + self.delete_ledger_entry(keyspace, txn_id).await + } + + /// Commit a single operation during recovery. + /// + /// PUT/UPDATE: write `item_data`, clear `prepared_txn_id`, set `last_committed_txn_timestamp`. + /// DELETE: update `partition_max_delete_timestamp`, then delete the row. + /// CHECK: no-op. + async fn recover_commit_op( + &self, + keyspace: &str, + op: &crate::data::transaction_ledger::LedgerOp, + txn_id: Uuid, + txn_timestamp: i64, + ) -> Result<(), StorageError> { + if op.op == "CHECK" { + return Ok(()); + } + + let table = data_table_name(&op.table_id); + let txn_id_bytes = Bytes::new(txn_id.as_bytes().to_vec()); + let sk = ledger_sk(&op)?; + + match op.op.as_str() { + "PUT" | "UPDATE" => { + let item_data = op.item_data.as_deref().ok_or_else(|| { + StorageError::Internal(format!( + "ledger op {} missing item_data for {}", op.op, txn_id + )) + })?; + + let result = if let Some((sk_val, sk_col)) = &sk { + let query = format!( + "UPDATE {keyspace}.{table} SET item_data = ?, prepared_txn_id = NULL, \ + last_committed_txn_timestamp = ? WHERE pk = ? AND {sk_col} = ? \ + IF prepared_txn_id = ?", + ); + query_with_item_ts_pk_sk_txnid( + &self.session, &query, item_data, txn_timestamp, + &op.pk, sk_val, txn_id_bytes, + ).await + } else { + let query = format!( + "UPDATE {keyspace}.{table} SET item_data = ?, prepared_txn_id = NULL, \ + last_committed_txn_timestamp = ? WHERE pk = ? IF prepared_txn_id = ?", + ); + self.session + .query_with_values(&query, cdrs_tokio::query_values!( + item_data, txn_timestamp, op.pk.as_str(), txn_id_bytes + )) + .await + .map_err(|e| StorageError::Internal(e.to_string())) + } + .map_err(|e| { + tracing::error!("recover_commit_op PUT/UPDATE: {e}"); + StorageError::Internal("Database error".to_owned()) + })?; + + // LWT may not apply if item was already committed (idempotent) + let _ = check_lwt_applied(&result, "recover_commit PUT/UPDATE"); + Ok(()) + } + "DELETE" => { + // Step 1: update partition_max_delete_timestamp + let update_null = format!( + "UPDATE {keyspace}.{table} SET partition_max_delete_timestamp = ? \ + WHERE pk = ? IF partition_max_delete_timestamp = null", + ); + let r1 = self.session + .query_with_values(&update_null, cdrs_tokio::query_values!(txn_timestamp, op.pk.as_str())) + .await + .map_err(|e| StorageError::Internal(e.to_string())) + .map_err(|e| { tracing::error!("recover_commit_op delete max_ts null: {e}"); e })?; + + if check_lwt_applied(&r1, "recover_commit delete max_ts null").is_err() { + let update_cmp = format!( + "UPDATE {keyspace}.{table} SET partition_max_delete_timestamp = ? \ + WHERE pk = ? IF partition_max_delete_timestamp < ?", + ); + let _ = self.session + .query_with_values(&update_cmp, cdrs_tokio::query_values!( + txn_timestamp, op.pk.as_str(), txn_timestamp + )) + .await + .map_err(|e| StorageError::Internal(e.to_string())) + .map_err(|e| { tracing::error!("recover_commit_op delete max_ts cmp: {e}"); e })?; + } + + // Step 2: delete the row + let result = if let Some((sk_val, sk_col)) = &sk { + let query = format!( + "DELETE FROM {keyspace}.{table} WHERE pk = ? AND {sk_col} = ? \ + IF prepared_txn_id = ?", + ); + query_with_pk_sk_txnid(&self.session, &query, &op.pk, sk_val, txn_id_bytes).await + } else { + let query = format!( + "DELETE FROM {keyspace}.{table} WHERE pk = ? IF prepared_txn_id = ?", + ); + self.session + .query_with_values(&query, cdrs_tokio::query_values!(op.pk.as_str(), txn_id_bytes)) + .await + .map_err(|e| StorageError::Internal(e.to_string())) + } + .map_err(|e| { + tracing::error!("recover_commit_op DELETE: {e}"); + StorageError::Internal("Database error".to_owned()) + })?; + + let _ = check_lwt_applied(&result, "recover_commit DELETE"); + Ok(()) + } + other => Err(StorageError::Internal(format!("unknown op in ledger: {other}"))), + } + } + + /// Rollback a single operation during recovery. + /// + /// Tries DELETE with `IF prepared_txn_id = ? AND created_to_prepare = true` first. + /// Falls back to clearing `prepared_txn_id` for pre-existing items. + /// CHECK: no-op. + async fn recover_rollback_op( + &self, + keyspace: &str, + op: &crate::data::transaction_ledger::LedgerOp, + txn_id: Uuid, + ) -> Result<(), StorageError> { + if op.op == "CHECK" { + return Ok(()); + } + + let table = data_table_name(&op.table_id); + let txn_id_bytes = Bytes::new(txn_id.as_bytes().to_vec()); + let sk = ledger_sk(&op)?; + + let (delete_result, update_query) = if let Some((sk_val, sk_col)) = &sk { + let dq = format!( + "DELETE FROM {keyspace}.{table} WHERE pk = ? AND {sk_col} = ? \ + IF prepared_txn_id = ? AND created_to_prepare = true", + ); + let uq = format!( + "UPDATE {keyspace}.{table} SET prepared_txn_id = null \ + WHERE pk = ? AND {sk_col} = ? IF prepared_txn_id = ?", + ); + let dr = query_with_pk_sk_txnid( + &self.session, &dq, &op.pk, sk_val, txn_id_bytes.clone(), + ).await.map_err(|e| { + tracing::error!("recover_rollback_op delete: {e}"); + StorageError::Internal("Database error".to_owned()) + })?; + (dr, uq) + } else { + let dq = format!( + "DELETE FROM {keyspace}.{table} WHERE pk = ? \ + IF prepared_txn_id = ? AND created_to_prepare = true", + ); + let uq = format!( + "UPDATE {keyspace}.{table} SET prepared_txn_id = null \ + WHERE pk = ? IF prepared_txn_id = ?", + ); + let dr = self.session + .query_with_values(&dq, cdrs_tokio::query_values!(op.pk.as_str(), txn_id_bytes.clone())) + .await + .map_err(|e| StorageError::Internal(e.to_string())) + .map_err(|e| { tracing::error!("recover_rollback_op delete: {e}"); e })?; + (dr, uq) + }; + + if check_lwt_applied(&delete_result, "recover_rollback delete").is_ok() { + return Ok(()); + } + + // Item was pre-existing - clear the transaction marker + let result = if let Some((sk_val, sk_col)) = &sk { + query_with_pk_sk_txnid(&self.session, &update_query, &op.pk, sk_val, txn_id_bytes).await + } else { + self.session + .query_with_values(&update_query, cdrs_tokio::query_values!(op.pk.as_str(), txn_id_bytes)) + .await + .map_err(|e| StorageError::Internal(e.to_string())) + } + .map_err(|e| { + tracing::error!("recover_rollback_op update: {e}"); + StorageError::Internal("Database error".to_owned()) + })?; + + let _ = check_lwt_applied(&result, "recover_rollback update"); + Ok(()) + } +} + +/// Resolve sort key value and column name from key_info + item. +fn resolve_sk( + key_info: &TableKeyInfo, + key: &Item, +) -> Result<(Option, Option), StorageError> { + if let Some((sk_name, sk_type)) = sk_info(&key_info.key_schema, &key_info.attribute_definitions) + { + let sk_value = key + .get(sk_name) + .ok_or_else(|| StorageError::Internal("missing sort key".to_owned()))?; + let sk = parse_sk(sk_value, sk_type)?; + Ok((Some(sk), Some(sk_column(sk_type).to_owned()))) + } else { + Ok((None, None)) + } +} + +/// Resolve sort key value and column name from a TransactGetOp. +fn resolve_sk_get( + op: &TransactGetOp<'_>, +) -> Result<(Option, Option), StorageError> { + resolve_sk(op.key_info, op.key) +} + +/// Check if LWT was applied successfully. +fn check_lwt_applied(result: &Envelope, context: &str) -> Result<(), CancellationReason> { + let body = result.response_body().map_err(|e| { + tracing::error!("{} response_body: {e}", context); + CancellationReason::validation_error("Database error".to_owned()) + })?; + + if let Some(rows) = body.into_rows() { + if let Some(row) = rows.first() { + let applied: bool = get_column::(&row, "[applied]", context) + .map_err(|_| transaction_conflict_reason())?; + if !applied { + return Err(transaction_conflict_reason()); + } + } + } + + Ok(()) +} + +/// Extract sort key from a `LedgerOp` as `Option<(SortKeyValue, col_name)>`. +fn ledger_sk(op: &crate::data::transaction_ledger::LedgerOp) -> Result, StorageError> { + match (&op.sk_col, &op.sk_val) { + (Some(col), Some(val)) => Ok(Some((ledger_sk_to_sort_key(col, val)?, col.clone()))), + _ => Ok(None), + } +} + +/// Reconstruct a `SortKeyValue` from the text representation stored in `LedgerOp`. +/// +/// `sk_col` encodes the type ("sk_s" → S, "sk_n" → N, "sk_b" → B). +fn ledger_sk_to_sort_key(sk_col: &str, sk_val: &str) -> Result { + match sk_col { + "sk_s" => Ok(SortKeyValue::S(sk_val.to_owned())), + "sk_n" => { + let d = sk_val + .parse::() + .map_err(|e| StorageError::Internal(format!("invalid numeric sk in ledger: {e}")))?; + Ok(SortKeyValue::N(d)) + } + "sk_b" => { + use base64::Engine as _; + let b = base64::engine::general_purpose::STANDARD + .decode(sk_val) + .map_err(|e| StorageError::Internal(format!("invalid binary sk in ledger: {e}")))?; + Ok(SortKeyValue::B(b)) + } + other => Err(StorageError::Internal(format!("unknown sk_col in ledger: {other}"))), + } +} diff --git a/crates/storage-cassandra/src/data/update_item.rs b/crates/storage-cassandra/src/data/update_item.rs new file mode 100644 index 00000000..e8089c0b --- /dev/null +++ b/crates/storage-cassandra/src/data/update_item.rs @@ -0,0 +1,379 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! `update_item` implementation for the Cassandra backend. + +use cdrs_tokio::consistency::Consistency; +use cdrs_tokio::query::BatchQueryBuilder; +use cdrs_tokio::query_values; +use cdrs_tokio::types::IntoRustByName; +use extenddb_core::expression::{self, Expr, ExpressionMaps, UpdateAction}; +use extenddb_core::types::{Item, KeyType, TableKeyInfo}; +use extenddb_core::validation; +use extenddb_storage::StreamCapture; +use extenddb_storage::error::StorageError; +use extenddb_storage::util::{parse_sk, pk_to_text, sk_column}; + +use super::ddl::data_table_name; +use super::{json_to_item, query_with_pk_sk, query_with_pk_sk_item}; +use crate::CassandraEngine; +use crate::stream_util::stream_record_statement; + +// 400 KB limit from DynamoDB specification +const MAX_ITEM_SIZE_BYTES: usize = 400 * 1024; + +impl CassandraEngine { + /// Implementation of `DataEngine::update_item`. + #[allow(clippy::too_many_arguments)] + pub(crate) async fn update_item_impl( + &self, + key_info: &TableKeyInfo, + key: &Item, + actions: &[UpdateAction], + return_old: bool, + return_new: bool, + condition: Option<&Expr>, + maps: &ExpressionMaps, + stream: Option<&StreamCapture>, + ) -> Result<(Option, Option), StorageError> { + let data_keyspace = self.account_keyspace(&key_info.account_id); + let ddb_table = data_table_name(&key_info.table_id); + + let pk_name = &key_info.key_schema[0].attribute_name; + let pk_value = key + .get(pk_name) + .ok_or_else(|| StorageError::Internal("missing partition key".to_owned()))?; + let pk_text = pk_to_text(pk_value)?; + + let catalog_keyspace = self.catalog_keyspace(); + let indexes = super::index::fetch_indexes_for_table( + &key_info.table_id, + &self.session, + &catalog_keyspace, + ) + .await?; + let sys_delay = if indexes.is_empty() { + 0 + } else { + self.gsi_default_delay_ms.load(std::sync::atomic::Ordering::Relaxed) + }; + + // Fetch existing item (including prepared_txn_id for transaction conflict detection) + let old_json = if let Some(sk_elem) = key_info + .key_schema + .iter() + .find(|k| k.key_type == KeyType::Range) + { + let sk_name = &sk_elem.attribute_name; + let sk_value = key + .get(sk_name) + .ok_or_else(|| StorageError::Internal("missing sort key".to_owned()))?; + let sk_type = key_info + .attribute_definitions + .iter() + .find(|ad| ad.attribute_name == *sk_name) + .ok_or_else(|| StorageError::Internal("sort key type not found".to_owned()))? + .attribute_type; + let sk = parse_sk(sk_value, sk_type)?; + let sk_col = sk_column(sk_type); + + let select_query = format!( + "SELECT item_data, prepared_txn_id FROM {}.{} WHERE pk = ? AND {} = ?", + data_keyspace, ddb_table, sk_col + ); + + let result = query_with_pk_sk(&self.session, &select_query, &pk_text, &sk).await?; + + let body = result + .response_body() + .map_err(|e| StorageError::Internal(format!("Parse response: {}", e)))?; + + let rows = body.into_rows().unwrap_or_default(); + if let Some(row) = rows.first() { + // Check for in-flight transaction + let prepared_txn_id: Option = + row.get_by_name("prepared_txn_id").ok().flatten(); + if prepared_txn_id.is_some() { + return Err(StorageError::TransactionCanceled(vec![ + extenddb_core::types::CancellationReason { + code: "TransactionConflict".to_owned(), + message: Some( + "Item is being modified by a concurrent transaction".to_owned(), + ), + item: None, + }, + ])); + } + let item_data: String = + crate::cassandra_util::get_column(&row, "item_data", "update_item")?; + Some(json_to_item(item_data)?) + } else { + None + } + } else { + let select_query = format!( + "SELECT item_data, prepared_txn_id FROM {}.{} WHERE pk = ?", + data_keyspace, ddb_table + ); + + let row = crate::cassandra_util::query_optional( + &self.session, + &select_query, + query_values!(pk_text.as_ref() as &str), + "update_item", + ) + .await?; + + if let Some(row) = row { + // Check for in-flight transaction + let prepared_txn_id: Option = + row.get_by_name("prepared_txn_id").ok().flatten(); + if prepared_txn_id.is_some() { + return Err(StorageError::TransactionCanceled(vec![ + extenddb_core::types::CancellationReason { + code: "TransactionConflict".to_owned(), + message: Some( + "Item is being modified by a concurrent transaction".to_owned(), + ), + item: None, + }, + ])); + } + let item_data: String = + crate::cassandra_util::get_column(&row, "item_data", "update_item")?; + Some(json_to_item(item_data)?) + } else { + None + } + }; + + // Build working item: existing or new with key attributes only (upsert) + let item_existed = old_json.is_some(); + let mut item = if let Some(item) = old_json { + item + } else { + key.clone() + }; + + // Only capture pre-mutation item when the item already existed; for upserts + // (item_existed == false) there is no old image to record. + let pre_mutation_item = if (!indexes.is_empty() || stream.is_some()) && item_existed { + Some(item.clone()) + } else { + None + }; + let old_item = if return_old { Some(item.clone()) } else { None }; + + // Evaluate condition against the existing item (empty if non-existent) + // DynamoDB treats a non-existent item as having no attributes + let condition_item = if item_existed { + &item + } else { + &std::collections::BTreeMap::new() + }; + match super::check_condition(condition, condition_item, maps) { + Ok(()) => {} + Err(StorageError::ConditionFailed(_)) => { + if item_existed { + return Err(StorageError::ConditionFailed(Some(item))); + } + return Err(StorageError::ConditionFailed(None)); + } + Err(e) => return Err(e), + } + + // Apply update actions + expression::apply_update_validated(actions, &mut item, maps, &[], &[]) + .map_err(|e| StorageError::Validation(e.to_string()))?; + + // Validate item size (400 KB limit) + validation::validate_item_size(&item, MAX_ITEM_SIZE_BYTES) + .map_err(|e| StorageError::Validation(e.to_string()))?; + + let new_item = if return_new { Some(item.clone()) } else { None }; + + // Write the updated item back + let item_json = + serde_json::to_value(&item).map_err(|e| StorageError::Internal(e.to_string()))?; + let item_json_str = item_json.to_string(); + + if let Some(sk_elem) = key_info + .key_schema + .iter() + .find(|k| k.key_type == KeyType::Range) + { + let sk_name = &sk_elem.attribute_name; + let sk_value = key + .get(sk_name) + .ok_or_else(|| StorageError::Internal("missing sort key".to_owned()))?; + let sk_type = key_info + .attribute_definitions + .iter() + .find(|ad| ad.attribute_name == *sk_name) + .ok_or_else(|| StorageError::Internal("sort key type not found".to_owned()))? + .attribute_type; + let sk = parse_sk(sk_value, sk_type)?; + let sk_col = sk_column(sk_type); + + let update_cql = format!( + "UPDATE {}.{} SET item_data = ? WHERE pk = ? AND {} = ?", + data_keyspace, ddb_table, sk_col + ); + + let stream_stmt = stream.and_then(|cap| { + stream_record_statement( + &data_keyspace, + &key_info.table_id, + key_info, + pre_mutation_item.as_ref(), + Some(&item), + cap, + &self.hlc, + self.stream_retention_seconds, + ) + }); + + if indexes.is_empty() && stream_stmt.is_none() { + query_with_pk_sk_item(&self.session, &update_cql, &pk_text, &sk, &item_json_str) + .await?; + } else { + let update_qv = cdrs_tokio::query::QueryValues::SimpleValues(vec![ + item_json_str.as_str().into(), + cdrs_tokio::types::value::Value::from(pk_text.as_ref() as &str), + super::index::sk_to_value(&sk), + ]); + let mut batch = BatchQueryBuilder::new() + .with_consistency(Consistency::LocalQuorum) + .add_query(update_cql, update_qv); + + if !indexes.is_empty() { + super::index::sync_indexes( + &mut batch, + &data_keyspace, + &key_info.key_schema, + &key_info.attribute_definitions, + &indexes, + pre_mutation_item.as_ref(), + Some(&item), + sys_delay, + )?; + } + + let async_enqueued = if !indexes.is_empty() { + super::index::enqueue_async_indexes( + &self.session, + &mut batch, + &data_keyspace, + key_info, + &indexes, + pre_mutation_item.as_ref(), + Some(&item), + sys_delay, + ) + .await? + } else { + 0 + }; + + if let Some(stmt) = stream_stmt { + batch = batch.add_query( + stmt, + cdrs_tokio::query::QueryValues::SimpleValues(vec![]), + ); + } + + self.session + .batch(batch.build().map_err(|e| StorageError::Internal(e.to_string()))?) + .await + .map_err(|e| StorageError::Internal(format!("Batch execution: {}", e)))?; + + if async_enqueued > 0 { + self.gsi_queue.notify_workers(); + } + } + } else { + let update_cql = format!( + "UPDATE {}.{} SET item_data = ? WHERE pk = ?", + data_keyspace, ddb_table + ); + + let stream_stmt = stream.and_then(|cap| { + stream_record_statement( + &data_keyspace, + &key_info.table_id, + key_info, + pre_mutation_item.as_ref(), + Some(&item), + cap, + &self.hlc, + self.stream_retention_seconds, + ) + }); + + if indexes.is_empty() && stream_stmt.is_none() { + crate::cassandra_util::execute( + &self.session, + &update_cql, + query_values!(item_json_str.as_str(), pk_text.as_ref() as &str), + "update_item", + ) + .await?; + } else { + let update_qv = cdrs_tokio::query::QueryValues::SimpleValues(vec![ + item_json_str.as_str().into(), + cdrs_tokio::types::value::Value::from(pk_text.as_ref() as &str), + ]); + let mut batch = BatchQueryBuilder::new() + .with_consistency(Consistency::LocalQuorum) + .add_query(update_cql, update_qv); + + if !indexes.is_empty() { + super::index::sync_indexes( + &mut batch, + &data_keyspace, + &key_info.key_schema, + &key_info.attribute_definitions, + &indexes, + pre_mutation_item.as_ref(), + Some(&item), + sys_delay, + )?; + } + + let async_enqueued = if !indexes.is_empty() { + super::index::enqueue_async_indexes( + &self.session, + &mut batch, + &data_keyspace, + key_info, + &indexes, + pre_mutation_item.as_ref(), + Some(&item), + sys_delay, + ) + .await? + } else { + 0 + }; + + if let Some(stmt) = stream_stmt { + batch = batch.add_query( + stmt, + cdrs_tokio::query::QueryValues::SimpleValues(vec![]), + ); + } + + self.session + .batch(batch.build().map_err(|e| StorageError::Internal(e.to_string()))?) + .await + .map_err(|e| StorageError::Internal(format!("Batch execution: {}", e)))?; + + if async_enqueued > 0 { + self.gsi_queue.notify_workers(); + } + } + } + + Ok((old_item, new_item)) + } +} diff --git a/crates/storage-cassandra/src/delete_table.rs b/crates/storage-cassandra/src/delete_table.rs new file mode 100644 index 00000000..60539ab1 --- /dev/null +++ b/crates/storage-cassandra/src/delete_table.rs @@ -0,0 +1,144 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! `delete_table` implementation for `CassandraEngine`. + +use cdrs_tokio::types::IntoRustByName; +use extenddb_core::types::{DeleteTableInput, TableDescription}; +use extenddb_storage::error::StorageError; + +use crate::CassandraEngine; + +impl CassandraEngine { + pub(crate) async fn delete_table_impl( + &self, + account_id: &str, + input: DeleteTableInput, + ) -> Result { + let catalog_keyspace = self.catalog_keyspace(); + + // Fetch table metadata before deletion + let table_query = format!( + "SELECT * FROM {}.tables WHERE account_id = ? AND table_name = ?", + catalog_keyspace + ); + + let table_result = self + .session + .query_with_values( + &table_query, + cdrs_tokio::query_values!(account_id, input.table_name.as_str()), + ) + .await + .map_err(|e| StorageError::Internal(format!("Query table: {}", e)))?; + + let table_body = table_result + .response_body() + .map_err(|e| StorageError::Internal(e.to_string()))?; + + let table_rows = table_body + .into_rows() + .ok_or_else(|| StorageError::TableNotFound(input.table_name.clone()))?; + + let table_row = table_rows + .first() + .ok_or_else(|| StorageError::TableNotFound(input.table_name.clone()))?; + + // Check deletion protection + let deletion_protection: bool = table_row + .get_r_by_name("deletion_protection_enabled") + .unwrap_or(false); + + if deletion_protection { + let table_arn: String = table_row + .get_r_by_name("table_arn") + .map_err(|e| StorageError::Internal(format!("Parse table_arn: {}", e)))?; + return Err(StorageError::DeletionProtected(table_arn)); + } + + let table_id: String = table_row + .get_r_by_name("table_id") + .map_err(|e| StorageError::Internal(format!("Parse table_id: {}", e)))?; + + // Fetch indexes for response + let index_query = format!( + "SELECT * FROM {}.indexes WHERE table_id = ?", + catalog_keyspace + ); + + let index_result = self + .session + .query_with_values(&index_query, cdrs_tokio::query_values!(table_id.as_str())) + .await + .map_err(|e| StorageError::Internal(format!("Query indexes: {}", e)))?; + + let index_body = index_result + .response_body() + .map_err(|e| StorageError::Internal(e.to_string()))?; + + let index_rows = index_body.into_rows().unwrap_or_default(); + + // Build description for response (before deletion) + let description = + self.build_table_description_from_row(account_id, table_row, index_rows.clone())?; + + // Delete physical tables from the owning account keyspace. Catalog + // metadata and user data intentionally live in different keyspaces. + let account_keyspace = self.account_keyspace(account_id); + self.drop_data_table(&account_keyspace, &table_id) + .await + .map_err(|e| StorageError::Internal(e.to_string()))?; + + // Delete index data tables + for idx_row in &index_rows { + let index_id: String = idx_row + .get_r_by_name("index_id") + .map_err(|e| StorageError::Internal(format!("Parse index_id: {}", e)))?; + self.drop_index_data_table(&account_keyspace, &index_id) + .await + .map_err(|e| StorageError::Internal(format!("Drop index table: {e}")))?; + } + + // Delete catalog entries (indexes first due to FK) + let delete_indexes_query = format!( + "DELETE FROM {}.indexes WHERE table_id = ?", + catalog_keyspace + ); + self.session + .query_with_values( + &delete_indexes_query, + cdrs_tokio::query_values!(table_id.as_str()), + ) + .await + .map_err(|e| StorageError::Internal(format!("Delete indexes: {}", e)))?; + + // Delete table-scoped continuous-backup configuration so recreating the + // same table name does not inherit stale PITR state. + let delete_continuous_backup_query = format!( + "DELETE FROM {}.continuous_backups WHERE account_id = ? AND table_name = ?", + catalog_keyspace + ); + self.session + .query_with_values( + &delete_continuous_backup_query, + cdrs_tokio::query_values!(account_id, input.table_name.as_str()), + ) + .await + .map_err(|e| StorageError::Internal(format!("Delete continuous backup state: {e}")))?; + + // Delete table catalog entry + let delete_table_query = format!( + "DELETE FROM {}.tables WHERE account_id = ? AND table_name = ?", + catalog_keyspace + ); + self.session + .query_with_values( + &delete_table_query, + cdrs_tokio::query_values!(account_id, input.table_name.as_str()), + ) + .await + .map_err(|e| StorageError::Internal(format!("Delete table: {}", e)))?; + + Ok(description) + } +} diff --git a/crates/storage-cassandra/src/engine.rs b/crates/storage-cassandra/src/engine.rs new file mode 100644 index 00000000..1a20b4ed --- /dev/null +++ b/crates/storage-cassandra/src/engine.rs @@ -0,0 +1,235 @@ +// Copyright 2026 ExtendDB Contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Cassandra storage engine implementation. + +use std::sync::Arc; + +use cdrs_tokio::authenticators::{NoneAuthenticatorProvider, StaticPasswordAuthenticatorProvider}; +use cdrs_tokio::cluster::session::{Session, SessionBuilder, TcpSessionBuilder}; +use cdrs_tokio::cluster::{NodeTcpConfigBuilder, TcpConnectionManager}; +use cdrs_tokio::load_balancing::RoundRobinLoadBalancingStrategy; +use cdrs_tokio::query_values; +use cdrs_tokio::transport::TransportTcp; + +use extenddb_storage::error::StorageError; + +use crate::config::CassandraStorageConfig; + +pub type CassandraSession = Session< + TransportTcp, + TcpConnectionManager, + RoundRobinLoadBalancingStrategy, +>; + +/// Cassandra storage engine. +/// +/// Implements ExtendDB storage traits using Apache Cassandra as the backend. +pub struct CassandraEngine { + /// Cassandra session (connection pool) + pub(crate) session: Arc, + + /// AWS region for ARN construction + pub(crate) region: String, + + /// Keyspace prefix for catalog and account keyspaces + pub(crate) keyspace_prefix: String, + + /// Replication factor for new keyspaces + replication_factor: u32, + + /// Datacenter name for NetworkTopologyStrategy + datacenter: String, + + /// Wakes the control plane poller when a table enters CREATING or DELETING state + pub(crate) control_plane_notify: Arc, + + /// Default GSI propagation delay in milliseconds (for async indexes) + pub(crate) gsi_default_delay_ms: Arc, + + /// GSI queue handle for waking workers after async enqueues + pub(crate) gsi_queue: Arc, + + /// Hybrid Logical Clock for stream sequence number generation + pub(crate) hlc: crate::stream_util::SharedHlc, + + /// Stream record TTL in seconds (default: 30 hours = 108000) + pub(crate) stream_retention_seconds: u32, +} + +impl CassandraEngine { + /// Create a new Cassandra storage engine. + pub async fn new(config: &CassandraStorageConfig, region: &str) -> Result { + // Build Cassandra session + let session = Self::create_session(config).await?; + + Ok(Self { + session: Arc::new(session), + region: region.to_string(), + keyspace_prefix: config.keyspace_prefix.clone(), + replication_factor: config.replication_factor, + datacenter: config.datacenter.clone(), + control_plane_notify: Arc::new(tokio::sync::Notify::new()), + gsi_default_delay_ms: Arc::new(std::sync::atomic::AtomicU64::new(1000)), // Default 1 second + gsi_queue: crate::gsi_queue::GsiQueue::new(), + hlc: crate::stream_util::new_shared_hlc(config.instance_id.as_deref().unwrap_or("default")), + stream_retention_seconds: 108_000, // 30 hours; overridden by spawn_workers (Step 7) + }) + } + + /// Create a Cassandra session with connection pool. + pub async fn create_session( + config: &CassandraStorageConfig, + ) -> Result { + if config.contact_points.is_empty() { + return Err(StorageError::Connection( + "No contact points configured".to_string(), + )); + } + + // Build node config with contact points + let mut node_builder = NodeTcpConfigBuilder::new(); + for contact_point in &config.contact_points { + node_builder = node_builder.with_contact_point(contact_point.clone().into()); + } + + // Add authentication if configured + let node_builder = + if let (Some(username), Some(password)) = (&config.username, &config.password) { + let auth_provider = + Arc::new(StaticPasswordAuthenticatorProvider::new(username, password)); + node_builder.with_authenticator_provider(auth_provider) + } else { + node_builder.with_authenticator_provider(Arc::new(NoneAuthenticatorProvider)) + }; + + // Build cluster config + let cluster_config = node_builder.build().await.map_err(|e| { + StorageError::Connection(format!("Failed to build cluster config: {}", e)) + })?; + + // Create session with round-robin load balancing + // Wrap in timeout to prevent indefinite hangs on connection/auth failures + const CONNECTION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + let lb = RoundRobinLoadBalancingStrategy::new(); + let session_future = TcpSessionBuilder::new(lb, cluster_config).build(); + + let session = tokio::time::timeout(CONNECTION_TIMEOUT, session_future) + .await + .map_err(|_| StorageError::Connection( + format!( + "Connection timeout after {:?}. Check that Cassandra is running and accessible at {:?}. \ + If authentication is enabled, verify username/password are correct.", + CONNECTION_TIMEOUT, + config.contact_points + ) + ))? + .map_err(|e| { + // Enhance error message for common authentication failures + let err_str = e.to_string(); + if err_str.contains("authentication") || err_str.contains("Authentication") { + StorageError::Connection(format!( + "Authentication failed: {}. Verify username/password in config.", + e + )) + } else { + StorageError::Connection(format!("Failed to build session: {}", e)) + } + })?; + + Ok(session) + } + + /// Get a reference to the Cassandra session. + pub fn session(&self) -> &CassandraSession { + &self.session + } + + /// Get an Arc clone of the Cassandra session. + pub fn session_arc(&self) -> Arc { + Arc::clone(&self.session) + } + + /// Get the catalog keyspace name. + pub fn catalog_keyspace(&self) -> String { + format!("{}_catalog", self.keyspace_prefix) + } + + /// Get the account keyspace name for a given account ID. + pub fn account_keyspace(&self, account_id: &str) -> String { + format!("{}_account_{}", self.keyspace_prefix, account_id) + } + + /// Create a keyspace with NetworkTopologyStrategy. + pub async fn create_keyspace(&self, keyspace_name: &str) -> Result<(), StorageError> { + let cql = format!( + "CREATE KEYSPACE IF NOT EXISTS {} WITH replication = {{'class': 'NetworkTopologyStrategy', '{}': {}}}", + keyspace_name, self.datacenter, self.replication_factor + ); + + self.session + .query(cql) + .await + .map_err(|e| StorageError::Internal(format!("Failed to create keyspace: {}", e)))?; + + Ok(()) + } + + /// Drop a keyspace. + pub async fn drop_keyspace(&self, keyspace_name: &str) -> Result<(), StorageError> { + let cql = format!("DROP KEYSPACE IF EXISTS {}", keyspace_name); + + self.session + .query(cql) + .await + .map_err(|e| StorageError::Internal(format!("Failed to drop keyspace: {}", e)))?; + + Ok(()) + } + + /// Check if a keyspace exists. + pub async fn keyspace_exists(&self, keyspace_name: &str) -> Result { + let cql = "SELECT keyspace_name FROM system_schema.keyspaces WHERE keyspace_name = ?"; + + let rows = self + .session + .query_with_values(cql, query_values!(keyspace_name)) + .await + .map_err(|e| StorageError::Internal(format!("Failed to query keyspaces: {}", e)))? + .response_body() + .map_err(|e| StorageError::Internal(format!("Failed to get response body: {}", e)))? + .into_rows() + .ok_or_else(|| StorageError::Internal("Failed to parse rows".to_string()))?; + + Ok(!rows.is_empty()) + } + + /// Defense-in-depth: validate `account_id` before use in CQL identifiers. + /// + /// `account_id` is interpolated into CQL identifiers via keyspace names. + /// Reject values that could break identifiers. + pub(crate) fn validate_account_id(account_id: &str) -> Result<(), StorageError> { + if account_id.contains('"') || account_id.contains('\0') || !account_id.is_ascii() { + return Err(StorageError::Internal( + "account_id contains invalid characters for use in CQL identifiers".to_owned(), + )); + } + Ok(()) + } +} + +// TODO: Implement storage traits: +// - TableEngine +// - DataEngine +// - MetadataEngine +// - StreamEngine +// - WorkerStore +// - BackupEngine +// - StorageEngine (composite trait) +// - ManagementStore +// - AdminStore +// - SettingsStore +// - MetricsStore +// - RateLimitStore +// - AuthorizationStore +// - CatalogStore (composite trait) diff --git a/crates/storage-cassandra/src/gsi_queue.rs b/crates/storage-cassandra/src/gsi_queue.rs new file mode 100644 index 00000000..4533701f --- /dev/null +++ b/crates/storage-cassandra/src/gsi_queue.rs @@ -0,0 +1,78 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Persistent GSI propagation queue backed by the `gsi_pending` table. +//! +//! Rows are inserted inside the same logged batch as the base write (atomically +//! with the item mutation). Each row is self-describing: `index_context` carries +//! everything the worker needs to apply the update with zero catalog reads. +//! +//! Workers own one partition each (worker `i` → `worker_partition = i`), so all +//! updates for a given base item are applied in `(ready_at, id)` order by a +//! single worker — per-key FIFO without any row-level locking. + +use extenddb_core::types::{AttributeDefinition, KeySchemaElement, Projection}; +use serde::{Deserialize, Serialize}; +use tokio::sync::Notify; +use std::sync::Arc; + +/// Number of worker partitions. Fixed: changing while the queue is non-empty +/// would split a key's rows across workers. +pub(crate) const NUM_WORKERS: u64 = 4; + +/// A single target index definition, snapshotted at enqueue time. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct GsiIndexDef { + pub(crate) index_id: String, + pub(crate) key_schema: Vec, + pub(crate) projection: Projection, +} + +/// Everything a worker needs to apply a pending GSI update without touching the +/// catalog. Serialized into `gsi_pending.index_context` at enqueue time. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct GsiApplyContext { + pub(crate) base_key_schema: Vec, + pub(crate) attribute_definitions: Vec, + pub(crate) index: GsiIndexDef, +} + +/// Routes all updates for a given base item to one worker (FNV-1a, stable +/// across builds — not `std`'s hash which is not guaranteed stable). +pub(crate) fn partition_for(base_pk_text: &str) -> i32 { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in base_pk_text.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + (hash % NUM_WORKERS) as i32 +} + +/// Jitter a propagation delay: uniform in `[delay_ms/2 + 1, delay_ms]`. +/// Values <= 1 are returned unchanged. +pub(crate) fn jitter_delay_ms(delay_ms: u64) -> u64 { + if delay_ms <= 1 { + delay_ms + } else { + use rand::Rng; + rand::rng().random_range(delay_ms / 2 + 1..=delay_ms) + } +} + +/// Handle for waking GSI workers after a write enqueues rows. +pub struct GsiQueue { + pub(crate) notify: Arc, +} + +impl GsiQueue { + pub fn new() -> Arc { + Arc::new(Self { + notify: Arc::new(Notify::new()), + }) + } + + /// Wake all workers after a write inserts into `gsi_pending`. + pub fn notify_workers(&self) { + self.notify.notify_waiters(); + } +} diff --git a/crates/storage-cassandra/src/lib.rs b/crates/storage-cassandra/src/lib.rs new file mode 100644 index 00000000..85f2d816 --- /dev/null +++ b/crates/storage-cassandra/src/lib.rs @@ -0,0 +1,293 @@ +// Copyright 2026 ExtendDB Contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Cassandra storage backend for ExtendDB. +//! +//! This crate implements the ExtendDB storage traits using Apache Cassandra +//! as the underlying database. It provides a DynamoDB-compatible API backed +//! by Cassandra's distributed architecture. + +mod admin_store; +mod authorization_store; +mod backup_engine; +pub mod bootstrapper; +pub mod cassandra_util; +pub mod catalog_store; +pub mod config; +pub mod create_table; +mod credential_store; +pub mod data; +mod delete_table; +pub mod engine; +pub mod gsi_queue; +mod management_store; +mod metadata_engine; +pub mod migrations; +pub mod operations; +mod stream_engine; +pub mod stream_util; +pub mod table_engine; +mod update_table; +mod table_helpers; +mod worker_store; +pub mod workers; + +pub use bootstrapper::CassandraBootstrapper; +pub use catalog_store::CassandraCatalogStore; +pub use config::CassandraStorageConfig; +pub use engine::{CassandraEngine, CassandraSession}; + +use cdrs_tokio::types::IntoRustByName; +use extenddb_storage::hooks::{ServerRuntimeHooks, WorkerContext}; +use extenddb_storage::server_components::{BackendError, ServerComponents}; +use std::sync::Arc; + +// ============================================================================ +// Backend-Specific Runtime Hooks +// ============================================================================ + +/// Backend-specific runtime hooks for Cassandra. +struct CassandraRuntimeHooks { + engine: Arc, + control_plane_notify: Arc, + gsi_worker_guard: std::sync::OnceLock, +} + +#[async_trait::async_trait] +impl ServerRuntimeHooks for CassandraRuntimeHooks { + async fn spawn_workers(&self, ctx: &WorkerContext) -> Vec> { + // Backend-specific workers that need Cassandra internals. + // Each handle is returned so `serve` can drain them on shutdown. + + let storage_for_poller = self.engine.clone(); + let cp_notify = self.control_plane_notify.clone(); + let catalog_store = ctx.catalog_store.clone(); + let control_plane = tokio::spawn(async move { + workers::poll_control_plane_transitions(storage_for_poller, cp_notify, catalog_store) + .await + }); + + let engine_for_recovery = self.engine.clone(); + let transaction_recovery = tokio::spawn(async move { + workers::poll_transaction_recovery( + engine_for_recovery, + std::time::Duration::from_secs(60), + std::time::Duration::from_secs(30), + ) + .await + }); + + // Read the initial GSI delay immediately so the atomic is correct from + // the first request, not after the first 30s sleep. + let gsi_delay = self.engine.gsi_default_delay_ms.clone(); + if let Ok(Some(val)) = ctx.catalog_store.get_setting("gsi_propagation_delay_ms").await { + if let Ok(ms) = val.parse::() { + gsi_delay.store(ms, std::sync::atomic::Ordering::Relaxed); + } + } + let catalog_store_for_gsi = ctx.catalog_store.clone(); + let gsi_delay_poller = tokio::spawn(async move { + workers::poll_gsi_delay(catalog_store_for_gsi, gsi_delay).await + }); + + // GSI propagation workers (one per partition). + let guard = workers::spawn_gsi_workers(self.engine.clone()); + let _ = self.gsi_worker_guard.set(guard); + + vec![control_plane, transaction_recovery, gsi_delay_poller] + } + + fn backend_info(&self) -> Option { + Some(format!("keyspace_prefix={}", self.engine.keyspace_prefix)) + } +} + +// ============================================================================ +// Backend Registration +// ============================================================================ + +/// Returns the Cassandra storage backend descriptor. +/// +/// The thin `main` installs it before dispatching any subcommand: +/// +/// ```ignore +/// extenddb_storage::set_backend(extenddb_storage_cassandra::backend())?; +/// ``` +pub fn backend() -> extenddb_storage::Backend { + extenddb_storage::Backend { + name: "cassandra", + bootstrapper: |config_path, cli_args| { + Box::pin(async move { + let store = CassandraBootstrapper::from_config(&config_path, &cli_args).await?; + Ok(Box::new(store) as Box) + }) + }, + operations: &operations::CassandraOperationsEngine, + storage_config: |table| { + let mut config: CassandraStorageConfig = table + .clone() + .try_into() + .map_err(|e: toml::de::Error| format!("Failed to parse cassandra config: {e}"))?; + config.ensure_cached_connection_string(); + Ok(Box::new(config) as Box) + }, + settings_store: |connection_string| { + let connection_string = connection_string.to_string(); + Box::pin(async move { + let catalog_store = make_catalog_store_from_connection_string(&connection_string) + .await + .map_err(extenddb_storage::settings_store::SettingsStoreError::ConnectionFailed)?; + Ok(Box::new(catalog_store) as Box) + }) + }, + diagnostics_store: |connection_string| { + let connection_string = connection_string.to_string(); + Box::pin(async move { + let catalog_store = make_catalog_store_from_connection_string(&connection_string) + .await + .map_err(extenddb_storage::diagnostics_store::DiagnosticsStoreError::ConnectionFailed)?; + Ok(Box::new(catalog_store) as Box) + }) + }, + server_components: server_components_factory, + } +} + +/// Build a `CassandraCatalogStore` from a bare connection string. +/// +/// Used by the `settings_store` and `diagnostics_store` factories, which +/// receive only a connection string (no full config object). +async fn make_catalog_store_from_connection_string( + connection_string: &str, +) -> Result { + let (contact_points, keyspace_prefix) = + CassandraStorageConfig::parse_connection_string(connection_string); + + if contact_points.is_empty() { + return Err("No contact points provided".to_string()); + } + + use cdrs_tokio::authenticators::StaticPasswordAuthenticatorProvider; + use cdrs_tokio::cluster::NodeTcpConfigBuilder; + use cdrs_tokio::cluster::session::{SessionBuilder, TcpSessionBuilder}; + use cdrs_tokio::load_balancing::RoundRobinLoadBalancingStrategy; + + let mut node_builder = NodeTcpConfigBuilder::new(); + for contact_point in &contact_points { + node_builder = node_builder.with_contact_point(contact_point.clone().into()); + } + let auth_provider = Arc::new(StaticPasswordAuthenticatorProvider::new( + "cassandra", + "cassandra", + )); + node_builder = node_builder.with_authenticator_provider(auth_provider); + + let cluster_config = node_builder + .build() + .await + .map_err(|e| format!("Failed to build cluster config: {e}"))?; + + let session = TcpSessionBuilder::new(RoundRobinLoadBalancingStrategy::new(), cluster_config) + .build() + .await + .map_err(|e| format!("Failed to create session: {e}"))?; + + Ok(CassandraCatalogStore::new( + Arc::new(session), + keyspace_prefix, + "datacenter1".to_string(), + 1, + )) +} + +fn server_components_factory( + config: &dyn extenddb_storage::config::StorageConfig, + region: &str, + _options: extenddb_storage::server_components::ServerComponentsOptions, +) -> std::pin::Pin< + Box> + Send>, +> { + let config = config.clone_box(); + let region = region.to_string(); + + Box::pin(async move { + let cassandra_config = config + .as_any() + .downcast_ref::() + .ok_or_else(|| { + BackendError::InitializationFailed("Expected CassandraStorageConfig".to_string()) + })?; + + let engine = CassandraEngine::new(cassandra_config, ®ion) + .await + .map_err(|e| BackendError::ConnectionFailed { + backend: "cassandra".to_string(), + details: e.to_string(), + })?; + + let control_plane_notify = engine.control_plane_notify.clone(); + let engine = Arc::new(engine); + + match engine.process_control_plane_transitions().await { + Ok(ref t) if t.is_empty() => {} + Ok(transitions) => { + for (name, transition) in &transitions { + tracing::info!("Recovered table '{name}': {transition}"); + } + } + Err(e) => tracing::error!("Failed to recover control plane transitions: {e}"), + } + + // Load encryption key from catalog. + let catalog_keyspace = format!("{}_catalog", cassandra_config.keyspace_prefix); + let enc_key_query = format!( + "SELECT value FROM {catalog_keyspace}.settings WHERE key = 'encryption_key'" + ); + let enc_key_result = engine.session.query(&enc_key_query).await.map_err(|e| { + BackendError::InitializationFailed(format!("Failed to load encryption key: {e}")) + })?; + let enc_key_body = enc_key_result.response_body().map_err(|e| { + BackendError::InitializationFailed(format!( + "Failed to parse encryption key response: {e}" + )) + })?; + let enc_key_rows = enc_key_body + .into_rows() + .ok_or(BackendError::MissingEncryptionKey)?; + let enc_key_row = enc_key_rows + .into_iter() + .next() + .ok_or(BackendError::MissingEncryptionKey)?; + let enc_key: String = enc_key_row.get_r_by_name("value").map_err(|e| { + BackendError::InitializationFailed(format!("Failed to parse encryption key: {e}")) + })?; + + let catalog_store = Arc::new(CassandraCatalogStore::with_encryption_key( + engine.session.clone(), + cassandra_config.keyspace_prefix.clone(), + cassandra_config.datacenter.clone(), + cassandra_config.replication_factor, + enc_key.clone(), + )); + + let credential_store: Arc = + Arc::new(credential_store::CassandraCredentialStore::new( + engine.session.clone(), + cassandra_config.keyspace_prefix.clone(), + enc_key, + )); + + let runtime_hooks = Box::new(CassandraRuntimeHooks { + engine: engine.clone(), + control_plane_notify, + gsi_worker_guard: std::sync::OnceLock::new(), + }); + + Ok(ServerComponents { + engine: engine as Arc, + catalog_store: catalog_store as Arc, + credential_store, + runtime_hooks: Some(runtime_hooks), + }) + }) +} diff --git a/crates/storage-cassandra/src/management_store/access_keys.rs b/crates/storage-cassandra/src/management_store/access_keys.rs new file mode 100755 index 00000000..4e37b867 --- /dev/null +++ b/crates/storage-cassandra/src/management_store/access_keys.rs @@ -0,0 +1,421 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Access key, session, and caller-tag operations for `CassandraCatalogStore`. + +use cdrs_tokio::types::blob::Blob; +use extenddb_storage::management_store::{AccessKeyCreated, OpError, OpResult}; +use crate::catalog_store::CassandraCatalogStore; + +impl CassandraCatalogStore { + // ── Access keys ──────────────────────────────────────────────── + + pub(crate) async fn create_access_key_impl( + &self, + account_id: &str, + user_name: &str, + ) -> OpResult { + // Check if user exists (emulate FK constraint) + if !self.user_exists(account_id, user_name).await? { + return Err(OpError::NotFound("User not found".to_owned())); + } + + // P119: Use cached encryption key if available, fall back to DB query. + let enc_key: String = if let Some(cached) = self.encryption_key() { + cached.to_string() + } else { + let catalog_keyspace = self.catalog_keyspace(); + let query = format!( + "SELECT value FROM {}.settings WHERE key = 'encryption_key'", + catalog_keyspace + ); + + let row = crate::cassandra_util::query_optional( + self.session(), + &query, + cdrs_tokio::query_values!(), + "create_access_key", + ) + .await? + .ok_or_else(|| OpError::Internal("Encryption key not configured".to_owned()))?; + + crate::cassandra_util::get_column(&row, "value", "create_access_key")? + }; + + let access_key_id = generate_access_key_id(); + let secret_key = generate_secret_key(); + let encrypted = encrypt_secret(&secret_key, &enc_key, &access_key_id).map_err(|e| { + tracing::error!("create_access_key encryption: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + + let catalog_keyspace = self.catalog_keyspace(); + let insert_query = format!( + "INSERT INTO {}.access_keys (access_key_id, account_id, user_name, secret_key_encrypted, is_active, created_at) \ + VALUES (?, ?, ?, ?, true, toTimestamp(now()))", + catalog_keyspace + ); + + let encrypted_blob = cdrs_tokio::types::blob::Blob::new(encrypted); + + self.session() + .query_with_values( + &insert_query, + cdrs_tokio::query_values!( + access_key_id.as_str(), + account_id, + user_name, + encrypted_blob + ), + ) + .await + .map_err(|e| { + tracing::error!("create_access_key insert failed: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + + Ok(AccessKeyCreated { + access_key_id, + secret_access_key: secret_key, + }) + } + + pub(crate) async fn delete_access_key_impl( + &self, + account_id: &str, + user_name: &str, + key_id: &str, + ) -> OpResult<()> { + let catalog_keyspace = self.catalog_keyspace(); + + // Check if key exists and belongs to the correct account/user + let check_query = format!( + "SELECT access_key_id, account_id, user_name FROM {}.access_keys \ + WHERE access_key_id = ?", + catalog_keyspace + ); + + let row = crate::cassandra_util::query_optional( + self.session(), + &check_query, + cdrs_tokio::query_values!(key_id), + "delete_access_key", + ) + .await?; + + match row { + None => return Err(OpError::NotFound("Access key not found".to_owned())), + Some(r) => { + // Verify it belongs to the specified account and user + let key_account: String = + crate::cassandra_util::get_column(&r, "account_id", "delete_access_key")?; + let key_user: String = + crate::cassandra_util::get_column(&r, "user_name", "delete_access_key")?; + + if key_account != account_id || key_user != user_name { + return Err(OpError::NotFound("Access key not found".to_owned())); + } + } + } + + // Delete the key (by PRIMARY KEY only) + let delete_query = format!( + "DELETE FROM {}.access_keys WHERE access_key_id = ?", + catalog_keyspace + ); + + crate::cassandra_util::execute( + self.session(), + &delete_query, + cdrs_tokio::query_values!(key_id), + "delete_access_key", + ) + .await + } + + pub(crate) async fn list_access_keys_impl( + &self, + account_id: &str, + user_name: &str, + ) -> OpResult> { + let catalog_keyspace = self.catalog_keyspace(); + let query = format!( + "SELECT access_key_id, is_active, created_at FROM {}.access_keys \ + WHERE account_id = ? AND user_name = ? ALLOW FILTERING", + catalog_keyspace + ); + + let rows = crate::cassandra_util::query_rows( + self.session(), + &query, + cdrs_tokio::query_values!(account_id, user_name), + "list_access_keys", + ) + .await?; + + let mut keys = crate::cassandra_util::map_rows( + rows, + |row| { + use crate::cassandra_util::{get_column, get_timestamp}; + Ok::<_, extenddb_storage::management_store::OpError>(( + get_column::(row, "access_key_id", "list_access_keys")?, + get_column::(row, "is_active", "list_access_keys")?, + get_timestamp(row, "created_at", "list_access_keys")?, + )) + }, + "list_access_keys", + )?; + + // Sort by created_at to match PostgreSQL ORDER BY behavior + keys.sort_by_key(|(_, _, created_at)| *created_at); + + Ok(keys) + } + + pub(crate) async fn import_access_key_impl( + &self, + account_id: &str, + user_name: &str, + access_key_id: &str, + secret_access_key: &str, + ) -> OpResult<()> { + // Check if user exists (emulate FK constraint) + if !self.user_exists(account_id, user_name).await? { + return Err(OpError::NotFound("IAM user not found".to_owned())); + } + + // P119: Use cached encryption key if available, fall back to DB query. + let enc_key: String = if let Some(cached) = self.encryption_key() { + cached.to_string() + } else { + let catalog_keyspace = self.catalog_keyspace(); + let query = format!( + "SELECT value FROM {}.settings WHERE key = 'encryption_key'", + catalog_keyspace + ); + + let row = crate::cassandra_util::query_optional( + self.session(), + &query, + cdrs_tokio::query_values!(), + "import_access_key", + ) + .await? + .ok_or_else(|| OpError::Internal("Encryption key not configured".to_owned()))?; + + crate::cassandra_util::get_column(&row, "value", "import_access_key")? + }; + + let encrypted = + encrypt_secret(secret_access_key, &enc_key, access_key_id).map_err(|e| { + tracing::error!("import_access_key encryption: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + + let insert_query = format!( + "INSERT INTO {}.access_keys (access_key_id, secret_key_encrypted, account_id, user_name, is_active, created_at) \ + VALUES (?, ?, ?, ?, true, toTimestamp(now())) IF NOT EXISTS", + self.catalog_keyspace() + ); + + let encrypted_blob = cdrs_tokio::types::blob::Blob::new(encrypted); + + let applied = crate::cassandra_util::apply_lwt( + self.session(), + &insert_query, + cdrs_tokio::query_values!(access_key_id, encrypted_blob, account_id, user_name), + "import_access_key", + ) + .await?; + + if !applied { + return Err(OpError::AlreadyExists( + "Access key ID already exists".to_owned(), + )); + } + + Ok(()) + } + + // ── Sessions ─────────────────────────────────────────────────── + + #[allow(clippy::too_many_arguments)] + pub(crate) async fn store_session_impl( + &self, + session_token: &str, + access_key_id: &str, + secret_key_encrypted: &[u8], + account_id: &str, + role_name: &str, + session_name: &str, + session_tags: &Option, + session_policy: &Option, + expires_at: time::OffsetDateTime, + ) -> OpResult<()> { + let query = format!( + "INSERT INTO {}.iam_sessions \ + (session_token, access_key_id, secret_key_encrypted, account_id, role_name, \ + session_name, session_tags, session_policy, expires_at, created_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, toTimestamp(now()))", + self.catalog_keyspace() + ); + + let session_tags_json = session_tags.as_ref().map(|v| v.to_string()); + let session_policy_json = session_policy.as_ref().map(|v| v.to_string()); + let expires_ms = expires_at.unix_timestamp() * 1000 + i64::from(expires_at.millisecond()); + + crate::cassandra_util::execute( + self.session(), + &query, + cdrs_tokio::query_values!( + session_token, + access_key_id, + Blob::new(secret_key_encrypted.to_vec()), + account_id, + role_name, + session_name, + session_tags_json.as_deref(), + session_policy_json.as_deref(), + expires_ms + ), + "store_session", + ) + .await + } + + // ── Caller tags ──────────────────────────────────────────────── + + pub(crate) async fn fetch_caller_tags_impl( + &self, + account_id: &str, + resource: &str, + ) -> OpResult> { + if let Some(user_name) = resource.strip_prefix("user/") { + let query = format!( + "SELECT tag_key, tag_value FROM {}.iam_user_tags \ + WHERE account_id = ? AND user_name = ?", + self.catalog_keyspace() + ); + let rows = crate::cassandra_util::query_rows( + self.session(), + &query, + cdrs_tokio::query_values!(account_id, user_name), + "fetch_caller_tags user", + ) + .await?; + crate::cassandra_util::map_rows( + rows, + |row| { + Ok(( + crate::cassandra_util::get_column(row, "tag_key", "fetch_caller_tags")?, + crate::cassandra_util::get_column(row, "tag_value", "fetch_caller_tags")?, + )) + }, + "fetch_caller_tags", + ) + } else if let Some(role_name) = resource.strip_prefix("role/") { + let query = format!( + "SELECT tag_key, tag_value FROM {}.iam_role_tags \ + WHERE account_id = ? AND role_name = ?", + self.catalog_keyspace() + ); + let rows = crate::cassandra_util::query_rows( + self.session(), + &query, + cdrs_tokio::query_values!(account_id, role_name), + "fetch_caller_tags role", + ) + .await?; + crate::cassandra_util::map_rows( + rows, + |row| { + Ok(( + crate::cassandra_util::get_column(row, "tag_key", "fetch_caller_tags")?, + crate::cassandra_util::get_column(row, "tag_value", "fetch_caller_tags")?, + )) + }, + "fetch_caller_tags", + ) + } else if let Some(rest) = resource.strip_prefix("assumed-role/") { + let role_name = rest.split('/').next().unwrap_or(""); + if role_name.is_empty() { + return Ok(Vec::new()); + } + let query = format!( + "SELECT tag_key, tag_value FROM {}.iam_role_tags \ + WHERE account_id = ? AND role_name = ?", + self.catalog_keyspace() + ); + let rows = crate::cassandra_util::query_rows( + self.session(), + &query, + cdrs_tokio::query_values!(account_id, role_name), + "fetch_caller_tags assumed-role", + ) + .await?; + crate::cassandra_util::map_rows( + rows, + |row| { + Ok(( + crate::cassandra_util::get_column(row, "tag_key", "fetch_caller_tags")?, + crate::cassandra_util::get_column(row, "tag_value", "fetch_caller_tags")?, + )) + }, + "fetch_caller_tags", + ) + } else { + Ok(Vec::new()) + } + } +} + +// ── Crypto helpers (duplicated from server::crypto to avoid circular dep) ── +// TODO - These should be lifted to extenddb-storage or extenddb-auth +fn generate_access_key_id() -> String { + const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let mut rng = rand::rng(); + let suffix: String = (0..8) + .map(|_| CHARSET[rand::Rng::random_range(&mut rng, 0..CHARSET.len())] as char) + .collect(); + format!("AKIAEXTENDDB{suffix}") +} + +fn generate_secret_key() -> String { + const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut rng = rand::rng(); + let suffix: String = (0..32) + .map(|_| CHARSET[rand::Rng::random_range(&mut rng, 0..CHARSET.len())] as char) + .collect(); + format!("extenddb{suffix}") +} + +fn encrypt_secret(plaintext: &str, key_b64: &str, aad: &str) -> Result, String> { + use aes_gcm::Aes256Gcm; + use aes_gcm::KeyInit; + use aes_gcm::aead::Aead; + use aes_gcm::aead::Payload; + use base64::Engine; + + let key_bytes = base64::engine::general_purpose::STANDARD + .decode(key_b64) + .map_err(|e| format!("decode encryption key: {e}"))?; + + let key = aes_gcm::Key::::from_slice(&key_bytes); + let cipher = Aes256Gcm::new(key); + + let nonce_bytes: [u8; 12] = rand::random(); + let nonce = aes_gcm::Nonce::from_slice(&nonce_bytes); + + let payload = Payload { + msg: plaintext.as_bytes(), + aad: aad.as_bytes(), + }; + let ciphertext = cipher + .encrypt(nonce, payload) + .map_err(|e| format!("encrypt: {e}"))?; + + let mut result = Vec::with_capacity(12 + ciphertext.len()); + result.extend_from_slice(&nonce_bytes); + result.extend_from_slice(&ciphertext); + Ok(result) +} diff --git a/crates/storage-cassandra/src/management_store/accounts.rs b/crates/storage-cassandra/src/management_store/accounts.rs new file mode 100644 index 00000000..3b106b93 --- /dev/null +++ b/crates/storage-cassandra/src/management_store/accounts.rs @@ -0,0 +1,434 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Account management operations for `CassandraCatalogStore`. + +use cdrs_tokio::types::IntoRustByName; +use extenddb_storage::management_store::{AccountDetail, OpError, OpResult}; + +use crate::catalog_store::CassandraCatalogStore; + +impl CassandraCatalogStore { + pub(crate) async fn create_account_impl( + &self, + account_id: &str, + account_name: &str, + ) -> OpResult<()> { + let catalog_keyspace = self.catalog_keyspace(); + let query = format!( + "INSERT INTO {}.accounts (account_id, account_name, created_at) VALUES (?, ?, toTimestamp(now())) IF NOT EXISTS", + catalog_keyspace + ); + + let result = self + .session() + .query_with_values(&query, cdrs_tokio::query_values!(account_id, account_name)) + .await + .map_err(|e| { + tracing::error!("create_account query failed: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + + // Check LWT result + let body = result.response_body().map_err(|e| { + tracing::error!("create_account response_body failed: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + + let applied = if let Some(rows) = body.into_rows() { + if let Some(row) = rows.first() { + row.get_r_by_name("[applied]").unwrap_or(false) + } else { + false + } + } else { + false + }; + + // Ensure account keyspace exists (whether account was just created OR already existed) + // This makes retries safe: if account creation succeeded but keyspace creation failed, + // the retry will create the keyspace before returning AlreadyExists error. + self.ensure_account_keyspace(account_id).await?; + + // Return AlreadyExists only AFTER keyspace is guaranteed to exist + if !applied { + return Err(OpError::AlreadyExists("Account already exists".to_owned())); + } + + Ok(()) + } + + pub(crate) async fn delete_account_impl(&self, account_id: &str) -> OpResult<()> { + if !self.account_exists(account_id).await? { + return Err(OpError::NotFound("Account not found".to_owned())); + } + + let catalog_keyspace = self.catalog_keyspace(); + + // Check if account has tables + let tables_query = format!( + "SELECT table_name FROM {}.tables WHERE account_id = ? LIMIT 1", + catalog_keyspace + ); + + let has_tables = crate::cassandra_util::query_optional( + self.session(), + &tables_query, + cdrs_tokio::query_values!(account_id), + "delete_account", + ) + .await? + .is_some(); + + if has_tables { + return Err(OpError::HasDependents( + "Cannot delete account with existing tables. Delete all tables first.".to_owned(), + )); + } + + // Backups outlive their source table, but cannot outlive the account + // keyspace that stores their payload. Remove every denormalized and + // authoritative catalog row before dropping that keyspace. + let backup_query = format!( + "SELECT backup_arn, table_name, created_at FROM {}.backups_by_account WHERE account_id = ?", + catalog_keyspace + ); + let backup_rows = crate::cassandra_util::query_rows::( + self.session(), + &backup_query, + cdrs_tokio::query_values!(account_id), + "delete_account_backups", + ) + .await?; + for row in backup_rows { + let backup_arn: String = row + .get_r_by_name("backup_arn") + .map_err(|e| OpError::Internal(format!("Parse backup ARN: {e}")))?; + let table_name: String = row + .get_r_by_name("table_name") + .map_err(|e| OpError::Internal(format!("Parse backup table: {e}")))?; + let created_at: i64 = row + .get_r_by_name("created_at") + .map_err(|e| OpError::Internal(format!("Parse backup timestamp: {e}")))?; + crate::cassandra_util::execute::( + self.session(), + &format!( + "DELETE FROM {}.backups_by_table WHERE account_id = ? AND table_name = ? AND created_at = ? AND backup_arn = ?", + catalog_keyspace + ), + cdrs_tokio::query_values!(account_id, table_name, created_at, backup_arn.as_str()), + "delete_account_table_backup", + ) + .await?; + crate::cassandra_util::execute::( + self.session(), + &format!( + "DELETE FROM {}.backups_by_arn WHERE account_id = ? AND backup_arn = ?", + catalog_keyspace + ), + cdrs_tokio::query_values!(account_id, backup_arn), + "delete_account_backup", + ) + .await?; + } + crate::cassandra_util::execute::( + self.session(), + &format!( + "DELETE FROM {}.backups_by_account WHERE account_id = ?", + catalog_keyspace + ), + cdrs_tokio::query_values!(account_id), + "delete_account_backup_index", + ) + .await?; + crate::cassandra_util::execute::( + self.session(), + &format!( + "DELETE FROM {}.continuous_backups WHERE account_id = ?", + catalog_keyspace + ), + cdrs_tokio::query_values!(account_id), + "delete_account_continuous_backups", + ) + .await?; + + // Delete account from catalog + let delete_query = format!( + "DELETE FROM {}.accounts WHERE account_id = ?", + catalog_keyspace + ); + + crate::cassandra_util::execute( + self.session(), + &delete_query, + cdrs_tokio::query_values!(account_id), + "delete_account", + ) + .await?; + + // Drop account keyspace + self.drop_account_keyspace(account_id).await?; + + Ok(()) + } + + pub(crate) async fn list_all_accounts_impl(&self) -> OpResult> { + let catalog_keyspace = self.catalog_keyspace(); + let query = format!( + "SELECT account_id, account_name FROM {}.accounts", + catalog_keyspace + ); + + let rows = crate::cassandra_util::query_rows( + self.session(), + &query, + cdrs_tokio::query_values!(), + "list_all_accounts", + ) + .await?; + + let mut accounts = crate::cassandra_util::map_rows( + rows, + |row| { + use crate::cassandra_util::get_column; + Ok::<_, OpError>(( + get_column::(row, "account_id", "list_all_accounts")?, + get_column::(row, "account_name", "list_all_accounts")?, + )) + }, + "list_all_accounts", + )?; + + accounts.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(accounts) + } + + pub(crate) async fn list_all_accounts_full_impl( + &self, + ) -> OpResult> { + let catalog_keyspace = self.catalog_keyspace(); + let query = format!( + "SELECT account_id, account_name, created_at FROM {}.accounts", + catalog_keyspace + ); + + let rows = crate::cassandra_util::query_rows( + self.session(), + &query, + cdrs_tokio::query_values!(), + "list_all_accounts_full", + ) + .await?; + + let mut accounts = crate::cassandra_util::map_rows( + rows, + |row| { + use crate::cassandra_util::{get_column, get_timestamp}; + Ok::<_, OpError>(( + get_column::(row, "account_id", "list_all_accounts_full")?, + get_column::(row, "account_name", "list_all_accounts_full")?, + get_timestamp(row, "created_at", "list_all_accounts_full")?, + )) + }, + "list_all_accounts_full", + )?; + + accounts.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(accounts) + } + + pub(crate) async fn list_accounts_for_impl( + &self, + account_id: &str, + ) -> OpResult> { + let catalog_keyspace = self.catalog_keyspace(); + let query = format!( + "SELECT account_id, account_name FROM {}.accounts WHERE account_id = ?", + catalog_keyspace + ); + + let rows = crate::cassandra_util::query_rows( + self.session(), + &query, + cdrs_tokio::query_values!(account_id), + "list_accounts_for", + ) + .await?; + + crate::cassandra_util::map_rows( + rows, + |row| { + use crate::cassandra_util::get_column; + Ok::<_, OpError>(( + get_column::(row, "account_id", "list_accounts_for")?, + get_column::(row, "account_name", "list_accounts_for")?, + )) + }, + "list_accounts_for", + ) + } + + pub(crate) async fn get_account_detail_impl( + &self, + account_id: &str, + ) -> OpResult> { + let catalog_keyspace = self.catalog_keyspace(); + + // Get account name + let account_query = format!( + "SELECT account_name FROM {}.accounts WHERE account_id = ?", + catalog_keyspace + ); + + let account_row = crate::cassandra_util::query_optional( + self.session(), + &account_query, + cdrs_tokio::query_values!(account_id), + "get_account_detail", + ) + .await?; + + let Some(row) = account_row else { + return Ok(None); + }; + + let account_name: String = + crate::cassandra_util::get_column(&row, "account_name", "get_account_detail")?; + + // Get users + let users_query = format!( + "SELECT user_name FROM {}.iam_users WHERE account_id = ?", + catalog_keyspace + ); + + let users_rows = crate::cassandra_util::query_rows( + self.session(), + &users_query, + cdrs_tokio::query_values!(account_id), + "get_account_detail", + ) + .await?; + + let mut users = crate::cassandra_util::map_rows( + users_rows, + |row| { + use crate::cassandra_util::get_column; + get_column::(row, "user_name", "get_account_detail") + }, + "get_account_detail", + )?; + users.sort(); + + // Get groups + let groups_query = format!( + "SELECT group_name FROM {}.iam_groups WHERE account_id = ?", + catalog_keyspace + ); + + let groups_rows = crate::cassandra_util::query_rows( + self.session(), + &groups_query, + cdrs_tokio::query_values!(account_id), + "get_account_detail", + ) + .await?; + + let mut groups = crate::cassandra_util::map_rows( + groups_rows, + |row| { + use crate::cassandra_util::get_column; + get_column::(row, "group_name", "get_account_detail") + }, + "get_account_detail", + )?; + groups.sort(); + + // Get roles + let roles_query = format!( + "SELECT role_name FROM {}.iam_roles WHERE account_id = ?", + catalog_keyspace + ); + + let roles_rows = crate::cassandra_util::query_rows( + self.session(), + &roles_query, + cdrs_tokio::query_values!(account_id), + "get_account_detail", + ) + .await?; + + let mut roles = crate::cassandra_util::map_rows( + roles_rows, + |row| { + use crate::cassandra_util::get_column; + get_column::(row, "role_name", "get_account_detail") + }, + "get_account_detail", + )?; + roles.sort(); + + Ok(Some(AccountDetail { + account_name, + users, + groups, + roles, + })) + } + + pub(crate) async fn dashboard_counts_impl(&self) -> OpResult<(i64, i64)> { + let catalog_keyspace = self.catalog_keyspace(); + + // Count accounts + let accounts_query = format!("SELECT account_id FROM {}.accounts", catalog_keyspace); + let accounts_rows = crate::cassandra_util::query_rows( + self.session(), + &accounts_query, + cdrs_tokio::query_values!(), + "dashboard_counts", + ) + .await?; + let account_count = accounts_rows.len() as i64; + + // Count admins + let admins_query = format!("SELECT admin_name FROM {}.admin_users", catalog_keyspace); + let admins_rows = crate::cassandra_util::query_rows( + self.session(), + &admins_query, + cdrs_tokio::query_values!(), + "dashboard_counts", + ) + .await?; + let admin_count = admins_rows.len() as i64; + + Ok((account_count, admin_count)) + } + + pub(crate) async fn get_default_account_id_impl(&self) -> OpResult> { + let keyspace = self.catalog_keyspace(); + let query = format!( + "SELECT value FROM {keyspace}.settings WHERE key = 'default_account_id'" + ); + let rows = crate::cassandra_util::query_rows::( + &self.session(), + &query, + cdrs_tokio::query_values!(), + "default_account_id", + ) + .await + .map_err(|e| { + tracing::error!("default_account_id: {e}"); + extenddb_storage::management_store::OpError::Internal("Database error".to_owned()) + })?; + Ok(rows + .into_iter() + .next() + .and_then(|row| { + crate::cassandra_util::get_column::( + &row, + "value", + "default_account_id", + ) + .ok() + })) + } +} diff --git a/crates/storage-cassandra/src/management_store/groups.rs b/crates/storage-cassandra/src/management_store/groups.rs new file mode 100644 index 00000000..bc8c2cd6 --- /dev/null +++ b/crates/storage-cassandra/src/management_store/groups.rs @@ -0,0 +1,293 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Group management operations for `CassandraCatalogStore`. + +use extenddb_storage::management_store::{GroupDetail, OpError, OpResult}; + +use crate::catalog_store::CassandraCatalogStore; + +impl CassandraCatalogStore { + pub(crate) async fn create_group_impl( + &self, + account_id: &str, + group_name: &str, + ) -> OpResult<()> { + // Check if account exists (emulate FK constraint) + if !self.account_exists(account_id).await? { + return Err(OpError::NotFound("Account not found".to_owned())); + } + + let catalog_keyspace = self.catalog_keyspace(); + let group_arn = format!("arn:aws:iam::{account_id}:group/{group_name}"); + let insert_query = format!( + "INSERT INTO {}.iam_groups (account_id, group_name, group_arn, created_at) \ + VALUES (?, ?, ?, toTimestamp(now())) IF NOT EXISTS", + catalog_keyspace + ); + + let applied = crate::cassandra_util::apply_lwt( + self.session(), + &insert_query, + cdrs_tokio::query_values!(account_id, group_name, group_arn.as_str()), + "create_group", + ) + .await?; + + if !applied { + return Err(OpError::AlreadyExists("IAM group already exists".to_owned())); + } + + Ok(()) + } + + pub(crate) async fn delete_group_impl( + &self, + account_id: &str, + group_name: &str, + ) -> OpResult<()> { + if !self.group_exists(account_id, group_name).await? { + return Err(OpError::NotFound("IAM group not found".to_owned())); + } + + let catalog_keyspace = self.catalog_keyspace(); + let delete_query = format!( + "DELETE FROM {}.iam_groups WHERE account_id = ? AND group_name = ?", + catalog_keyspace + ); + + crate::cassandra_util::execute( + self.session(), + &delete_query, + cdrs_tokio::query_values!(account_id, group_name), + "delete_group", + ) + .await + } + + pub(crate) async fn list_groups_impl( + &self, + account_id: &str, + ) -> OpResult> { + let catalog_keyspace = self.catalog_keyspace(); + let query = format!( + "SELECT account_id, group_name, group_arn, created_at \ + FROM {}.iam_groups WHERE account_id = ?", + catalog_keyspace + ); + + let rows = crate::cassandra_util::query_rows( + self.session(), + &query, + cdrs_tokio::query_values!(account_id), + "list_groups", + ) + .await?; + + let mut groups = crate::cassandra_util::map_rows( + rows, + |row| { + use crate::cassandra_util::{get_column, get_timestamp}; + Ok::<_, OpError>(( + get_column::(row, "account_id", "list_groups")?, + get_column::(row, "group_name", "list_groups")?, + get_column::(row, "group_arn", "list_groups")?, + get_timestamp(row, "created_at", "list_groups")?, + )) + }, + "list_groups", + )?; + + groups.sort_by(|a, b| a.1.cmp(&b.1)); + Ok(groups) + } + + pub(crate) async fn get_group_detail_impl( + &self, + account_id: &str, + group_name: &str, + ) -> OpResult> { + if !self.group_exists(account_id, group_name).await? { + return Ok(None); + } + + let catalog_keyspace = self.catalog_keyspace(); + + // Get members + let members_query = format!( + "SELECT user_name FROM {}.iam_group_members WHERE account_id = ? AND group_name = ?", + catalog_keyspace + ); + + let members_rows = crate::cassandra_util::query_rows( + self.session(), + &members_query, + cdrs_tokio::query_values!(account_id, group_name), + "get_group_detail", + ) + .await?; + + let mut members = crate::cassandra_util::map_rows( + members_rows, + |row| { + use crate::cassandra_util::get_column; + get_column::(row, "user_name", "get_group_detail") + }, + "get_group_detail", + )?; + members.sort(); + + // Get policies + let policies_query = format!( + "SELECT policy_name FROM {}.iam_policies \ + WHERE account_id = ? AND principal_type = 'group' AND principal_name = ?", + catalog_keyspace + ); + + let policies_rows = crate::cassandra_util::query_rows( + self.session(), + &policies_query, + cdrs_tokio::query_values!(account_id, group_name), + "get_group_detail", + ) + .await?; + + let mut policies = crate::cassandra_util::map_rows( + policies_rows, + |row| { + use crate::cassandra_util::get_column; + get_column::(row, "policy_name", "get_group_detail") + }, + "get_group_detail", + )?; + policies.sort(); + + // Get all users in account + let all_users_query = format!( + "SELECT user_name FROM {}.iam_users WHERE account_id = ?", + catalog_keyspace + ); + + let all_users_rows = crate::cassandra_util::query_rows( + self.session(), + &all_users_query, + cdrs_tokio::query_values!(account_id), + "get_group_detail", + ) + .await?; + + let mut all_users = crate::cassandra_util::map_rows( + all_users_rows, + |row| { + use crate::cassandra_util::get_column; + get_column::(row, "user_name", "get_group_detail") + }, + "get_group_detail", + )?; + all_users.sort(); + + Ok(Some(GroupDetail { + members, + policies, + all_users, + })) + } + + pub(crate) async fn add_group_member_impl( + &self, + account_id: &str, + group_name: &str, + user_name: &str, + ) -> OpResult<()> { + // Check if group and user exist (emulate FK constraint) + if !self.group_exists(account_id, group_name).await? { + return Err(OpError::NotFound("Group or user not found".to_owned())); + } + + if !self.user_exists(account_id, user_name).await? { + return Err(OpError::NotFound("Group or user not found".to_owned())); + } + + let catalog_keyspace = self.catalog_keyspace(); + let insert_query = format!( + "INSERT INTO {}.iam_group_members (account_id, group_name, user_name) VALUES (?, ?, ?) IF NOT EXISTS", + catalog_keyspace + ); + + let applied = crate::cassandra_util::apply_lwt( + self.session(), + &insert_query, + cdrs_tokio::query_values!(account_id, group_name, user_name), + "add_group_member", + ) + .await?; + + if !applied { + return Err(OpError::AlreadyExists( + "User is already a member of this group".to_owned(), + )); + } + + Ok(()) + } + + pub(crate) async fn remove_group_member_impl( + &self, + account_id: &str, + group_name: &str, + user_name: &str, + ) -> OpResult<()> { + let catalog_keyspace = self.catalog_keyspace(); + + // Check if membership exists + let check_query = format!( + "SELECT user_name FROM {}.iam_group_members \ + WHERE account_id = ? AND group_name = ? AND user_name = ?", + catalog_keyspace + ); + + if crate::cassandra_util::query_optional( + self.session(), + &check_query, + cdrs_tokio::query_values!(account_id, group_name, user_name), + "remove_group_member", + ) + .await? + .is_none() + { + return Err(OpError::NotFound("Membership not found".to_owned())); + } + + let delete_query = format!( + "DELETE FROM {}.iam_group_members WHERE account_id = ? AND group_name = ? AND user_name = ?", + catalog_keyspace + ); + + crate::cassandra_util::execute( + self.session(), + &delete_query, + cdrs_tokio::query_values!(account_id, group_name, user_name), + "remove_group_member", + ) + .await + } + + // Helper to check if group exists + async fn group_exists(&self, account_id: &str, group_name: &str) -> Result { + let catalog_keyspace = self.catalog_keyspace(); + let query = format!( + "SELECT group_name FROM {}.iam_groups WHERE account_id = ? AND group_name = ?", + catalog_keyspace + ); + + let rows = crate::cassandra_util::query_rows( + self.session(), + &query, + cdrs_tokio::query_values!(account_id, group_name), + "group_exists", + ) + .await?; + + Ok(!rows.is_empty()) + } +} diff --git a/crates/storage-cassandra/src/management_store/mod.rs b/crates/storage-cassandra/src/management_store/mod.rs new file mode 100755 index 00000000..82d8651f --- /dev/null +++ b/crates/storage-cassandra/src/management_store/mod.rs @@ -0,0 +1,590 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! `ManagementStore` implementation for `CassandraCatalogStore`. +//! +//! The trait impl delegates to `_impl` methods in submodules, keeping each +//! file under the 500-line limit. + +use extenddb_storage::management_store::{ + AccessKeyCreated, AccountDetail, GroupDetail, OpResult, RoleDetail, UserDetail, +}; +use futures::future::BoxFuture; + +use super::catalog_store::CassandraCatalogStore; + +mod access_keys; +mod accounts; +mod groups; +mod policies; +mod roles; +mod users; + +impl extenddb_storage::management_store::ManagementStore for CassandraCatalogStore { + // ── Accounts ─────────────────────────────────────────────────── + + fn create_account(&self, account_id: &str, account_name: &str) -> BoxFuture<'_, OpResult<()>> { + let account_id = account_id.to_string(); + let account_name = account_name.to_string(); + Box::pin(async move { self.create_account_impl(&account_id, &account_name).await }) + } + + fn delete_account(&self, account_id: &str) -> BoxFuture<'_, OpResult<()>> { + let account_id = account_id.to_string(); + Box::pin(async move { self.delete_account_impl(&account_id).await }) + } + + fn list_all_accounts(&self) -> BoxFuture<'_, OpResult>> { + Box::pin(async move { self.list_all_accounts_impl().await }) + } + + fn list_all_accounts_full( + &self, + ) -> BoxFuture<'_, OpResult>> { + Box::pin(async move { self.list_all_accounts_full_impl().await }) + } + + fn default_account_id(&self) -> BoxFuture<'_, OpResult>> { + Box::pin(async move { self.get_default_account_id_impl().await }) + } + + fn list_accounts_for( + &self, + account_id: &str, + ) -> BoxFuture<'_, OpResult>> { + let account_id = account_id.to_string(); + Box::pin(async move { self.list_accounts_for_impl(&account_id).await }) + } + + fn get_account_detail( + &self, + account_id: &str, + ) -> BoxFuture<'_, OpResult>> { + let account_id = account_id.to_string(); + Box::pin(async move { self.get_account_detail_impl(&account_id).await }) + } + + fn dashboard_counts(&self) -> BoxFuture<'_, OpResult<(i64, i64)>> { + Box::pin(async move { self.dashboard_counts_impl().await }) + } + + // ── Users ────────────────────────────────────────────────────── + + fn create_user( + &self, + account_id: &str, + user_name: &str, + password_hash: Option<&str>, + ) -> BoxFuture<'_, OpResult<()>> { + let account_id = account_id.to_string(); + let user_name = user_name.to_string(); + let password_hash = password_hash.map(|s| s.to_string()); + Box::pin(async move { + self.create_user_impl(&account_id, &user_name, password_hash.as_deref()) + .await + }) + } + + fn delete_user(&self, account_id: &str, user_name: &str) -> BoxFuture<'_, OpResult<()>> { + let account_id = account_id.to_string(); + let user_name = user_name.to_string(); + Box::pin(async move { self.delete_user_impl(&account_id, &user_name).await }) + } + + fn list_users( + &self, + account_id: &str, + ) -> BoxFuture<'_, OpResult>> { + let account_id = account_id.to_string(); + Box::pin(async move { self.list_users_impl(&account_id).await }) + } + + fn get_user_detail( + &self, + account_id: &str, + user_name: &str, + ) -> BoxFuture<'_, OpResult>> { + let account_id = account_id.to_string(); + let user_name = user_name.to_string(); + Box::pin(async move { self.get_user_detail_impl(&account_id, &user_name).await }) + } + + fn verify_iam_user_password( + &self, + account_id: &str, + user_name: &str, + password: &str, + ) -> BoxFuture<'_, OpResult> { + let account_id = account_id.to_string(); + let user_name = user_name.to_string(); + let password = password.to_string(); + Box::pin(async move { + self.verify_iam_user_password_impl(&account_id, &user_name, &password) + .await + }) + } + + fn change_user_password( + &self, + account_id: &str, + user_name: &str, + password_hash: &str, + ) -> BoxFuture<'_, OpResult<()>> { + let account_id = account_id.to_string(); + let user_name = user_name.to_string(); + let password_hash = password_hash.to_string(); + Box::pin(async move { + self.change_user_password_impl(&account_id, &user_name, &password_hash) + .await + }) + } + + fn tag_user( + &self, + account_id: &str, + user_name: &str, + tags: &[(String, String)], + ) -> BoxFuture<'_, OpResult<()>> { + let account_id = account_id.to_string(); + let user_name = user_name.to_string(); + let tags = tags.to_vec(); + Box::pin(async move { self.tag_user_impl(&account_id, &user_name, &tags).await }) + } + + fn untag_user( + &self, + account_id: &str, + user_name: &str, + tag_keys: &[String], + ) -> BoxFuture<'_, OpResult<()>> { + let account_id = account_id.to_string(); + let user_name = user_name.to_string(); + let tag_keys = tag_keys.to_vec(); + Box::pin(async move { + self.untag_user_impl(&account_id, &user_name, &tag_keys) + .await + }) + } + + fn list_user_tags( + &self, + account_id: &str, + user_name: &str, + ) -> BoxFuture<'_, OpResult>> { + let account_id = account_id.to_string(); + let user_name = user_name.to_string(); + Box::pin(async move { self.list_user_tags_impl(&account_id, &user_name).await }) + } + + // ── Groups ───────────────────────────────────────────────────── + + fn create_group(&self, account_id: &str, group_name: &str) -> BoxFuture<'_, OpResult<()>> { + let account_id = account_id.to_string(); + let group_name = group_name.to_string(); + Box::pin(async move { self.create_group_impl(&account_id, &group_name).await }) + } + + fn delete_group(&self, account_id: &str, group_name: &str) -> BoxFuture<'_, OpResult<()>> { + let account_id = account_id.to_string(); + let group_name = group_name.to_string(); + Box::pin(async move { self.delete_group_impl(&account_id, &group_name).await }) + } + + fn list_groups( + &self, + account_id: &str, + ) -> BoxFuture<'_, OpResult>> { + let account_id = account_id.to_string(); + Box::pin(async move { self.list_groups_impl(&account_id).await }) + } + + fn get_group_detail( + &self, + account_id: &str, + group_name: &str, + ) -> BoxFuture<'_, OpResult>> { + let account_id = account_id.to_string(); + let group_name = group_name.to_string(); + Box::pin(async move { self.get_group_detail_impl(&account_id, &group_name).await }) + } + + fn add_group_member( + &self, + account_id: &str, + group_name: &str, + user_name: &str, + ) -> BoxFuture<'_, OpResult<()>> { + let account_id = account_id.to_string(); + let group_name = group_name.to_string(); + let user_name = user_name.to_string(); + Box::pin(async move { + self.add_group_member_impl(&account_id, &group_name, &user_name) + .await + }) + } + + fn remove_group_member( + &self, + account_id: &str, + group_name: &str, + user_name: &str, + ) -> BoxFuture<'_, OpResult<()>> { + let account_id = account_id.to_string(); + let group_name = group_name.to_string(); + let user_name = user_name.to_string(); + Box::pin(async move { + self.remove_group_member_impl(&account_id, &group_name, &user_name) + .await + }) + } + + // ── Roles ────────────────────────────────────────────────────── + + fn create_role( + &self, + account_id: &str, + role_name: &str, + trust_policy: &serde_json::Value, + ) -> BoxFuture<'_, OpResult<()>> { + let account_id = account_id.to_string(); + let role_name = role_name.to_string(); + let trust_policy = trust_policy.clone(); + Box::pin(async move { + self.create_role_impl(&account_id, &role_name, &trust_policy) + .await + }) + } + + fn delete_role(&self, account_id: &str, role_name: &str) -> BoxFuture<'_, OpResult<()>> { + let account_id = account_id.to_string(); + let role_name = role_name.to_string(); + Box::pin(async move { self.delete_role_impl(&account_id, &role_name).await }) + } + + fn list_roles( + &self, + account_id: &str, + ) -> BoxFuture< + '_, + OpResult< + Vec<( + String, + String, + String, + serde_json::Value, + time::OffsetDateTime, + )>, + >, + > { + let account_id = account_id.to_string(); + Box::pin(async move { self.list_roles_impl(&account_id).await }) + } + + fn get_role_detail( + &self, + account_id: &str, + role_name: &str, + ) -> BoxFuture<'_, OpResult>> { + let account_id = account_id.to_string(); + let role_name = role_name.to_string(); + Box::pin(async move { self.get_role_detail_impl(&account_id, &role_name).await }) + } + + fn get_role_trust_policy( + &self, + account_id: &str, + role_name: &str, + ) -> BoxFuture<'_, OpResult>> { + let account_id = account_id.to_string(); + let role_name = role_name.to_string(); + Box::pin(async move { + self.get_role_trust_policy_impl(&account_id, &role_name) + .await + }) + } + + fn tag_role( + &self, + account_id: &str, + role_name: &str, + tags: &[(String, String)], + ) -> BoxFuture<'_, OpResult<()>> { + let account_id = account_id.to_string(); + let role_name = role_name.to_string(); + let tags = tags.to_vec(); + Box::pin(async move { self.tag_role_impl(&account_id, &role_name, &tags).await }) + } + + fn untag_role( + &self, + account_id: &str, + role_name: &str, + tag_keys: &[String], + ) -> BoxFuture<'_, OpResult<()>> { + let account_id = account_id.to_string(); + let role_name = role_name.to_string(); + let tag_keys = tag_keys.to_vec(); + Box::pin(async move { + self.untag_role_impl(&account_id, &role_name, &tag_keys) + .await + }) + } + + fn list_role_tags( + &self, + account_id: &str, + role_name: &str, + ) -> BoxFuture<'_, OpResult>> { + let account_id = account_id.to_string(); + let role_name = role_name.to_string(); + Box::pin(async move { self.list_role_tags_impl(&account_id, &role_name).await }) + } + + // ── Policies ─────────────────────────────────────────────────── + + fn put_policy( + &self, + account_id: &str, + principal_type: &str, + principal_name: &str, + policy_name: &str, + document: &serde_json::Value, + ) -> BoxFuture<'_, OpResult<()>> { + let account_id = account_id.to_string(); + let principal_type = principal_type.to_string(); + let principal_name = principal_name.to_string(); + let policy_name = policy_name.to_string(); + let document = document.clone(); + Box::pin(async move { + self.put_policy_impl( + &account_id, + &principal_type, + &principal_name, + &policy_name, + &document, + ) + .await + }) + } + + fn delete_policy( + &self, + account_id: &str, + principal_type: &str, + principal_name: &str, + policy_name: &str, + ) -> BoxFuture<'_, OpResult<()>> { + let account_id = account_id.to_string(); + let principal_type = principal_type.to_string(); + let principal_name = principal_name.to_string(); + let policy_name = policy_name.to_string(); + Box::pin(async move { + self.delete_policy_impl(&account_id, &principal_type, &principal_name, &policy_name) + .await + }) + } + + fn list_policies( + &self, + account_id: &str, + principal_type: &str, + principal_name: &str, + ) -> BoxFuture<'_, OpResult>> { + let account_id = account_id.to_string(); + let principal_type = principal_type.to_string(); + let principal_name = principal_name.to_string(); + Box::pin(async move { + self.list_policies_impl(&account_id, &principal_type, &principal_name) + .await + }) + } + + // ── Permissions boundaries ───────────────────────────────────── + + fn set_user_boundary( + &self, + account_id: &str, + user_name: &str, + document: &serde_json::Value, + ) -> BoxFuture<'_, OpResult<()>> { + let account_id = account_id.to_string(); + let user_name = user_name.to_string(); + let document = document.clone(); + Box::pin(async move { + self.set_boundary_impl(&account_id, "user", &user_name, &document) + .await + }) + } + + fn get_user_boundary( + &self, + account_id: &str, + user_name: &str, + ) -> BoxFuture<'_, OpResult>> { + let account_id = account_id.to_string(); + let user_name = user_name.to_string(); + Box::pin(async move { + self.get_boundary_impl(&account_id, "user", &user_name) + .await + }) + } + + fn delete_user_boundary( + &self, + account_id: &str, + user_name: &str, + ) -> BoxFuture<'_, OpResult<()>> { + let account_id = account_id.to_string(); + let user_name = user_name.to_string(); + Box::pin(async move { + self.delete_boundary_impl(&account_id, "user", &user_name) + .await + }) + } + + fn set_role_boundary( + &self, + account_id: &str, + role_name: &str, + document: &serde_json::Value, + ) -> BoxFuture<'_, OpResult<()>> { + let account_id = account_id.to_string(); + let role_name = role_name.to_string(); + let document = document.clone(); + Box::pin(async move { + self.set_boundary_impl(&account_id, "role", &role_name, &document) + .await + }) + } + + fn get_role_boundary( + &self, + account_id: &str, + role_name: &str, + ) -> BoxFuture<'_, OpResult>> { + let account_id = account_id.to_string(); + let role_name = role_name.to_string(); + Box::pin(async move { + self.get_boundary_impl(&account_id, "role", &role_name) + .await + }) + } + + fn delete_role_boundary( + &self, + account_id: &str, + role_name: &str, + ) -> BoxFuture<'_, OpResult<()>> { + let account_id = account_id.to_string(); + let role_name = role_name.to_string(); + Box::pin(async move { + self.delete_boundary_impl(&account_id, "role", &role_name) + .await + }) + } + + // ── Access keys ──────────────────────────────────────────────── + + fn create_access_key( + &self, + account_id: &str, + user_name: &str, + ) -> BoxFuture<'_, OpResult> { + let account_id = account_id.to_string(); + let user_name = user_name.to_string(); + Box::pin(async move { self.create_access_key_impl(&account_id, &user_name).await }) + } + + fn delete_access_key( + &self, + account_id: &str, + user_name: &str, + key_id: &str, + ) -> BoxFuture<'_, OpResult<()>> { + let account_id = account_id.to_string(); + let user_name = user_name.to_string(); + let key_id = key_id.to_string(); + Box::pin(async move { + self.delete_access_key_impl(&account_id, &user_name, &key_id) + .await + }) + } + + fn list_access_keys( + &self, + account_id: &str, + user_name: &str, + ) -> BoxFuture<'_, OpResult>> { + let account_id = account_id.to_string(); + let user_name = user_name.to_string(); + Box::pin(async move { self.list_access_keys_impl(&account_id, &user_name).await }) + } + + fn import_access_key( + &self, + account_id: &str, + user_name: &str, + access_key_id: &str, + secret_access_key: &str, + ) -> BoxFuture<'_, OpResult<()>> { + let account_id = account_id.to_string(); + let user_name = user_name.to_string(); + let access_key_id = access_key_id.to_string(); + let secret_access_key = secret_access_key.to_string(); + Box::pin(async move { + self.import_access_key_impl(&account_id, &user_name, &access_key_id, &secret_access_key) + .await + }) + } + + // ── Sessions ─────────────────────────────────────────────────── + + #[allow(clippy::too_many_arguments)] + fn store_session( + &self, + session_token: &str, + access_key_id: &str, + secret_key_encrypted: &[u8], + account_id: &str, + role_name: &str, + session_name: &str, + session_tags: &Option, + session_policy: &Option, + expires_at: time::OffsetDateTime, + ) -> BoxFuture<'_, OpResult<()>> { + let session_token = session_token.to_string(); + let access_key_id = access_key_id.to_string(); + let secret_key_encrypted = secret_key_encrypted.to_vec(); + let account_id = account_id.to_string(); + let role_name = role_name.to_string(); + let session_name = session_name.to_string(); + let session_tags = session_tags.clone(); + let session_policy = session_policy.clone(); + Box::pin(async move { + self.store_session_impl( + &session_token, + &access_key_id, + &secret_key_encrypted, + &account_id, + &role_name, + &session_name, + &session_tags, + &session_policy, + expires_at, + ) + .await + }) + } + + // ── Caller tags ──────────────────────────────────────────────── + + fn fetch_caller_tags( + &self, + account_id: &str, + resource: &str, + ) -> BoxFuture<'_, OpResult>> { + let account_id = account_id.to_string(); + let resource = resource.to_string(); + Box::pin(async move { self.fetch_caller_tags_impl(&account_id, &resource).await }) + } +} diff --git a/crates/storage-cassandra/src/management_store/policies.rs b/crates/storage-cassandra/src/management_store/policies.rs new file mode 100644 index 00000000..a06e589c --- /dev/null +++ b/crates/storage-cassandra/src/management_store/policies.rs @@ -0,0 +1,248 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Policy and permissions-boundary operations for `CassandraCatalogStore`. + +use extenddb_storage::management_store::{OpError, OpResult}; + +use crate::catalog_store::CassandraCatalogStore; + +impl CassandraCatalogStore { + // ── Policies ─────────────────────────────────────────────────── + + pub(crate) async fn put_policy_impl( + &self, + account_id: &str, + principal_type: &str, + principal_name: &str, + policy_name: &str, + document: &serde_json::Value, + ) -> OpResult<()> { + // Check if account exists (emulate FK constraint) + if !self.account_exists(account_id).await? { + return Err(OpError::NotFound("Account not found".to_owned())); + } + + let catalog_keyspace = self.catalog_keyspace(); + + // Using natural UPSERT semantics (ADR-0004) + let insert_query = format!( + "INSERT INTO {}.iam_policies (account_id, principal_type, principal_name, policy_name, policy_document, created_at) \ + VALUES (?, ?, ?, ?, ?, toTimestamp(now()))", + catalog_keyspace + ); + + let doc_str = document.to_string(); + + crate::cassandra_util::execute( + self.session(), + &insert_query, + cdrs_tokio::query_values!( + account_id, + principal_type, + principal_name, + policy_name, + doc_str.as_str() + ), + "put_policy", + ) + .await + } + + pub(crate) async fn delete_policy_impl( + &self, + account_id: &str, + principal_type: &str, + principal_name: &str, + policy_name: &str, + ) -> OpResult<()> { + let catalog_keyspace = self.catalog_keyspace(); + + // Check if policy exists + let check_query = format!( + "SELECT policy_name FROM {}.iam_policies \ + WHERE account_id = ? AND principal_type = ? AND principal_name = ? AND policy_name = ?", + catalog_keyspace + ); + + if crate::cassandra_util::query_optional( + self.session(), + &check_query, + cdrs_tokio::query_values!(account_id, principal_type, principal_name, policy_name), + "delete_policy", + ) + .await? + .is_none() + { + return Err(OpError::NotFound("Policy not found".to_owned())); + } + + let delete_query = format!( + "DELETE FROM {}.iam_policies \ + WHERE account_id = ? AND principal_type = ? AND principal_name = ? AND policy_name = ?", + catalog_keyspace + ); + + crate::cassandra_util::execute( + self.session(), + &delete_query, + cdrs_tokio::query_values!(account_id, principal_type, principal_name, policy_name), + "delete_policy", + ) + .await + } + + pub(crate) async fn list_policies_impl( + &self, + account_id: &str, + principal_type: &str, + principal_name: &str, + ) -> OpResult> { + let catalog_keyspace = self.catalog_keyspace(); + let query = format!( + "SELECT policy_name, policy_document, created_at FROM {}.iam_policies \ + WHERE account_id = ? AND principal_type = ? AND principal_name = ?", + catalog_keyspace + ); + + let rows = crate::cassandra_util::query_rows( + self.session(), + &query, + cdrs_tokio::query_values!(account_id, principal_type, principal_name), + "list_policies", + ) + .await?; + + let mut policies = crate::cassandra_util::map_rows( + rows, + |row| { + use crate::cassandra_util::{get_column, get_timestamp}; + let doc_str: String = get_column(row, "policy_document", "list_policies")?; + let doc = serde_json::from_str(&doc_str).map_err(|e| { + tracing::error!("list_policies parse policy_document: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + Ok::<_, OpError>(( + get_column::(row, "policy_name", "list_policies")?, + doc, + get_timestamp(row, "created_at", "list_policies")?, + )) + }, + "list_policies", + )?; + + policies.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(policies) + } + + // ── Permissions boundaries ───────────────────────────────────── + + pub(crate) async fn set_boundary_impl( + &self, + account_id: &str, + principal_type: &str, + principal_name: &str, + document: &serde_json::Value, + ) -> OpResult<()> { + // Check if account exists (emulate FK constraint) + if !self.account_exists(account_id).await? { + return Err(OpError::NotFound("Account not found".to_owned())); + } + + let catalog_keyspace = self.catalog_keyspace(); + + // Using natural UPSERT semantics + let insert_query = format!( + "INSERT INTO {}.iam_permissions_boundaries (account_id, principal_type, principal_name, policy_document) \ + VALUES (?, ?, ?, ?)", + catalog_keyspace + ); + + let doc_str = document.to_string(); + + crate::cassandra_util::execute( + self.session(), + &insert_query, + cdrs_tokio::query_values!(account_id, principal_type, principal_name, doc_str.as_str()), + "set_boundary", + ) + .await + } + + pub(crate) async fn get_boundary_impl( + &self, + account_id: &str, + principal_type: &str, + principal_name: &str, + ) -> OpResult> { + let catalog_keyspace = self.catalog_keyspace(); + let query = format!( + "SELECT policy_document FROM {}.iam_permissions_boundaries \ + WHERE account_id = ? AND principal_type = ? AND principal_name = ?", + catalog_keyspace + ); + + let row = crate::cassandra_util::query_optional( + self.session(), + &query, + cdrs_tokio::query_values!(account_id, principal_type, principal_name), + "get_boundary", + ) + .await?; + + let Some(row) = row else { + return Ok(None); + }; + + let doc_str: String = + crate::cassandra_util::get_column(&row, "policy_document", "get_boundary")?; + let doc = serde_json::from_str(&doc_str).map_err(|e| { + tracing::error!("get_boundary parse policy_document: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + + Ok(Some(doc)) + } + + pub(crate) async fn delete_boundary_impl( + &self, + account_id: &str, + principal_type: &str, + principal_name: &str, + ) -> OpResult<()> { + let catalog_keyspace = self.catalog_keyspace(); + + // Check if boundary exists + let check_query = format!( + "SELECT principal_name FROM {}.iam_permissions_boundaries \ + WHERE account_id = ? AND principal_type = ? AND principal_name = ?", + catalog_keyspace + ); + + if crate::cassandra_util::query_optional( + self.session(), + &check_query, + cdrs_tokio::query_values!(account_id, principal_type, principal_name), + "delete_boundary", + ) + .await? + .is_none() + { + return Err(OpError::NotFound("Permissions boundary not set".to_owned())); + } + + let delete_query = format!( + "DELETE FROM {}.iam_permissions_boundaries \ + WHERE account_id = ? AND principal_type = ? AND principal_name = ?", + catalog_keyspace + ); + + crate::cassandra_util::execute( + self.session(), + &delete_query, + cdrs_tokio::query_values!(account_id, principal_type, principal_name), + "delete_boundary", + ) + .await + } +} diff --git a/crates/storage-cassandra/src/management_store/roles.rs b/crates/storage-cassandra/src/management_store/roles.rs new file mode 100755 index 00000000..0d730123 --- /dev/null +++ b/crates/storage-cassandra/src/management_store/roles.rs @@ -0,0 +1,325 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Role management operations for `CassandraCatalogStore`. + +use cdrs_tokio::query_values; +use extenddb_storage::management_store::{OpError, OpResult, RoleDetail}; +use time::OffsetDateTime; + +use crate::cassandra_util::{ + execute, get_column, get_timestamp, map_rows, query_optional, query_rows, +}; +use crate::catalog_store::CassandraCatalogStore; + +impl CassandraCatalogStore { + async fn role_exists(&self, account_id: &str, role_name: &str) -> OpResult { + let query = format!( + "SELECT role_name FROM {}.iam_roles WHERE account_id = ? AND role_name = ?", + self.catalog_keyspace() + ); + let row = query_optional( + self.session(), + &query, + query_values!(account_id, role_name), + "role_exists", + ) + .await?; + Ok(row.is_some()) + } + + pub(crate) async fn create_role_impl( + &self, + account_id: &str, + role_name: &str, + trust_policy: &serde_json::Value, + ) -> OpResult<()> { + if !self.account_exists(account_id).await? { + return Err(OpError::NotFound("Account not found".to_owned())); + } + + let role_arn = format!("arn:aws:iam::{account_id}:role/{role_name}"); + let trust_policy_json = serde_json::to_string(trust_policy).map_err(|e| { + tracing::error!("create_role serialize trust_policy: {e}"); + OpError::Internal("JSON serialization failed".to_owned()) + })?; + let now = OffsetDateTime::now_utc(); + let now_ms = now.unix_timestamp() * 1000 + i64::from(now.millisecond()); + + let query = format!( + "INSERT INTO {}.iam_roles \ + (account_id, role_name, role_arn, trust_policy, created_at) \ + VALUES (?, ?, ?, ?, ?) IF NOT EXISTS", + self.catalog_keyspace() + ); + + let applied = crate::cassandra_util::apply_lwt( + self.session(), + &query, + query_values!(account_id, role_name, role_arn, trust_policy_json, now_ms), + "create_role", + ) + .await?; + + if !applied { + return Err(OpError::AlreadyExists("IAM role already exists".to_owned())); + } + + Ok(()) + } + + pub(crate) async fn delete_role_impl(&self, account_id: &str, role_name: &str) -> OpResult<()> { + if !self.role_exists(account_id, role_name).await? { + return Err(OpError::NotFound("IAM role not found".to_owned())); + } + + let query = format!( + "DELETE FROM {}.iam_roles WHERE account_id = ? AND role_name = ?", + self.catalog_keyspace() + ); + execute( + self.session(), + &query, + query_values!(account_id, role_name), + "delete_role", + ) + .await + } + + pub(crate) async fn list_roles_impl( + &self, + account_id: &str, + ) -> OpResult< + Vec<( + String, + String, + String, + serde_json::Value, + time::OffsetDateTime, + )>, + > { + let query = format!( + "SELECT account_id, role_name, role_arn, trust_policy, created_at \ + FROM {}.iam_roles WHERE account_id = ?", + self.catalog_keyspace() + ); + let rows = query_rows( + self.session(), + &query, + query_values!(account_id), + "list_roles", + ) + .await?; + + let mut roles = map_rows( + rows, + |row| { + let account: String = get_column(row, "account_id", "list_roles")?; + let role_name: String = get_column(row, "role_name", "list_roles")?; + let role_arn: String = get_column(row, "role_arn", "list_roles")?; + let trust_policy_str: String = get_column(row, "trust_policy", "list_roles")?; + let trust_policy: serde_json::Value = serde_json::from_str(&trust_policy_str) + .map_err(|e| { + tracing::error!("list_roles deserialize trust_policy: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + let created_at = get_timestamp(row, "created_at", "list_roles")?; + Ok((account, role_name, role_arn, trust_policy, created_at)) + }, + "list_roles", + )?; + roles.sort_by(|a, b| a.1.cmp(&b.1)); + Ok(roles) + } + + pub(crate) async fn get_role_detail_impl( + &self, + account_id: &str, + role_name: &str, + ) -> OpResult> { + let query = format!( + "SELECT trust_policy FROM {}.iam_roles WHERE account_id = ? AND role_name = ?", + self.catalog_keyspace() + ); + let row = query_optional( + self.session(), + &query, + query_values!(account_id, role_name), + "get_role_detail", + ) + .await?; + + let Some(row) = row else { + return Ok(None); + }; + + let trust_policy_str: String = get_column(&row, "trust_policy", "get_role_detail")?; + let trust_policy: serde_json::Value = + serde_json::from_str(&trust_policy_str).map_err(|e| { + tracing::error!("get_role_detail deserialize trust_policy: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + + let policies_query = format!( + "SELECT policy_name FROM {}.iam_policies \ + WHERE account_id = ? AND principal_type = 'role' AND principal_name = ?", + self.catalog_keyspace() + ); + let policies_rows = query_rows( + self.session(), + &policies_query, + query_values!(account_id, role_name), + "get_role_detail policies", + ) + .await?; + + let mut policies = map_rows( + policies_rows, + |row| get_column(row, "policy_name", "get_role_detail"), + "get_role_detail", + )?; + policies.sort(); + + let tags_query = format!( + "SELECT tag_key, tag_value FROM {}.iam_role_tags WHERE account_id = ? AND role_name = ?", + self.catalog_keyspace() + ); + let tags_rows = query_rows( + self.session(), + &tags_query, + query_values!(account_id, role_name), + "get_role_detail tags", + ) + .await?; + + let mut tags = map_rows( + tags_rows, + |row| { + Ok(( + get_column(row, "tag_key", "get_role_detail")?, + get_column(row, "tag_value", "get_role_detail")?, + )) + }, + "get_role_detail", + )?; + tags.sort(); + + Ok(Some(RoleDetail { + trust_policy, + policies, + tags, + })) + } + + pub(crate) async fn get_role_trust_policy_impl( + &self, + account_id: &str, + role_name: &str, + ) -> OpResult> { + let query = format!( + "SELECT trust_policy FROM {}.iam_roles WHERE account_id = ? AND role_name = ?", + self.catalog_keyspace() + ); + let row = query_optional( + self.session(), + &query, + query_values!(account_id, role_name), + "get_role_trust_policy", + ) + .await?; + + let Some(row) = row else { + return Ok(None); + }; + + let trust_policy_str: String = get_column(&row, "trust_policy", "get_role_trust_policy")?; + let trust_policy: serde_json::Value = + serde_json::from_str(&trust_policy_str).map_err(|e| { + tracing::error!("get_role_trust_policy deserialize trust_policy: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + Ok(Some(trust_policy)) + } + + // ── Role tags ────────────────────────────────────────────────── + + pub(crate) async fn tag_role_impl( + &self, + account_id: &str, + role_name: &str, + tags: &[(String, String)], + ) -> OpResult<()> { + if !self.role_exists(account_id, role_name).await? { + return Err(OpError::NotFound("IAM role not found".to_owned())); + } + + for (key, value) in tags { + let query = format!( + "INSERT INTO {}.iam_role_tags (account_id, role_name, tag_key, tag_value) \ + VALUES (?, ?, ?, ?)", + self.catalog_keyspace() + ); + execute( + self.session(), + &query, + query_values!(account_id, role_name, key.as_str(), value.as_str()), + "tag_role", + ) + .await?; + } + Ok(()) + } + + pub(crate) async fn untag_role_impl( + &self, + account_id: &str, + role_name: &str, + tag_keys: &[String], + ) -> OpResult<()> { + for key in tag_keys { + let query = format!( + "DELETE FROM {}.iam_role_tags WHERE account_id = ? AND role_name = ? AND tag_key = ?", + self.catalog_keyspace() + ); + execute( + self.session(), + &query, + query_values!(account_id, role_name, key.as_str()), + "untag_role", + ) + .await?; + } + Ok(()) + } + + pub(crate) async fn list_role_tags_impl( + &self, + account_id: &str, + role_name: &str, + ) -> OpResult> { + let query = format!( + "SELECT tag_key, tag_value FROM {}.iam_role_tags WHERE account_id = ? AND role_name = ?", + self.catalog_keyspace() + ); + let rows = query_rows( + self.session(), + &query, + query_values!(account_id, role_name), + "list_role_tags", + ) + .await?; + + let mut tags = map_rows( + rows, + |row| { + Ok(( + get_column(row, "tag_key", "list_role_tags")?, + get_column(row, "tag_value", "list_role_tags")?, + )) + }, + "list_role_tags", + )?; + tags.sort(); + Ok(tags) + } +} diff --git a/crates/storage-cassandra/src/management_store/users.rs b/crates/storage-cassandra/src/management_store/users.rs new file mode 100644 index 00000000..07ec138a --- /dev/null +++ b/crates/storage-cassandra/src/management_store/users.rs @@ -0,0 +1,442 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! User management operations for `CassandraCatalogStore`. + +use cdrs_tokio::types::IntoRustByName; +use extenddb_storage::management_store::{OpError, OpResult, UserDetail}; +use crate::catalog_store::CassandraCatalogStore; + +impl CassandraCatalogStore { + pub(crate) async fn create_user_impl( + &self, + account_id: &str, + user_name: &str, + password_hash: Option<&str>, + ) -> OpResult<()> { + // Check if account exists (emulate FK constraint) + if !self.account_exists(account_id).await? { + return Err(OpError::NotFound("Account not found".to_owned())); + } + + let user_arn = format!("arn:aws:iam::{account_id}:user/{user_name}"); + let catalog_keyspace = self.catalog_keyspace(); + let session = self.session().clone(); + + let account_id = account_id.to_owned(); + let user_name = user_name.to_owned(); + let password_hash = password_hash.map(|s| s.to_owned()); + + // Seed self-service policy document + let self_service_policy = serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": [ + "iam:CreateAccessKey", + "iam:DeleteAccessKey", + "iam:ListAccessKeys", + "iam:ChangePassword" + ], + "Resource": format!("arn:aws:iam::{account_id}:user/{user_name}") + }] + }); + + // Use LWT to atomically create the user, preventing duplicate names. + let user_query = format!( + "INSERT INTO {}.iam_users (account_id, user_name, user_arn, password_hash, created_at) \ + VALUES (?, ?, ?, ?, toTimestamp(now())) IF NOT EXISTS", + catalog_keyspace + ); + + let applied = crate::cassandra_util::apply_lwt( + &session, + &user_query, + cdrs_tokio::query_values!( + account_id.as_str(), + user_name.as_str(), + user_arn.as_str(), + password_hash.as_deref() + ), + "create_user", + ) + .await?; + + if !applied { + return Err(OpError::AlreadyExists("IAM user already exists".to_owned())); + } + + // User was created; now insert the self-service policy. + let policy_query = format!( + "INSERT INTO {}.iam_policies (account_id, principal_type, principal_name, policy_name, policy_document, created_at) \ + VALUES (?, 'user', ?, 'SelfServicePolicy', ?, toTimestamp(now()))", + catalog_keyspace + ); + + session + .query_with_values( + &policy_query, + cdrs_tokio::query_values!( + account_id.as_str(), + user_name.as_str(), + self_service_policy.to_string().as_str() + ), + ) + .await + .map_err(|e| { + tracing::error!("create_user policy insert failed: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + + Ok(()) + } + + pub(crate) async fn delete_user_impl(&self, account_id: &str, user_name: &str) -> OpResult<()> { + // Check if user exists + if !self.user_exists(account_id, user_name).await? { + return Err(OpError::NotFound("IAM user not found".to_owned())); + } + + let catalog_keyspace = self.catalog_keyspace(); + let delete_query = format!( + "DELETE FROM {}.iam_users WHERE account_id = ? AND user_name = ?", + catalog_keyspace + ); + + crate::cassandra_util::execute( + self.session(), + &delete_query, + cdrs_tokio::query_values!(account_id, user_name), + "delete_user", + ) + .await + } + + pub(crate) async fn list_users_impl( + &self, + account_id: &str, + ) -> OpResult> { + let catalog_keyspace = self.catalog_keyspace(); + let query = format!( + "SELECT account_id, user_name, user_arn, password_hash, created_at \ + FROM {}.iam_users WHERE account_id = ?", + catalog_keyspace + ); + + let rows = crate::cassandra_util::query_rows( + self.session(), + &query, + cdrs_tokio::query_values!(account_id), + "list_users", + ) + .await?; + + let mut users = crate::cassandra_util::map_rows( + rows, + |row| { + use crate::cassandra_util::{get_column, get_timestamp}; + let password_hash_opt: Option = + row.get_by_name("password_hash").ok().flatten(); + Ok::<_, OpError>(( + get_column::(row, "account_id", "list_users")?, + get_column::(row, "user_name", "list_users")?, + get_column::(row, "user_arn", "list_users")?, + password_hash_opt.is_some(), + get_timestamp(row, "created_at", "list_users")?, + )) + }, + "list_users", + )?; + + // Sort by user_name to match PostgreSQL ORDER BY + users.sort_by(|a, b| a.1.cmp(&b.1)); + Ok(users) + } + + pub(crate) async fn get_user_detail_impl( + &self, + account_id: &str, + user_name: &str, + ) -> OpResult> { + let catalog_keyspace = self.catalog_keyspace(); + + // Check if user exists + if !self.user_exists(account_id, user_name).await? { + return Ok(None); + } + + // Get access keys + let keys_query = format!( + "SELECT access_key_id, is_active FROM {}.access_keys \ + WHERE account_id = ? AND user_name = ? ALLOW FILTERING", + catalog_keyspace + ); + + let keys_rows = crate::cassandra_util::query_rows( + self.session(), + &keys_query, + cdrs_tokio::query_values!(account_id, user_name), + "get_user_detail", + ) + .await?; + + let mut keys = crate::cassandra_util::map_rows( + keys_rows, + |row| { + use crate::cassandra_util::get_column; + Ok::<_, OpError>(( + get_column::(row, "access_key_id", "get_user_detail")?, + get_column::(row, "is_active", "get_user_detail")?, + )) + }, + "get_user_detail", + )?; + keys.sort_by(|a, b| a.0.cmp(&b.0)); + + // Get policies + let policies_query = format!( + "SELECT policy_name FROM {}.iam_policies \ + WHERE account_id = ? AND principal_type = 'user' AND principal_name = ?", + catalog_keyspace + ); + + let policies_rows = crate::cassandra_util::query_rows( + self.session(), + &policies_query, + cdrs_tokio::query_values!(account_id, user_name), + "get_user_detail", + ) + .await?; + + let mut policies = crate::cassandra_util::map_rows( + policies_rows, + |row| { + use crate::cassandra_util::get_column; + get_column::(row, "policy_name", "get_user_detail") + }, + "get_user_detail", + )?; + policies.sort(); + + // Get tags + let tags_query = format!( + "SELECT tag_key, tag_value FROM {}.iam_user_tags \ + WHERE account_id = ? AND user_name = ?", + catalog_keyspace + ); + + let tags_rows = crate::cassandra_util::query_rows( + self.session(), + &tags_query, + cdrs_tokio::query_values!(account_id, user_name), + "get_user_detail", + ) + .await?; + + let mut tags = crate::cassandra_util::map_rows( + tags_rows, + |row| { + use crate::cassandra_util::get_column; + Ok::<_, OpError>(( + get_column::(row, "tag_key", "get_user_detail")?, + get_column::(row, "tag_value", "get_user_detail")?, + )) + }, + "get_user_detail", + )?; + tags.sort_by(|a, b| a.0.cmp(&b.0)); + + // Get groups + let groups_query = format!( + "SELECT group_name FROM {}.iam_group_members \ + WHERE account_id = ? AND user_name = ? ALLOW FILTERING", + catalog_keyspace + ); + + let groups_rows = crate::cassandra_util::query_rows( + self.session(), + &groups_query, + cdrs_tokio::query_values!(account_id, user_name), + "get_user_detail", + ) + .await?; + + let mut groups = crate::cassandra_util::map_rows( + groups_rows, + |row| { + use crate::cassandra_util::get_column; + get_column::(row, "group_name", "get_user_detail") + }, + "get_user_detail", + )?; + groups.sort(); + + Ok(Some(UserDetail { + keys, + policies, + tags, + groups, + })) + } + + pub(crate) async fn verify_iam_user_password_impl( + &self, + account_id: &str, + user_name: &str, + password: &str, + ) -> OpResult { + let catalog_keyspace = self.catalog_keyspace(); + let query = format!( + "SELECT password_hash FROM {}.iam_users \ + WHERE account_id = ? AND user_name = ?", + catalog_keyspace + ); + + let row = crate::cassandra_util::query_optional( + self.session(), + &query, + cdrs_tokio::query_values!(account_id, user_name), + "verify_iam_user_password", + ) + .await?; + + let Some(row) = row else { + return Ok(false); + }; + + let hash_opt: Option = row.get_by_name("password_hash").ok().flatten(); + + let Some(hash) = hash_opt else { + return Ok(false); + }; + + let pw = password.to_owned(); + Ok( + tokio::task::spawn_blocking(move || bcrypt::verify(pw, &hash).unwrap_or(false)) + .await + .unwrap_or(false), + ) + } + + pub(crate) async fn change_user_password_impl( + &self, + account_id: &str, + user_name: &str, + password_hash: &str, + ) -> OpResult<()> { + // Check if user exists + if !self.user_exists(account_id, user_name).await? { + return Err(OpError::NotFound("IAM user not found".to_owned())); + } + + let catalog_keyspace = self.catalog_keyspace(); + let update_query = format!( + "UPDATE {}.iam_users SET password_hash = ? WHERE account_id = ? AND user_name = ?", + catalog_keyspace + ); + + crate::cassandra_util::execute( + self.session(), + &update_query, + cdrs_tokio::query_values!(password_hash, account_id, user_name), + "change_user_password", + ) + .await + } + + // ── User tags ────────────────────────────────────────────────── + + pub(crate) async fn tag_user_impl( + &self, + account_id: &str, + user_name: &str, + tags: &[(String, String)], + ) -> OpResult<()> { + // Check if user exists (emulate FK constraint) + if !self.user_exists(account_id, user_name).await? { + return Err(OpError::NotFound("IAM user not found".to_owned())); + } + + let catalog_keyspace = self.catalog_keyspace(); + + // Insert tags (using natural UPSERT semantics - ADR-0004) + for (key, value) in tags { + let insert_query = format!( + "INSERT INTO {}.iam_user_tags (account_id, user_name, tag_key, tag_value) VALUES (?, ?, ?, ?)", + catalog_keyspace + ); + + crate::cassandra_util::execute( + self.session(), + &insert_query, + cdrs_tokio::query_values!(account_id, user_name, key.as_str(), value.as_str()), + "tag_user", + ) + .await?; + } + + Ok(()) + } + + pub(crate) async fn untag_user_impl( + &self, + account_id: &str, + user_name: &str, + tag_keys: &[String], + ) -> OpResult<()> { + let catalog_keyspace = self.catalog_keyspace(); + + for key in tag_keys { + let delete_query = format!( + "DELETE FROM {}.iam_user_tags WHERE account_id = ? AND user_name = ? AND tag_key = ?", + catalog_keyspace + ); + + crate::cassandra_util::execute( + self.session(), + &delete_query, + cdrs_tokio::query_values!(account_id, user_name, key.as_str()), + "untag_user", + ) + .await?; + } + + Ok(()) + } + + pub(crate) async fn list_user_tags_impl( + &self, + account_id: &str, + user_name: &str, + ) -> OpResult> { + let catalog_keyspace = self.catalog_keyspace(); + let query = format!( + "SELECT tag_key, tag_value FROM {}.iam_user_tags \ + WHERE account_id = ? AND user_name = ?", + catalog_keyspace + ); + + let rows = crate::cassandra_util::query_rows( + self.session(), + &query, + cdrs_tokio::query_values!(account_id, user_name), + "list_user_tags", + ) + .await?; + + let mut tags = crate::cassandra_util::map_rows( + rows, + |row| { + use crate::cassandra_util::get_column; + Ok::<_, OpError>(( + get_column::(row, "tag_key", "list_user_tags")?, + get_column::(row, "tag_value", "list_user_tags")?, + )) + }, + "list_user_tags", + )?; + + tags.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(tags) + } +} diff --git a/crates/storage-cassandra/src/metadata_engine.rs b/crates/storage-cassandra/src/metadata_engine.rs new file mode 100755 index 00000000..24238f62 --- /dev/null +++ b/crates/storage-cassandra/src/metadata_engine.rs @@ -0,0 +1,176 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! `MetadataEngine` trait implementation for `CassandraEngine`. + +use extenddb_core::types::{Item, Tag, TimeToLiveDescription}; +use extenddb_storage::MetadataEngine; +use extenddb_storage::error::StorageError; +use futures::future::BoxFuture; + +use crate::CassandraEngine; + +impl MetadataEngine for CassandraEngine { + fn describe_ttl( + &self, + account_id: &str, + table_name: &str, + ) -> BoxFuture<'_, Result> { + Box::pin(async move { todo!("describe_ttl not implemented") }) + } + + fn update_ttl( + &self, + account_id: &str, + table_name: &str, + attribute_name: &str, + enabled: bool, + ) -> BoxFuture<'_, Result<(), StorageError>> { + Box::pin(async move { todo!("update_ttl not implemented") }) + } + + fn tag_resource(&self, arn: &str, tags: &[Tag]) -> BoxFuture<'_, Result<(), StorageError>> { + let arn = arn.to_string(); + let tags = tags.to_vec(); + let catalog = self.catalog_keyspace(); + Box::pin(async move { + for tag in &tags { + let query = format!( + "INSERT INTO {}.tags (resource_arn, tag_key, tag_value) VALUES (?, ?, ?)", + catalog + ); + self.session_arc() + .query_with_values(&query, cdrs_tokio::query_values!(arn.as_str(), tag.key.as_str(), tag.value.as_str())) + .await + .map_err(|e| { + tracing::error!("tag_resource: {e}"); + StorageError::Internal("Database error".to_owned()) + })?; + } + Ok(()) + }) + } + + fn untag_resource( + &self, + arn: &str, + tag_keys: &[String], + ) -> BoxFuture<'_, Result<(), StorageError>> { + let arn = arn.to_string(); + let tag_keys = tag_keys.to_vec(); + let catalog = self.catalog_keyspace(); + Box::pin(async move { + for key in &tag_keys { + let query = format!( + "DELETE FROM {}.tags WHERE resource_arn = ? AND tag_key = ?", + catalog + ); + self.session_arc() + .query_with_values(&query, cdrs_tokio::query_values!(arn.as_str(), key.as_str())) + .await + .map_err(|e| { + tracing::error!("untag_resource: {e}"); + StorageError::Internal("Database error".to_owned()) + })?; + } + Ok(()) + }) + } + + fn list_tags(&self, arn: &str) -> BoxFuture<'_, Result, StorageError>> { + let arn = arn.to_string(); + let catalog = self.catalog_keyspace(); + Box::pin(async move { + let query = format!( + "SELECT tag_key, tag_value FROM {}.tags WHERE resource_arn = ?", + catalog + ); + let result = self.session_arc() + .query_with_values(&query, cdrs_tokio::query_values!(arn.as_str())) + .await + .map_err(|e| { + tracing::error!("list_tags: {e}"); + StorageError::Internal("Database error".to_owned()) + })?; + + let rows = result + .response_body() + .map_err(|e| StorageError::Internal(e.to_string()))? + .into_rows() + .unwrap_or_default(); + + let mut tags = Vec::with_capacity(rows.len()); + for row in rows { + let key: String = crate::cassandra_util::get_column(&row, "tag_key", "list_tags")?; + let value: String = crate::cassandra_util::get_column(&row, "tag_value", "list_tags")?; + tags.push(Tag { key, value }); + } + Ok(tags) + }) + } + + fn tables_with_ttl( + &self, + account_id: &str, + ) -> BoxFuture<'_, Result, StorageError>> { + Box::pin(async move { todo!("tables_with_ttl not implemented") }) + } + + fn all_tables_with_ttl( + &self, + ) -> BoxFuture<'_, Result, StorageError>> { + Box::pin(async move { todo!("all_tables_with_ttl not implemented") }) + } + + fn all_tables_with_ttl_index_ready( + &self, + ) -> BoxFuture<'_, Result, StorageError>> { + Box::pin(async move { todo!("all_tables_with_ttl_index_ready not implemented") }) + } + + fn create_ttl_index( + &self, + account_id: &str, + table_name: &str, + ttl_attribute: &str, + ) -> BoxFuture<'_, Result<(), StorageError>> { + Box::pin(async move { todo!("create_ttl_index not implemented") }) + } + + fn drop_ttl_index( + &self, + account_id: &str, + table_name: &str, + ) -> BoxFuture<'_, Result<(), StorageError>> { + Box::pin(async move { todo!("drop_ttl_index not implemented") }) + } + + fn find_expired_items_indexed( + &self, + account_id: &str, + table_name: &str, + ttl_attribute: &str, + limit: usize, + ) -> BoxFuture<'_, Result, StorageError>> { + Box::pin(async move { todo!("find_expired_items_indexed not implemented") }) + } + + fn refresh_table_size( + &self, + account_id: &str, + table_name: &str, + ) -> BoxFuture<'_, Result<(), StorageError>> { + Box::pin(async move { todo!("refresh_table_size not implemented") }) + } + + fn list_active_table_names( + &self, + account_id: &str, + ) -> BoxFuture<'_, Result, StorageError>> { + Box::pin(async move { todo!("list_active_table_names not implemented") }) + } + + fn all_active_tables(&self) -> BoxFuture<'_, Result, StorageError>> { + Box::pin(async move { todo!("all_active_tables not implemented") }) + } +} diff --git a/crates/storage-cassandra/src/migrations.rs b/crates/storage-cassandra/src/migrations.rs new file mode 100644 index 00000000..41d3eda4 --- /dev/null +++ b/crates/storage-cassandra/src/migrations.rs @@ -0,0 +1,251 @@ +// Copyright 2026 ExtendDB Contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Cassandra schema migrations. +//! +//! ## Migration Versioning +//! +//! This module uses Flyway-style versioning for migration files: +//! +//! - Format: `V###__description.cql` (e.g., `V001__initial_schema.cql`) +//! - Version: Integer extracted from `V###__` prefix (e.g., 1 from V001) +//! - Description: Text after double underscore (e.g., `initial_schema`) +//! +//! Flyway is a popular database migration tool that uses this naming convention. +//! See: https://flywaydb.org/documentation/concepts/migrations#naming +//! +//! ## Migration Tracking +//! +//! Applied migrations are tracked in the `schema_history` table: +//! - `version` (int): Extracted from filename (V001 → 1) +//! - `description` (text): Extracted from filename (V001__initial_schema.cql → initial_schema) +//! - `applied_at` (timestamp): When the migration was applied +//! +//! Migrations are applied in order by version number. Already-applied migrations +//! are skipped based on the version number in the tracking table. +//! +//! ## Adding New Migrations +//! +//! 1. Create a new file: `migrations/catalog/V###__description.cql` +//! 2. Use the next sequential version number (e.g., V003 after V002) +//! 3. Add the migration to the `CATALOG_MIGRATIONS` array below +//! 4. Migrations are embedded at compile time via `include_str!()` + +use cdrs_tokio::cluster::TcpConnectionManager; +use cdrs_tokio::cluster::session::Session; +use cdrs_tokio::load_balancing::RoundRobinLoadBalancingStrategy; +use cdrs_tokio::transport::TransportTcp; +use extenddb_storage::management_store::{OpError, OpResult}; +use std::sync::Arc; + +type CassandraSession = Session< + TransportTcp, + TcpConnectionManager, + RoundRobinLoadBalancingStrategy, +>; + +/// Embedded catalog migration files, applied in order. +pub(crate) const CATALOG_MIGRATIONS: &[(&str, &str)] = &[ + ( + "V001__initial_schema.cql", + include_str!("../migrations/catalog/V001__initial_schema.cql"), + ), + ( + "V002__backup_restore.cql", + include_str!("../migrations/catalog/V002__backup_restore.cql"), + ), + ( + "V003__account_scoped_idempotency.cql", + include_str!("../migrations/catalog/V003__account_scoped_idempotency.cql"), + ), +]; + +/// Embedded data migration files, applied in order. +pub(crate) const DATA_MIGRATIONS: &[(&str, &str)] = &[ + ( + "V001__initial_data_schema.cql", + include_str!("../migrations/data/V001__initial_data_schema.cql"), + ), + ( + "V002__backup_items.cql", + include_str!("../migrations/data/V002__backup_items.cql"), + ), +]; + +/// Run catalog migrations, skipping already-applied ones. +pub async fn run_catalog_migrations( + session: &Arc, + keyspace: &str, +) -> OpResult<()> { + println!("--- Running catalog migrations..."); + + // Set keyspace context for all subsequent queries + session + .query(format!("USE {}", keyspace)) + .await + .map_err(|e| OpError::Internal(format!("Failed to USE keyspace: {e}")))?; + + for (filename, sql) in CATALOG_MIGRATIONS { + if is_migration_applied(session, keyspace, filename).await? { + println!(" {filename} — already applied, skipping."); + continue; + } + println!(" Applying {filename}..."); + execute_migration(session, sql).await?; + record_migration(session, keyspace, filename).await?; + } + println!(" Migrations applied."); + Ok(()) +} + +/// Run data database migrations. +pub async fn run_data_migrations(session: &Arc, keyspace: &str) -> OpResult<()> { + println!("--- Running data migrations..."); + + // Set keyspace context for all subsequent queries + session + .query(format!("USE {}", keyspace)) + .await + .map_err(|e| OpError::Internal(format!("Failed to USE keyspace: {e}")))?; + + for (filename, sql) in DATA_MIGRATIONS { + if is_migration_applied(session, keyspace, filename).await? { + println!(" {filename} — already applied, skipping."); + continue; + } + println!(" Applying {filename}..."); + execute_migration(session, sql).await?; + record_migration(session, keyspace, filename).await?; + } + println!(" Data migrations applied."); + Ok(()) +} + +/// Check if a table exists in the given keyspace. +pub(crate) async fn table_exists( + session: &Arc, + keyspace: &str, + table_name: &str, +) -> OpResult { + let query = + "SELECT table_name FROM system_schema.tables WHERE keyspace_name = ? AND table_name = ?"; + let exists = session + .query_with_values(query, cdrs_tokio::query_values!(keyspace, table_name)) + .await + .ok() + .and_then(|frame| frame.response_body().ok()) + .and_then(|body| body.into_rows()) + .map(|rows| !rows.is_empty()) + .unwrap_or(false); + Ok(exists) +} + +/// Return the union of data migrations pending in any account keyspace. +/// +/// A migration is pending if at least one existing account has not recorded it. +/// This keeps a partially completed multi-account upgrade retryable. +pub(crate) async fn pending_data_migrations( + session: &Arc, + catalog_keyspace: &str, + account_keyspace_fn: impl Fn(&str) -> String, +) -> OpResult> { + let query = format!("SELECT account_id FROM {}.accounts", catalog_keyspace); + let rows = crate::cassandra_util::query_rows::( + &Arc::clone(session), + &query, + cdrs_tokio::query_values!(), + "pending_data_migrations", + ) + .await?; + + let mut pending = std::collections::BTreeSet::new(); + for row in rows { + let account_id: String = + crate::cassandra_util::get_column(&row, "account_id", "pending_data_migrations")?; + let keyspace = account_keyspace_fn(&account_id); + for (filename, _sql) in DATA_MIGRATIONS { + if !is_migration_applied(session, &keyspace, filename).await? { + pending.insert((*filename).to_owned()); + } + } + } + Ok(pending.into_iter().collect()) +} + +/// Check if a migration has already been applied. +async fn is_migration_applied( + session: &Arc, + keyspace: &str, + filename: &str, +) -> OpResult { + // Extract version from filename (V001__description.cql) + let version: i32 = filename + .strip_prefix("V") + .and_then(|s| s.split("__").next()) + .and_then(|s| s.parse().ok()) + .ok_or_else(|| OpError::Internal(format!("Invalid migration filename: {}", filename)))?; + + let check_cql = format!( + "SELECT version FROM {}.schema_history WHERE version = ?", + keyspace + ); + + let applied = session + .query_with_values(check_cql, cdrs_tokio::query_values!(version)) + .await + .ok() + .and_then(|frame| frame.response_body().ok()) + .and_then(|body| body.into_rows()) + .map(|rows| !rows.is_empty()) + .unwrap_or(false); + + Ok(applied) +} + +/// Execute a migration by splitting on semicolons and running each statement. +async fn execute_migration(session: &Arc, sql: &str) -> OpResult<()> { + for statement in sql.split(';').filter(|s| !s.trim().is_empty()) { + let stmt = statement.trim(); + if stmt.is_empty() { + continue; + } + + session + .query(stmt) + .await + .map_err(|e| OpError::Internal(format!("Migration failed: {e}")))?; + } + Ok(()) +} + +/// Record a migration in the `schema_history` table. +async fn record_migration( + session: &Arc, + keyspace: &str, + filename: &str, +) -> OpResult<()> { + // Extract version and description from filename + let version: i32 = filename + .strip_prefix("V") + .and_then(|s| s.split("__").next()) + .and_then(|s| s.parse().ok()) + .ok_or_else(|| OpError::Internal(format!("Invalid migration filename: {}", filename)))?; + + let description = filename + .split("__") + .nth(1) + .and_then(|s| s.strip_suffix(".cql")) + .unwrap_or("unknown"); + + let record_cql = format!( + "INSERT INTO {}.schema_history (version, description, applied_at) VALUES (?, ?, toTimestamp(now()))", + keyspace + ); + + session + .query_with_values(record_cql, cdrs_tokio::query_values!(version, description)) + .await + .map_err(|e| OpError::Internal(format!("Record migration: {e}")))?; + + Ok(()) +} diff --git a/crates/storage-cassandra/src/operations.rs b/crates/storage-cassandra/src/operations.rs new file mode 100644 index 00000000..db8f8f77 --- /dev/null +++ b/crates/storage-cassandra/src/operations.rs @@ -0,0 +1,215 @@ +// Copyright 2026 ExtendDB Contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Cassandra implementation of `OperationsEngine`. + +use extenddb_storage::error::StorageError; +use extenddb_storage::operations::{ConnectionParts, OperationsEngine}; + +/// Cassandra operations engine for CLI commands. +pub struct CassandraOperationsEngine; + +impl OperationsEngine for CassandraOperationsEngine { + fn parse_connection_string(&self, conn_str: &str) -> Result { + // Cassandra connection string format: "host1:9042,host2:9042/keyspace_prefix" + // Split on '/' to separate hosts from keyspace + let (hosts_str, keyspace) = if let Some((h, k)) = conn_str.split_once('/') { + (h, k.to_string()) + } else { + (conn_str, "extenddb".to_string()) + }; + + // Parse the first contact point for compatibility with ConnectionParts + let first_contact = hosts_str + .split(',') + .next() + .ok_or_else(|| StorageError::Internal("Empty connection string".to_string()))?; + + let parts: Vec<&str> = first_contact.split(':').collect(); + if parts.len() != 2 { + return Err(StorageError::Internal(format!( + "Invalid Cassandra contact point format: '{}'. Expected 'host:port'", + first_contact + ))); + } + + let host = parts[0].to_string(); + let port: u16 = parts[1] + .parse() + .map_err(|_| StorageError::Internal(format!("Invalid port number: '{}'", parts[1])))?; + + // Use keyspace_prefix as the "database" name for display + Ok(ConnectionParts { + host, + port, + database: format!("{}_catalog", keyspace), + user: String::new(), + password: String::new(), + }) + } + + fn redact_connection_string(&self, conn_str: &str) -> String { + // Cassandra connection strings don't contain passwords + // Just return as-is + conn_str.to_string() + } + + fn validate_identifier(&self, name: &str, label: &str) -> Result<(), StorageError> { + // Cassandra identifier rules: + // - Alphanumeric and underscore only + // - Cannot start with a digit + // - Max 48 characters (keyspace) or 48 characters (table) + // - Case-insensitive (stored lowercase unless quoted) + + if name.is_empty() { + return Err(StorageError::Internal(format!("{} cannot be empty", label))); + } + + if name.len() > 48 { + return Err(StorageError::Internal(format!( + "{} '{}' exceeds maximum length of 48 characters", + label, name + ))); + } + + // Check first character (cannot be digit) + if let Some(first_char) = name.chars().next() { + if first_char.is_ascii_digit() { + return Err(StorageError::Internal(format!( + "{} '{}' cannot start with a digit", + label, name + ))); + } + } + + // Check all characters (alphanumeric + underscore only) + if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + return Err(StorageError::Internal(format!( + "{} '{}' contains invalid characters. Only alphanumeric and underscore allowed", + label, name + ))); + } + + Ok(()) + } + + fn catalog_version(&self) -> String { + "0.0.1".to_string() + } + + fn is_sensitive_key(&self, key: &str) -> bool { + key.contains("password") || key.contains("secret") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_connection_string_with_keyspace() { + let ops = CassandraOperationsEngine; + let result = ops + .parse_connection_string("127.0.0.1:9042/extenddb") + .unwrap(); + + assert_eq!(result.host, "127.0.0.1"); + assert_eq!(result.port, 9042); + assert_eq!(result.database, "extenddb_catalog"); + } + + #[test] + fn test_parse_connection_string_without_keyspace() { + let ops = CassandraOperationsEngine; + let result = ops.parse_connection_string("127.0.0.1:9042").unwrap(); + + assert_eq!(result.host, "127.0.0.1"); + assert_eq!(result.port, 9042); + assert_eq!(result.database, "extenddb_catalog"); // Default keyspace + } + + #[test] + fn test_parse_connection_string_multiple_hosts() { + let ops = CassandraOperationsEngine; + let result = ops + .parse_connection_string("host1:9042,host2:9042,host3:9042/production") + .unwrap(); + + // Should parse first host + assert_eq!(result.host, "host1"); + assert_eq!(result.port, 9042); + assert_eq!(result.database, "production_catalog"); + } + + #[test] + fn test_parse_connection_string_invalid_port() { + let ops = CassandraOperationsEngine; + let result = ops.parse_connection_string("127.0.0.1:invalid/test"); + + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("Invalid port number") + ); + } + + #[test] + fn test_parse_connection_string_empty() { + let ops = CassandraOperationsEngine; + let result = ops.parse_connection_string(""); + + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + // Empty string splits to [""], which isn't empty, so we get "Invalid Cassandra contact point format" + assert!(err_msg.contains("Invalid") || err_msg.contains("Empty")); + } + + #[test] + fn test_validate_identifier_valid() { + let ops = CassandraOperationsEngine; + assert!(ops.validate_identifier("test_keyspace", "keyspace").is_ok()); + assert!(ops.validate_identifier("my_table_123", "table").is_ok()); + } + + #[test] + fn test_validate_identifier_starts_with_digit() { + let ops = CassandraOperationsEngine; + let result = ops.validate_identifier("123_test", "keyspace"); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("cannot start with a digit") + ); + } + + #[test] + fn test_validate_identifier_too_long() { + let ops = CassandraOperationsEngine; + let long_name = "a".repeat(49); + let result = ops.validate_identifier(&long_name, "keyspace"); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("exceeds maximum length") + ); + } + + #[test] + fn test_validate_identifier_invalid_chars() { + let ops = CassandraOperationsEngine; + let result = ops.validate_identifier("test-keyspace", "keyspace"); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("invalid characters") + ); + } +} diff --git a/crates/storage-cassandra/src/stream_engine.rs b/crates/storage-cassandra/src/stream_engine.rs new file mode 100755 index 00000000..5647eaef --- /dev/null +++ b/crates/storage-cassandra/src/stream_engine.rs @@ -0,0 +1,578 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! `StreamEngine` trait implementation for `CassandraEngine`. + +use extenddb_core::types::{ + SequenceNumberRange, Shard, StreamDescription, StreamRecord, StreamStatus, StreamSummary, + StreamViewType, +}; +use extenddb_storage::StreamEngine; +use extenddb_storage::error::StorageError; +use extenddb_storage::util::{parse_stream_arn, stream_arn}; +use futures::future::BoxFuture; + +use crate::CassandraEngine; + +impl CassandraEngine { + fn account_keyspace_for(&self, account_id: &str) -> String { + self.account_keyspace(account_id) + } + + /// Resolve the account keyspace for a shard by looking up the table_id + /// (embedded in the shard ID) via the secondary index on `tables.table_id`. + /// + /// Shard ID format: `shardId-{table_id}-{index:012}` + async fn account_keyspace_for_shard(&self, shard_id: &str) -> Result { + // Strip "shardId-" prefix and trailing "-{12 digits}" to get table_id. + let without_prefix = shard_id.strip_prefix("shardId-").ok_or_else(|| { + StorageError::Internal(format!("Invalid shard_id format: {shard_id}")) + })?; + let table_id = without_prefix.rsplitn(2, '-').nth(1).ok_or_else(|| { + StorageError::Internal(format!("Invalid shard_id format: {shard_id}")) + })?; + + let catalog_keyspace = self.catalog_keyspace(); + let query = format!( + "SELECT account_id FROM {catalog_keyspace}.tables WHERE table_id = ?" + ); + let result = self + .session + .query_with_values(&query, cdrs_tokio::query_values!(table_id)) + .await + .map_err(|e| StorageError::Internal(e.to_string()))?; + + use cdrs_tokio::types::IntoRustByName; + let account_id: String = result + .response_body() + .map_err(|e| StorageError::Internal(e.to_string()))? + .into_rows() + .unwrap_or_default() + .into_iter() + .next() + .ok_or_else(|| StorageError::TableNotFound(format!("No table found for shard {shard_id}")))? + .get_r_by_name("account_id") + .map_err(|e| StorageError::Internal(e.to_string()))?; + + Ok(self.account_keyspace(&account_id)) + } + + /// Like `account_keyspace_for_shard`, but also enforces that the shard's + /// table belongs to `caller_account_id`. Returns `Validation("Invalid + /// ShardIterator")` for both "shard not found" and "shard belongs to a + /// different account" — no information leakage about other accounts. + async fn account_keyspace_for_shard_owned_by( + &self, + shard_id: &str, + caller_account_id: &str, + ) -> Result { + let without_prefix = shard_id.strip_prefix("shardId-").ok_or_else(|| { + StorageError::Validation("Invalid ShardIterator".to_owned()) + })?; + let table_id = without_prefix.rsplitn(2, '-').nth(1).ok_or_else(|| { + StorageError::Validation("Invalid ShardIterator".to_owned()) + })?; + + let catalog_keyspace = self.catalog_keyspace(); + let query = format!( + "SELECT account_id FROM {catalog_keyspace}.tables WHERE table_id = ?" + ); + let result = self + .session + .query_with_values(&query, cdrs_tokio::query_values!(table_id)) + .await + .map_err(|e| StorageError::Internal(e.to_string()))?; + + use cdrs_tokio::types::IntoRustByName; + let table_account_id: Option = result + .response_body() + .map_err(|e| StorageError::Internal(e.to_string()))? + .into_rows() + .unwrap_or_default() + .into_iter() + .next() + .and_then(|row| row.get_r_by_name("account_id").ok()); + + // Collapse "not found" and "wrong account" into the same error. + match table_account_id { + Some(ref owner) if owner == caller_account_id => Ok(self.account_keyspace(owner)), + _ => Err(StorageError::Validation("Invalid ShardIterator".to_owned())), + } + } +} + +impl StreamEngine for CassandraEngine { + /// Not used for atomic writes — see ADR-0008. Stream records are injected + /// directly into LOGGED BATCHes via `stream_record_statement` in each write path. + fn write_stream_record( + &self, + _account_id: &str, + _record: &StreamRecord, + _shard_id: &str, + _table_name: &str, + ) -> BoxFuture<'_, Result<(), StorageError>> { + Box::pin(async move { + Err(StorageError::Internal( + "write_stream_record is not used in the Cassandra backend; \ + stream records are written atomically via LOGGED BATCH in each write path" + .to_owned(), + )) + }) + } + + fn get_stream_records( + &self, + account_id: &str, + shard_id: &str, + after_sequence: Option<&str>, + limit: i64, + ) -> BoxFuture<'_, Result<(Vec, Option), StorageError>> { + let account_id = account_id.to_string(); + let shard_id = shard_id.to_string(); + let after_sequence = after_sequence.map(str::to_string); + Box::pin(async move { + // Ownership guard: resolve the shard's owning account from the + // catalog and reject the request if it doesn't match the caller. + // Mirrors the PostgreSQL two-step check. Both "shard not found" and + // "shard belongs to a different account" collapse to the same + // `Invalid ShardIterator` error — consistent with real DynamoDB + // Streams behaviour (no information leakage about other accounts). + let keyspace = self + .account_keyspace_for_shard_owned_by(&shard_id, &account_id) + .await?; + + let query = if let Some(ref after) = after_sequence { + format!( + "SELECT record_data FROM {keyspace}.stream_records \ + WHERE shard_id = ? AND sequence_number > ? \ + LIMIT {limit}" + ) + } else { + format!( + "SELECT record_data FROM {keyspace}.stream_records \ + WHERE shard_id = ? \ + LIMIT {limit}" + ) + }; + + let result = if let Some(ref after) = after_sequence { + self.session + .query_with_values(&query, cdrs_tokio::query_values!(shard_id.as_str(), after.as_str())) + .await + } else { + self.session + .query_with_values(&query, cdrs_tokio::query_values!(shard_id.as_str())) + .await + } + .map_err(|e| StorageError::Internal(e.to_string()))?; + + use cdrs_tokio::types::IntoRustByName; + let rows = result + .response_body() + .map_err(|e| StorageError::Internal(e.to_string()))? + .into_rows() + .unwrap_or_default(); + + let records: Vec = rows + .into_iter() + .map(|row| { + let data: String = row + .get_r_by_name("record_data") + .map_err(|e| StorageError::Internal(e.to_string()))?; + serde_json::from_str(&data).map_err(|e| StorageError::Internal(e.to_string())) + }) + .collect::>()?; + + let last_seq = records.last().map(|r| r.dynamodb.sequence_number.clone()); + Ok((records, last_seq)) + }) + } + + fn describe_stream( + &self, + account_id: &str, + input: &extenddb_core::types::DescribeStreamInput, + ) -> BoxFuture<'_, Result> { + let account_id = account_id.to_string(); + let stream_arn_str = input.stream_arn.clone(); + let limit = input.limit; + let exclusive_start_shard_id = input.exclusive_start_shard_id.clone(); + Box::pin(async move { + let (table_name, stream_label) = parse_stream_arn(&stream_arn_str)?; + let catalog_keyspace = self.catalog_keyspace(); + let account_keyspace = self.account_keyspace_for(&account_id); + + // Fetch table metadata from catalog + let query = format!( + "SELECT key_schema, stream_specification, table_status, table_id \ + FROM {catalog_keyspace}.tables \ + WHERE account_id = ? AND table_name = ? AND stream_label = ? \ + ALLOW FILTERING" + ); + let result = self + .session + .query_with_values( + &query, + cdrs_tokio::query_values!( + account_id.as_str(), + table_name.as_str(), + stream_label.as_str() + ), + ) + .await + .map_err(|e| StorageError::Internal(e.to_string()))?; + + use cdrs_tokio::types::IntoRustByName; + let rows = result + .response_body() + .map_err(|e| StorageError::Internal(e.to_string()))? + .into_rows() + .unwrap_or_default(); + + let row = rows.into_iter().next().ok_or_else(|| { + StorageError::TableNotFound(format!( + "Requested resource not found: Stream: {stream_arn_str} not found." + )) + })?; + + let ks_json: String = row + .get_r_by_name("key_schema") + .map_err(|e| StorageError::Internal(e.to_string()))?; + let stream_spec_json: Option = row + .get_by_name("stream_specification") + .ok() + .flatten(); + let table_status: String = row + .get_r_by_name("table_status") + .map_err(|e| StorageError::Internal(e.to_string()))?; + let table_id: String = row + .get_r_by_name("table_id") + .map_err(|e| StorageError::Internal(e.to_string()))?; + + let key_schema = serde_json::from_str(&ks_json) + .map_err(|e| StorageError::Internal(e.to_string()))?; + + let stream_view_type = stream_spec_json + .and_then(|s| { + let v: serde_json::Value = serde_json::from_str(&s).ok()?; + serde_json::from_value(v["StreamViewType"].clone()).ok() + }) + .unwrap_or(StreamViewType::KeysOnly); + + // Fetch shards from account keyspace via secondary index on table_id + let limit_val = limit.unwrap_or(100); + let shard_query = if let Some(ref start) = exclusive_start_shard_id { + format!( + "SELECT shard_id, parent_shard_id, starting_sequence_number, ending_sequence_number \ + FROM {account_keyspace}.stream_shards \ + WHERE table_id = ? AND shard_id > ? \ + LIMIT {} ALLOW FILTERING", + limit_val + 1 + ) + } else { + format!( + "SELECT shard_id, parent_shard_id, starting_sequence_number, ending_sequence_number \ + FROM {account_keyspace}.stream_shards \ + WHERE table_id = ? \ + LIMIT {} ALLOW FILTERING", + limit_val + 1 + ) + }; + + let shard_result = if let Some(ref start) = exclusive_start_shard_id { + self.session + .query_with_values( + &shard_query, + cdrs_tokio::query_values!(table_id.as_str(), start.as_str()), + ) + .await + } else { + self.session + .query_with_values( + &shard_query, + cdrs_tokio::query_values!(table_id.as_str()), + ) + .await + } + .map_err(|e| StorageError::Internal(e.to_string()))?; + + let shard_rows = shard_result + .response_body() + .map_err(|e| StorageError::Internal(e.to_string()))? + .into_rows() + .unwrap_or_default(); + + #[allow(clippy::cast_sign_loss)] + let limit_usize = limit_val as usize; + let last_shard = if shard_rows.len() > limit_usize { + let row = &shard_rows[limit_usize - 1]; + let id: String = row.get_r_by_name("shard_id").map_err(|e| StorageError::Internal(e.to_string()))?; + Some(id) + } else { + None + }; + + let shards: Vec = shard_rows + .into_iter() + .take(limit_usize) + .map(|row| { + let shard_id: String = row.get_r_by_name("shard_id").map_err(|e| StorageError::Internal(e.to_string()))?; + let parent_shard_id: Option = row.get_by_name("parent_shard_id").ok().flatten(); + let starting: String = row.get_r_by_name("starting_sequence_number").map_err(|e| StorageError::Internal(e.to_string()))?; + let ending: Option = row.get_by_name("ending_sequence_number").ok().flatten(); + Ok(Shard { + shard_id, + parent_shard_id, + sequence_number_range: SequenceNumberRange { + starting_sequence_number: starting, + ending_sequence_number: ending, + }, + }) + }) + .collect::>()?; + + let stream_status = if table_status == "DELETING" { + StreamStatus::Disabling + } else { + StreamStatus::Enabled + }; + + Ok(StreamDescription { + stream_arn: stream_arn_str, + stream_label, + stream_status, + stream_view_type, + table_name, + key_schema, + shards, + last_evaluated_shard_id: last_shard, + }) + }) + } + + fn list_streams( + &self, + account_id: &str, + table_name: Option<&str>, + limit: i64, + exclusive_start_stream_arn: Option<&str>, + ) -> BoxFuture<'_, Result<(Vec, Option), StorageError>> { + let account_id = account_id.to_string(); + let table_name = table_name.map(str::to_string); + let exclusive_start_stream_arn = exclusive_start_stream_arn.map(str::to_string); + Box::pin(async move { + let catalog_keyspace = self.catalog_keyspace(); + + // Cassandra doesn't support IS NOT NULL filtering efficiently; fetch all and filter. + // For list_streams the result set is bounded by the number of tables per account. + let query = format!( + "SELECT table_name, stream_label FROM {catalog_keyspace}.tables \ + WHERE account_id = ? ALLOW FILTERING" + ); + let result = self + .session + .query_with_values(&query, cdrs_tokio::query_values!(account_id.as_str())) + .await + .map_err(|e| StorageError::Internal(e.to_string()))?; + + use cdrs_tokio::types::IntoRustByName; + let rows = result + .response_body() + .map_err(|e| StorageError::Internal(e.to_string()))? + .into_rows() + .unwrap_or_default(); + + // Parse start cursor + let start_cursor = exclusive_start_stream_arn + .as_deref() + .map(parse_stream_arn) + .transpose()?; + + let mut summaries: Vec = rows + .into_iter() + .filter_map(|row| { + let tn: String = row.get_r_by_name("table_name").ok()?; + let label: Option = row.get_by_name("stream_label").ok().flatten(); + let label = label?; // skip tables without streams + if let Some(ref filter_tn) = table_name { + if &tn != filter_tn { + return None; + } + } + Some(StreamSummary { + stream_arn: stream_arn(&self.region, &account_id, &tn, &label), + stream_label: label, + table_name: tn, + }) + }) + .collect(); + + // Sort for stable pagination (table_name, stream_label) + summaries.sort_by(|a, b| { + a.table_name.cmp(&b.table_name).then(a.stream_label.cmp(&b.stream_label)) + }); + + // Apply cursor + if let Some((start_table, start_label)) = start_cursor { + summaries.retain(|s| { + (&s.table_name, &s.stream_label) > (&start_table, &start_label) + }); + } + + #[allow(clippy::cast_sign_loss)] + let limit_usize = limit as usize; + let last_arn = if summaries.len() > limit_usize { + summaries.get(limit_usize - 1).map(|s| s.stream_arn.clone()) + } else { + None + }; + summaries.truncate(limit_usize); + + Ok((summaries, last_arn)) + }) + } + + /// No-op: Cassandra native TTL handles stream record expiry automatically. + fn cleanup_expired_stream_records( + &self, + _retention_hours: i64, + ) -> BoxFuture<'_, Result> { + Box::pin(async move { Ok(0) }) + } + + fn assign_shard( + &self, + _account_id: &str, + _table_name: &str, + partition_key: &str, + ) -> BoxFuture<'_, Result> { + // Pure computation — table_id is not available here, but the trait is used + // by the engine layer which resolves table_id before calling write paths. + // The write paths use stream_record_statement directly with table_id. + // This method is provided for completeness; callers should prefer the write-path injection. + let partition_key = partition_key.to_string(); + Box::pin(async move { + // Without table_id we cannot produce a fully-qualified shard_id. + // Return an error directing callers to use the write-path injection instead. + Err(StorageError::Internal(format!( + "assign_shard requires table_id; use stream_record_statement directly. pk={partition_key}" + ))) + }) + } + + fn next_sequence_number(&self, _shard_id: &str) -> BoxFuture<'_, Result> { + let seq = self.hlc.lock().unwrap_or_else(|e| e.into_inner()).generate(); + Box::pin(async move { Ok(seq) }) + } + + fn validate_shard( + &self, + account_id: &str, + stream_arn: &str, + shard_id: &str, + ) -> BoxFuture<'_, Result<(), StorageError>> { + let account_id = account_id.to_string(); + let stream_arn = stream_arn.to_string(); + let shard_id = shard_id.to_string(); + Box::pin(async move { + let (table_name, stream_label) = parse_stream_arn(&stream_arn)?; + let catalog_keyspace = self.catalog_keyspace(); + let account_keyspace = self.account_keyspace_for(&account_id); + + let query = format!( + "SELECT table_id FROM {catalog_keyspace}.tables \ + WHERE account_id = ? AND table_name = ? AND stream_label = ? \ + ALLOW FILTERING" + ); + let result = self + .session + .query_with_values( + &query, + cdrs_tokio::query_values!( + account_id.as_str(), + table_name.as_str(), + stream_label.as_str() + ), + ) + .await + .map_err(|e| StorageError::Internal(e.to_string()))?; + + use cdrs_tokio::types::IntoRustByName; + let rows = result + .response_body() + .map_err(|e| StorageError::Internal(e.to_string()))? + .into_rows() + .unwrap_or_default(); + + let table_id: String = rows + .into_iter() + .next() + .ok_or_else(|| { + StorageError::TableNotFound(format!( + "Requested resource not found: Stream: {stream_arn} not found." + )) + }) + .and_then(|row| { + row.get_r_by_name("table_id") + .map_err(|e| StorageError::Internal(e.to_string())) + })?; + + let shard_query = format!( + "SELECT shard_id FROM {account_keyspace}.stream_shards \ + WHERE shard_id = ? AND table_id = ? ALLOW FILTERING" + ); + let shard_result = self + .session + .query_with_values( + &shard_query, + cdrs_tokio::query_values!(shard_id.as_str(), table_id.as_str()), + ) + .await + .map_err(|e| StorageError::Internal(e.to_string()))?; + + let found = shard_result + .response_body() + .map_err(|e| StorageError::Internal(e.to_string()))? + .into_rows() + .map(|r| !r.is_empty()) + .unwrap_or(false); + + if !found { + return Err(StorageError::TableNotFound(format!( + "Requested resource not found: Stream: {stream_arn} not found." + ))); + } + Ok(()) + }) + } + + fn latest_sequence_number( + &self, + shard_id: &str, + ) -> BoxFuture<'_, Result, StorageError>> { + let shard_id = shard_id.to_string(); + Box::pin(async move { + let keyspace = self.account_keyspace_for_shard(&shard_id).await?; + let query = format!( + "SELECT sequence_number FROM {keyspace}.stream_records \ + WHERE shard_id = ? ORDER BY sequence_number DESC LIMIT 1" + ); + let result = self + .session + .query_with_values(&query, cdrs_tokio::query_values!(shard_id.as_str())) + .await + .map_err(|e| StorageError::Internal(e.to_string()))?; + + use cdrs_tokio::types::IntoRustByName; + let rows = result + .response_body() + .map_err(|e| StorageError::Internal(e.to_string()))? + .into_rows() + .unwrap_or_default(); + + Ok(rows.into_iter().next().map(|row| { + row.get_r_by_name("sequence_number").unwrap_or_default() + })) + }) + } +} diff --git a/crates/storage-cassandra/src/stream_util.rs b/crates/storage-cassandra/src/stream_util.rs new file mode 100644 index 00000000..4f49341b --- /dev/null +++ b/crates/storage-cassandra/src/stream_util.rs @@ -0,0 +1,274 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Stream utilities: Hybrid Logical Clock sequence number generation and +//! stream record batch statement construction. + +use std::sync::{Arc, Mutex}; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64; +use extenddb_core::types::{ + AttributeValue, Item, StreamEventName, StreamRecord, StreamRecordData, StreamViewType, + TableKeyInfo, item_size_bytes, +}; +use extenddb_storage::StreamCapture; + +/// Number of fixed shards per stream (matches PostgreSQL reference implementation). +pub const SHARDS_PER_STREAM: u32 = 4; + +/// Hybrid Logical Clock for generating ordered, unique stream sequence numbers. +/// +/// Format: `{timestamp_ms:013}{counter:06}{node_id:04}` = 23 digits. +/// This is within DynamoDB's 40-character sequence number limit. +/// +/// Node ID is derived from `crc32(hostname:contact_point) % 9999 + 1` at +/// startup, ensuring uniqueness across instances on the same host (different +/// ports) and in containerized environments. +pub struct HybridClock { + last_timestamp_ms: i64, + logical_counter: u32, + node_id: u16, +} + +impl HybridClock { + /// Create a new HybridClock with the given node ID. + pub fn new(node_id: u16) -> Self { + Self { + last_timestamp_ms: 0, + logical_counter: 0, + node_id, + } + } + + /// Derive a stable node ID (in the range 1..=9999) from an ExtendDB instance identifier. + /// + /// Instance identifiers (e.g. hostname + listening address) ensure that multiple ExtendDB + /// instances, whether on the same or different hosts, get distinct node IDs, in turn allowing + /// tie-breaking for conflicting clock values. + pub fn derive_node_id(instance_id: &str) -> u16 { + let hash = crc32fast::hash(instance_id.as_bytes()); + u16::try_from(hash % 9999 + 1).unwrap_or(1) + } + + /// Generate the next clock value, used a sequence number for stream records. + /// + /// Returns a 23-digit zero-padded string. Lexicographic ordering matches + /// temporal ordering. + pub fn generate(&mut self) -> String { + let now_ms = chrono::Utc::now().timestamp_millis(); + + if now_ms > self.last_timestamp_ms { + self.last_timestamp_ms = now_ms; + self.logical_counter = 0; + } else { + if now_ms < self.last_timestamp_ms { + // Clock went backwards — clamp and increment counter. + tracing::warn!( + delta_ms = self.last_timestamp_ms - now_ms, + "HybridClock: system clock went backwards; clamping to last timestamp" + ); + } + self.logical_counter += 1; + // Counter exhausted: wait for next millisecond. + // Requires ~1B writes/sec/node — will never occur in practice. + if self.logical_counter > 999_999 { + std::thread::sleep(std::time::Duration::from_millis(1)); + self.last_timestamp_ms = chrono::Utc::now().timestamp_millis(); + self.logical_counter = 0; + } + } + + format!( + "{:013}{:06}{:04}", + self.last_timestamp_ms, self.logical_counter, self.node_id + ) + } +} + +/// Thread-safe HybridClock handle. +pub type SharedHlc = Arc>; + +/// Create a new SharedHlc, deriving node ID from ExtendDB's instance ID. +pub fn new_shared_hlc(instance_id: &str) -> SharedHlc { + let node_id = HybridClock::derive_node_id(instance_id); + tracing::info!(node_id, instance_id, "HybridClock initialized"); + Arc::new(Mutex::new(HybridClock::new(node_id))) +} + +/// Compute the shard ID for a given partition key and table ID. +/// +/// Uses CRC32 hash modulo shard count, matching the PostgreSQL reference +/// implementation. Uses `table_id` (UUID) rather than `table_name` to +/// prevent shard ID collisions after table deletion and recreation. +pub fn assign_shard_id(partition_key: &str, table_id: &str) -> String { + let hash = crc32fast::hash(partition_key.as_bytes()); + let idx = (hash as usize) % SHARDS_PER_STREAM as usize; + format!("shardId-{}-{:012}", table_id, idx) +} + +/// Zero sequence number used as the starting point for new shards. +pub const ZERO_SEQUENCE: &str = "00000000000000000000000"; // 23 zeros + +/// Build a CQL INSERT statement for a stream record to be included in a LOGGED BATCH. +/// +/// Returns `None` if no record should be written (both old and new items are absent, +/// i.e. a delete of a non-existent item). +/// +/// The returned string uses string-interpolated values (not `?` placeholders) because +/// cdrs-tokio's `BatchQueryBuilder` requires all statements in a batch to share the +/// same bind parameter count. All values are server-generated or sanitized — there is +/// no user-controlled input in the interpolated fields. +/// +/// # Arguments +/// - `account_keyspace` — the per-account Cassandra keyspace +/// - `table_id` — UUID of the table (used for shard assignment) +/// - `key_info` — key schema and attribute definitions for the table +/// - `old_item` — item state before the write (None for inserts) +/// - `new_item` — item state after the write (None for deletes) +/// - `capture` — stream view type and region from the caller +/// - `hlc` — shared HLC for sequence number generation +/// - `retention_seconds` — Cassandra TTL for the stream record +pub fn stream_record_statement( + account_keyspace: &str, + table_id: &str, + key_info: &TableKeyInfo, + old_item: Option<&Item>, + new_item: Option<&Item>, + capture: &StreamCapture, + hlc: &Arc>, + retention_seconds: u32, +) -> Option { + let source = new_item.or(old_item)?; + + let event = match (old_item, new_item) { + (None, Some(_)) => StreamEventName::Insert, + (Some(_), Some(_)) => StreamEventName::Modify, + (Some(_), None) => StreamEventName::Remove, + (None, None) => return None, + }; + + let keys: std::collections::BTreeMap = key_info + .key_schema + .iter() + .filter_map(|ks| { + source + .get(&ks.attribute_name) + .map(|v| (ks.attribute_name.clone(), v.clone())) + }) + .collect(); + + let new_image = match capture.view_type { + StreamViewType::NewImage | StreamViewType::NewAndOldImages => new_item.cloned(), + _ => None, + }; + let old_image = match capture.view_type { + StreamViewType::OldImage | StreamViewType::NewAndOldImages => old_item.cloned(), + _ => None, + }; + + let size = i64::try_from(item_size_bytes(source)).unwrap_or(i64::MAX); + + let pk_name = &key_info.key_schema[0].attribute_name; + let pk_str = source + .get(pk_name) + .map(|v| match v { + AttributeValue::S(s) => s.clone(), + AttributeValue::N(n) => n.clone(), + AttributeValue::B(b) => BASE64.encode(b), + _ => String::new(), + }) + .unwrap_or_default(); + + let shard_id = assign_shard_id(&pk_str, table_id); + let sequence_number = hlc.lock().unwrap_or_else(|e| e.into_inner()).generate(); + + let record = StreamRecord { + event_id: uuid::Uuid::new_v4().to_string(), + event_name: event, + event_version: "1.1".to_owned(), + event_source: "aws:dynamodb".to_owned(), + aws_region: capture.region.to_string(), + dynamodb: StreamRecordData { + approximate_creation_date_time: chrono::Utc::now().timestamp(), + keys, + new_image, + old_image, + sequence_number, + size_bytes: size, + stream_view_type: capture.view_type, + }, + user_identity: capture.user_identity.clone(), + }; + + let record_json = serde_json::to_string(&record).ok()?; + let event_name = format!("{:?}", record.event_name); + let seq = &record.dynamodb.sequence_number; + let now_ms = chrono::Utc::now().timestamp_millis(); + + Some(format!( + "INSERT INTO {account_keyspace}.stream_records \ + (shard_id, sequence_number, table_id, event_name, record_data, created_at) \ + VALUES ('{shard_id}', '{seq}', '{table_id}', '{event_name}', \ + '{record_json_escaped}', {now_ms}) \ + USING TTL {retention_seconds}", + record_json_escaped = record_json.replace('\'', "''"), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sequence_ordering() { + let mut clock = HybridClock::new(1); + let a = clock.generate(); + let b = clock.generate(); + let c = clock.generate(); + assert!(a < b, "sequence numbers must be ordered: {a} < {b}"); + assert!(b < c, "sequence numbers must be ordered: {b} < {c}"); + } + + #[test] + fn test_sequence_length() { + let mut clock = HybridClock::new(1); + let seq = clock.generate(); + assert_eq!(seq.len(), 23, "sequence number must be 23 digits: {seq}"); + } + + #[test] + fn test_sequence_numeric() { + let mut clock = HybridClock::new(9999); + let seq = clock.generate(); + assert!(seq.chars().all(|c| c.is_ascii_digit()), "sequence must be numeric: {seq}"); + } + + #[test] + fn test_node_id_range() { + let id = HybridClock::derive_node_id("localhost:9042"); + assert!((1..=9999).contains(&id), "node_id must be in 1..=9999, got {id}"); + } + + #[test] + fn test_different_contact_points_give_different_ids() { + let id1 = HybridClock::derive_node_id("localhost:18443"); + let id2 = HybridClock::derive_node_id("localhost:18444"); + // Not guaranteed to differ (hash collision possible) but overwhelmingly likely. + // This test documents the intent rather than asserting strict inequality. + let _ = (id1, id2); + } + + #[test] + fn test_assign_shard_id_stable() { + let s1 = assign_shard_id("user-123", "table-uuid-abc"); + let s2 = assign_shard_id("user-123", "table-uuid-abc"); + assert_eq!(s1, s2, "shard assignment must be deterministic"); + } + + #[test] + fn test_assign_shard_id_format() { + let s = assign_shard_id("pk", "my-table-id"); + assert!(s.starts_with("shardId-my-table-id-"), "unexpected format: {s}"); + } +} diff --git a/crates/storage-cassandra/src/table_engine.rs b/crates/storage-cassandra/src/table_engine.rs new file mode 100644 index 00000000..ca186d49 --- /dev/null +++ b/crates/storage-cassandra/src/table_engine.rs @@ -0,0 +1,195 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! `TableEngine` trait implementation for `CassandraEngine`. + +use cdrs_tokio::types::IntoRustByName; +use futures::future::BoxFuture; + +use extenddb_core::types::{ + CreateTableInput, DeleteTableInput, DescribeTableInput, IndexInfo, ListTablesInput, + ListTablesOutput, TableDescription, TableKeyInfo, UpdateTableInput, +}; +use extenddb_storage::TableEngine; +use extenddb_storage::error::StorageError; + +use crate::CassandraEngine; + +impl TableEngine for CassandraEngine { + fn create_table( + &self, + account_id: &str, + input: CreateTableInput, + ) -> BoxFuture<'_, Result> { + let account_id = account_id.to_string(); + Box::pin(async move { self.create_table_impl(&account_id, input).await }) + } + + fn delete_table( + &self, + account_id: &str, + input: DeleteTableInput, + ) -> BoxFuture<'_, Result> { + let account_id = account_id.to_string(); + Box::pin(async move { self.delete_table_impl(&account_id, input).await }) + } + + fn describe_table( + &self, + account_id: &str, + input: DescribeTableInput, + ) -> BoxFuture<'_, Result> { + let account_id = account_id.to_string(); + Box::pin(async move { + self.build_table_description(&account_id, &input.table_name) + .await + }) + } + + fn list_tables( + &self, + account_id: &str, + input: ListTablesInput, + ) -> BoxFuture<'_, Result> { + let account_id = account_id.to_string(); + Box::pin(async move { + let catalog_keyspace = self.catalog_keyspace(); + let limit = input.limit.unwrap_or(100); + + // Fetch limit + 1 to determine if there are more results + let query = if let Some(ref _start) = input.exclusive_start_table_name { + format!( + "SELECT table_name FROM {}.tables WHERE account_id = ? AND table_name > ? ORDER BY table_name LIMIT ?", + catalog_keyspace + ) + } else { + format!( + "SELECT table_name FROM {}.tables WHERE account_id = ? ORDER BY table_name LIMIT ?", + catalog_keyspace + ) + }; + + let result = if let Some(ref start) = input.exclusive_start_table_name { + self.session + .query_with_values( + &query, + cdrs_tokio::query_values!(account_id.as_str(), start.as_str(), limit + 1), + ) + .await + } else { + self.session + .query_with_values( + &query, + cdrs_tokio::query_values!(account_id.as_str(), limit + 1), + ) + .await + }; + + let response = + result.map_err(|e| StorageError::Internal(format!("Query tables: {}", e)))?; + let body = response + .response_body() + .map_err(|e| StorageError::Internal(e.to_string()))?; + let rows = body.into_rows().unwrap_or_default(); + + let mut names: Vec = rows + .iter() + .filter_map(|row| row.get_r_by_name("table_name").ok()) + .collect(); + + let last_evaluated_table_name = if names.len() > limit as usize { + names.pop() + } else { + None + }; + + Ok(ListTablesOutput { + table_names: names, + last_evaluated_table_name, + }) + }) + } + + fn update_table( + &self, + account_id: &str, + input: UpdateTableInput, + ) -> BoxFuture<'_, Result> { + let account_id = account_id.to_owned(); + Box::pin(async move { self.update_table_impl(&account_id, input).await }) + } + + fn table_key_info( + &self, + account_id: &str, + table_name: &str, + ) -> BoxFuture<'_, Result> { + let account_id = account_id.to_string(); + let table_name = table_name.to_string(); + Box::pin(async move { self.fetch_table_key_info(&account_id, &table_name).await }) + } + + fn index_info( + &self, + account_id: &str, + table_name: &str, + index_name: &str, + ) -> BoxFuture<'_, Result> { + let account_id = account_id.to_string(); + let table_name = table_name.to_string(); + let index_name = index_name.to_string(); + Box::pin(async move { + // Resolve the table_id from the catalog, then delegate to the + // table_id-based lookup (the hot path used by query/scan). + let key_info = self.fetch_table_key_info(&account_id, &table_name).await?; + self.index_info_by_id(&key_info.table_id, &index_name).await + }) + } + + fn index_info_by_table_id( + &self, + table_id: &str, + index_name: &str, + ) -> BoxFuture<'_, Result> { + let table_id = table_id.to_string(); + let index_name = index_name.to_string(); + Box::pin(async move { self.index_info_by_id(&table_id, &index_name).await }) + } +} + +impl CassandraEngine { + /// Fetch index metadata as core `IndexInfo` for query/scan routing and + /// `ExclusiveStartKey` validation. Shared by `index_info` and + /// `index_info_by_table_id`. + async fn index_info_by_id( + &self, + table_id: &str, + index_name: &str, + ) -> Result { + let meta = crate::data::index::fetch_index_by_name( + table_id, + index_name, + &self.session_arc(), + &self.catalog_keyspace(), + ) + .await?; + + let index_type = match meta.index_type.as_str() { + "GSI" => extenddb_core::types::IndexType::Gsi, + "LSI" => extenddb_core::types::IndexType::Lsi, + other => { + return Err(StorageError::Internal(format!( + "unknown index type in database: {other}" + ))); + } + }; + + Ok(IndexInfo { + index_name: meta.index_name, + index_id: meta.index_id, + index_type, + key_schema: meta.key_schema, + projection: meta.projection, + }) + } +} diff --git a/crates/storage-cassandra/src/table_helpers.rs b/crates/storage-cassandra/src/table_helpers.rs new file mode 100644 index 00000000..0a26a852 --- /dev/null +++ b/crates/storage-cassandra/src/table_helpers.rs @@ -0,0 +1,266 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Helper methods for `TableEngine` operations. + +use cdrs_tokio::types::{IntoRustByName, rows::Row}; +use extenddb_core::types::{ + BillingMode, BillingModeSummary, GsiDescription, LsiDescription, + ProvisionedThroughputDescription, TableDescription, TableStatus, +}; +use extenddb_storage::error::StorageError; +use extenddb_storage::util::{index_arn, stream_arn}; + +use crate::CassandraEngine; + +impl CassandraEngine { + // TODO(fidelity): These two queries are not in a transaction. Under concurrent + // UpdateTable (future phase), the table row and index rows could be read at + // different points in time, producing an inconsistent snapshot. Cassandra + // doesn't have SELECT ... FOR SHARE, so we'd need application-level locking + // or accept eventual consistency. + pub(crate) async fn build_table_description( + &self, + account_id: &str, + table_name: &str, + ) -> Result { + let catalog_keyspace = self.catalog_keyspace(); + + // Query table metadata + let table_query = format!( + "SELECT * FROM {}.tables WHERE account_id = ? AND table_name = ?", + catalog_keyspace + ); + + let table_result = self + .session + .query_with_values( + &table_query, + cdrs_tokio::query_values!(account_id, table_name), + ) + .await + .map_err(|e| StorageError::Internal(format!("Query tables failed: {}", e)))?; + + let table_body = table_result + .response_body() + .map_err(|e| StorageError::Internal(e.to_string()))?; + + let table_rows = table_body + .into_rows() + .ok_or_else(|| StorageError::TableNotFound(table_name.to_owned()))?; + + let table_row = table_rows + .first() + .ok_or_else(|| StorageError::TableNotFound(table_name.to_owned()))?; + + // Extract table_id for index query + let table_id: String = table_row + .get_r_by_name("table_id") + .map_err(|e| StorageError::Internal(format!("Parse table_id: {}", e)))?; + + // Query indexes + let index_query = format!( + "SELECT * FROM {}.indexes WHERE table_id = ?", + catalog_keyspace + ); + + let index_result = self + .session + .query_with_values(&index_query, cdrs_tokio::query_values!(table_id.as_str())) + .await + .map_err(|e| StorageError::Internal(format!("Query indexes failed: {}", e)))?; + + let index_body = index_result + .response_body() + .map_err(|e| StorageError::Internal(e.to_string()))?; + + let index_rows = index_body.into_rows().unwrap_or_default(); + + // Delegate to builder + self.build_table_description_from_row(account_id, table_row, index_rows) + } + + pub(crate) fn build_table_description_from_row( + &self, + account_id: &str, + table_row: &Row, + index_rows: Vec, + ) -> Result { + // Parse table fields + let table_name: String = table_row + .get_r_by_name("table_name") + .map_err(|e| StorageError::Internal(format!("Parse table_name: {}", e)))?; + + let key_schema_str: String = table_row + .get_r_by_name("key_schema") + .map_err(|e| StorageError::Internal(format!("Parse key_schema: {}", e)))?; + let key_schema = serde_json::from_str(&key_schema_str) + .map_err(|e| StorageError::Internal(e.to_string()))?; + + let attr_defs_str: String = table_row + .get_r_by_name("attribute_definitions") + .map_err(|e| StorageError::Internal(format!("Parse attribute_definitions: {}", e)))?; + let attr_defs = serde_json::from_str(&attr_defs_str) + .map_err(|e| StorageError::Internal(e.to_string()))?; + + let billing_mode_str: String = table_row + .get_r_by_name("billing_mode") + .map_err(|e| StorageError::Internal(format!("Parse billing_mode: {}", e)))?; + + let pt_str: Option = table_row.get_r_by_name("provisioned_throughput").ok(); + let (rcu, wcu) = if let Some(ref s) = pt_str { + let pt: extenddb_core::types::ProvisionedThroughput = + serde_json::from_str(s).map_err(|e| StorageError::Internal(e.to_string()))?; + (pt.read_capacity_units, pt.write_capacity_units) + } else { + (0, 0) + }; + + let stream_spec_str: Option = table_row.get_r_by_name("stream_specification").ok(); + let stream_specification = stream_spec_str + .as_ref() + .and_then(|s| serde_json::from_str(s).ok()); + + let table_status_str: String = table_row + .get_r_by_name("table_status") + .map_err(|e| StorageError::Internal(format!("Parse table_status: {}", e)))?; + let table_status = match table_status_str.as_str() { + "ACTIVE" => TableStatus::Active, + "CREATING" => TableStatus::Creating, + "DELETING" => TableStatus::Deleting, + "UPDATING" => TableStatus::Updating, + other => { + return Err(StorageError::Internal(format!( + "unknown table status in database: {}", + other + ))); + } + }; + + let creation_timestamp: i64 = table_row + .get_r_by_name("created_at") + .map_err(|e| StorageError::Internal(format!("Parse created_at: {}", e)))?; + let creation_epoch = creation_timestamp as f64 / 1000.0; + + let table_size_bytes: i64 = table_row.get_r_by_name("table_size_bytes").unwrap_or(0); + let item_count: i64 = table_row.get_r_by_name("item_count").unwrap_or(0); + + let table_arn: String = table_row + .get_r_by_name("table_arn") + .map_err(|e| StorageError::Internal(format!("Parse table_arn: {}", e)))?; + let table_id: String = table_row + .get_r_by_name("table_id") + .map_err(|e| StorageError::Internal(format!("Parse table_id: {}", e)))?; + + let deletion_protection_enabled: bool = table_row + .get_r_by_name("deletion_protection_enabled") + .unwrap_or(false); + + let stream_label: Option = table_row.get_r_by_name("stream_label").ok(); + + let table_class: Option = table_row.get_by_name("table_class").ok().flatten(); + let on_demand_throughput: Option = table_row + .get_by_name("on_demand_throughput") + .ok() + .flatten() + .and_then(|s: String| serde_json::from_str(&s).ok()); + + // Build GSI/LSI descriptions + let mut gsis: Vec = Vec::new(); + let mut lsis: Vec = Vec::new(); + + for row in index_rows { + let index_name: String = row + .get_r_by_name("index_name") + .map_err(|e| StorageError::Internal(format!("Parse index_name: {}", e)))?; + let index_type: String = row + .get_r_by_name("index_type") + .map_err(|e| StorageError::Internal(format!("Parse index_type: {}", e)))?; + + let ks_str: String = row + .get_r_by_name("key_schema") + .map_err(|e| StorageError::Internal(format!("Parse key_schema: {}", e)))?; + let ks = + serde_json::from_str(&ks_str).map_err(|e| StorageError::Internal(e.to_string()))?; + + let proj_str: String = row + .get_r_by_name("projection") + .map_err(|e| StorageError::Internal(format!("Parse projection: {}", e)))?; + let proj = serde_json::from_str(&proj_str) + .map_err(|e| StorageError::Internal(e.to_string()))?; + + let index_status: String = row + .get_r_by_name("index_status") + .map_err(|e| StorageError::Internal(format!("Parse index_status: {}", e)))?; + + if index_type == "GSI" { + let pt_str: Option = row.get_r_by_name("provisioned_throughput").ok(); + let pt = pt_str.as_ref().and_then(|s| serde_json::from_str(s).ok()); + + gsis.push(GsiDescription { + index_name: index_name.clone(), + key_schema: ks, + projection: proj, + index_status, + provisioned_throughput: pt, + index_size_bytes: 0, + item_count: 0, + index_arn: index_arn(&self.region, account_id, &table_name, &index_name), + }); + } else { + lsis.push(LsiDescription { + index_name: index_name.clone(), + key_schema: ks, + projection: proj, + index_size_bytes: 0, + item_count: 0, + index_arn: index_arn(&self.region, account_id, &table_name, &index_name), + }); + } + } + + let billing_mode_summary = if billing_mode_str == "PAY_PER_REQUEST" { + Some(BillingModeSummary { + billing_mode: BillingMode::PayPerRequest, + last_update_to_pay_per_request_date_time: Some(creation_epoch), + }) + } else { + None + }; + + let latest_stream_arn = stream_label + .as_ref() + .map(|label| stream_arn(&self.region, account_id, &table_name, label)); + + Ok(TableDescription { + table_name, + key_schema, + attribute_definitions: attr_defs, + table_status, + creation_date_time: creation_epoch, + table_size_bytes, + item_count, + table_arn, + table_id, + provisioned_throughput: ProvisionedThroughputDescription { + read_capacity_units: rcu, + write_capacity_units: wcu, + number_of_decreases_today: 0, + last_increase_date_time: None, + last_decrease_date_time: None, + }, + billing_mode_summary, + global_secondary_indexes: if gsis.is_empty() { None } else { Some(gsis) }, + local_secondary_indexes: if lsis.is_empty() { None } else { Some(lsis) }, + stream_specification, + latest_stream_arn, + latest_stream_label: stream_label, + deletion_protection_enabled, + sse_description: None, + table_class_summary: table_class.map(|tc| serde_json::json!({"TableClass": tc})), + on_demand_throughput, + restore_summary: None, + vector_indexes: None, + }) + } +} diff --git a/crates/storage-cassandra/src/update_table.rs b/crates/storage-cassandra/src/update_table.rs new file mode 100644 index 00000000..b14c12fc --- /dev/null +++ b/crates/storage-cassandra/src/update_table.rs @@ -0,0 +1,363 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! `update_table` implementation for the Cassandra backend. + +use cdrs_tokio::consistency::Consistency; +use cdrs_tokio::query::BatchQueryBuilder; +use cdrs_tokio::query::QueryValues; +use cdrs_tokio::types::IntoRustByName; +use cdrs_tokio::types::value::Value; +use extenddb_core::types::{BillingMode, TableDescription, UpdateTableInput}; +use extenddb_storage::error::StorageError; + +use crate::CassandraEngine; +use crate::cassandra_util::query_optional; + +impl CassandraEngine { + pub(crate) async fn update_table_impl( + &self, + account_id: &str, + input: UpdateTableInput, + ) -> Result { + let catalog_ks = self.catalog_keyspace(); + + // Fetch table_id, status, key_schema, attribute_definitions. + let row = query_optional( + &self.session, + &format!( + "SELECT table_id, table_status, key_schema, attribute_definitions, \ + billing_mode, provisioned_throughput \ + FROM {catalog_ks}.tables WHERE account_id = ? AND table_name = ?" + ), + cdrs_tokio::query_values!(account_id, input.table_name.as_str()), + "update_table", + ) + .await? + .ok_or_else(|| StorageError::TableNotFound(input.table_name.clone()))?; + + let status: String = + crate::cassandra_util::get_column(&row, "table_status", "update_table")?; + if status != "ACTIVE" { + return Err(StorageError::TableNotActive(input.table_name.clone())); + } + let table_id: String = + crate::cassandra_util::get_column(&row, "table_id", "update_table")?; + let ks_json: String = + crate::cassandra_util::get_column(&row, "key_schema", "update_table")?; + let ad_json: String = + crate::cassandra_util::get_column(&row, "attribute_definitions", "update_table")?; + + // No-op rejection: PROVISIONED billing with identical throughput values. + if matches!(input.billing_mode, Some(BillingMode::Provisioned)) + && let Some(ref pt) = input.provisioned_throughput + { + let current_bm: Option = row.get_by_name("billing_mode").ok().flatten(); + let current_pt_str: Option = + row.get_by_name("provisioned_throughput").ok().flatten(); + let is_provisioned = + current_bm.as_deref() == Some("PROVISIONED") || current_bm.is_none(); + if is_provisioned { + let (cur_rcu, cur_wcu) = current_pt_str + .and_then(|s| serde_json::from_str::(&s).ok()) + .map(|v| { + let rcu = v.get("ReadCapacityUnits").and_then(|x| x.as_i64()).unwrap_or(0); + let wcu = v.get("WriteCapacityUnits").and_then(|x| x.as_i64()).unwrap_or(0); + (rcu, wcu) + }) + .unwrap_or((0, 0)); + if cur_rcu == pt.read_capacity_units && cur_wcu == pt.write_capacity_units { + return Err(StorageError::NoOpUpdate(format!( + "The provisioned throughput for the table will not change. \ + The requested value equals the current value. \ + Current ReadCapacityUnits provisioned for the table: {}. \ + Requested ReadCapacityUnits: {}. \ + Current WriteCapacityUnits provisioned for the table: {}. \ + Requested WriteCapacityUnits: {}.", + cur_rcu, pt.read_capacity_units, cur_wcu, pt.write_capacity_units + ))); + } + } + } + + // Build a LOGGED BATCH for all catalog column updates on `tables`. + // All statements touch the same partition (account_id, table_name) so + // they are atomic. Reads (no-op check, shard existence) happen before + // this batch; DDL (CREATE/DROP TABLE for GSIs) happens after. + let mut batch = BatchQueryBuilder::new().with_consistency(Consistency::LocalQuorum); + let mut batch_has_statements = false; + + macro_rules! add_update { + ($col:expr, $val:expr) => {{ + batch = batch.add_query( + format!( + "UPDATE {catalog_ks}.tables SET {} = ? \ + WHERE account_id = ? AND table_name = ?", + $col + ), + QueryValues::SimpleValues(vec![ + Value::from($val), + Value::from(account_id), + Value::from(input.table_name.as_str()), + ]), + ); + batch_has_statements = true; + }}; + } + + if let Some(bm) = &input.billing_mode { + let bm_str = match bm { + BillingMode::Provisioned => "PROVISIONED", + BillingMode::PayPerRequest => "PAY_PER_REQUEST", + }; + add_update!("billing_mode", bm_str); + } + + if let Some(pt) = &input.provisioned_throughput { + let pt_json = serde_json::to_string(pt) + .map_err(|e| StorageError::Internal(e.to_string()))?; + add_update!("provisioned_throughput", pt_json.as_str()); + } + + if let Some(dp) = input.deletion_protection_enabled { + add_update!("deletion_protection_enabled", dp); + } + + if let Some(tc) = &input.table_class { + add_update!("table_class", tc.as_str()); + } + + if let Some(odt) = &input.on_demand_throughput { + let odt_json = serde_json::to_string(odt) + .map_err(|e| StorageError::Internal(e.to_string()))?; + add_update!("on_demand_throughput", odt_json.as_str()); + } + + // Stream specification: add to batch, then handle shard init separately + // (init_stream_shards writes to a different keyspace and does its own batch). + let mut needs_shard_init = false; + let mut needs_label_restore = false; + if let Some(spec) = &input.stream_specification { + let spec_json = serde_json::to_string(spec) + .map_err(|e| StorageError::Internal(e.to_string()))?; + add_update!("stream_specification", spec_json.as_str()); + + if spec.stream_enabled { + let account_ks = self.account_keyspace(account_id); + let existing = query_optional( + &self.session, + &format!( + "SELECT shard_id FROM {account_ks}.stream_shards \ + WHERE table_id = ? LIMIT 1 ALLOW FILTERING" + ), + cdrs_tokio::query_values!(table_id.as_str()), + "update_table stream_shards check", + ) + .await?; + + if existing.is_none() { + needs_shard_init = true; + } else { + // Re-enabling: check if stream_label needs restoring. + let label_row = query_optional( + &self.session, + &format!( + "SELECT stream_label FROM {catalog_ks}.tables \ + WHERE account_id = ? AND table_name = ?" + ), + cdrs_tokio::query_values!(account_id, input.table_name.as_str()), + "update_table stream_label check", + ) + .await?; + let has_label = label_row + .and_then(|r| { + let v: Option = r.get_by_name("stream_label").ok().flatten(); + Some(v.is_some()) + }) + .unwrap_or(false); + if !has_label { + needs_label_restore = true; + let label = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S").to_string(); + add_update!("stream_label", label.as_str()); + } + } + } + } + + // GSI updates: pre-validate, then add catalog changes to batch. + let base_key_schema: Vec; + let base_attr_defs: Vec; + let mut gsi_creates: Vec<(String, String)> = Vec::new(); // (index_id, index_name) + let mut gsi_deletes: Vec = Vec::new(); // index_names + + if let Some(updates) = &input.global_secondary_index_updates { + base_key_schema = serde_json::from_str(&ks_json) + .map_err(|e| StorageError::Internal(e.to_string()))?; + base_attr_defs = serde_json::from_str(&ad_json) + .map_err(|e| StorageError::Internal(e.to_string()))?; + let effective_attr_defs = input + .attribute_definitions + .as_deref() + .unwrap_or(&base_attr_defs); + + if let Some(new_ad) = &input.attribute_definitions { + let new_ad_json = serde_json::to_string(new_ad) + .map_err(|e| StorageError::Internal(e.to_string()))?; + add_update!("attribute_definitions", new_ad_json.as_str()); + } + + for update in updates { + if let Some(create) = &update.create { + // Reject duplicate index name. + let existing = query_optional( + &self.session, + &format!( + "SELECT index_name FROM {catalog_ks}.indexes \ + WHERE table_id = ? AND index_name = ? ALLOW FILTERING" + ), + cdrs_tokio::query_values!( + table_id.as_str(), + create.index_name.as_str() + ), + "update_table gsi duplicate check", + ) + .await?; + if existing.is_some() { + return Err(StorageError::IndexAlreadyExists(create.index_name.clone())); + } + + let index_id = uuid::Uuid::new_v4().to_string(); + let idx_ks_json = serde_json::to_string(&create.key_schema) + .map_err(|e| StorageError::Internal(e.to_string()))?; + let proj_json = serde_json::to_string(&create.projection) + .map_err(|e| StorageError::Internal(e.to_string()))?; + let pt_json = create + .provisioned_throughput + .as_ref() + .map(|pt| serde_json::to_string(pt)) + .transpose() + .map_err(|e| StorageError::Internal(e.to_string()))? + .unwrap_or_default(); + + batch = batch.add_query( + format!( + "INSERT INTO {catalog_ks}.indexes \ + (table_id, index_name, index_id, index_type, key_schema, \ + projection, index_status, provisioned_throughput) \ + VALUES (?, ?, ?, 'GSI', ?, ?, 'ACTIVE', ?)" + ), + QueryValues::SimpleValues(vec![ + Value::from(table_id.as_str()), + Value::from(create.index_name.as_str()), + Value::from(index_id.as_str()), + Value::from(idx_ks_json.as_str()), + Value::from(proj_json.as_str()), + Value::from(pt_json.as_str()), + ]), + ); + batch_has_statements = true; + gsi_creates.push((index_id, create.index_name.clone())); + + // Create the data table after the batch commits (DDL can't be batched). + // Store what we need for post-batch DDL. + let _ = (effective_attr_defs, &base_key_schema, &base_attr_defs); + } + + if let Some(delete) = &update.delete { + // Verify index exists before adding delete to batch. + let existing = query_optional( + &self.session, + &format!( + "SELECT index_id FROM {catalog_ks}.indexes \ + WHERE table_id = ? AND index_name = ? ALLOW FILTERING" + ), + cdrs_tokio::query_values!( + table_id.as_str(), + delete.index_name.as_str() + ), + "update_table gsi delete check", + ) + .await? + .ok_or_else(|| StorageError::IndexNotFound(delete.index_name.clone()))?; + let index_id: String = + crate::cassandra_util::get_column(&existing, "index_id", "update_table gsi delete")?; + + batch = batch.add_query( + format!( + "DELETE FROM {catalog_ks}.indexes \ + WHERE table_id = ? AND index_name = ?" + ), + QueryValues::SimpleValues(vec![ + Value::from(table_id.as_str()), + Value::from(delete.index_name.as_str()), + ]), + ); + batch_has_statements = true; + gsi_deletes.push(index_id); + } + } + } else { + base_key_schema = Vec::new(); + base_attr_defs = Vec::new(); + } + + // Execute the catalog batch atomically. + if batch_has_statements { + self.session + .batch( + batch + .build() + .map_err(|e| StorageError::Internal(e.to_string()))?, + ) + .await + .map_err(|e| { + tracing::error!("update_table batch: {e}"); + StorageError::Internal("Database error".to_owned()) + })?; + } + + // Post-batch: shard init (has its own internal batch across two keyspaces). + if needs_shard_init { + let account_ks = self.account_keyspace(account_id); + self.init_stream_shards(account_id, &input.table_name, &account_ks, &table_id) + .await?; + } + let _ = needs_label_restore; // handled inside the batch above + + // Post-batch: GSI data table DDL (CREATE/DROP TABLE cannot be batched). + if let Some(updates) = &input.global_secondary_index_updates { + let effective_attr_defs = input + .attribute_definitions + .as_deref() + .unwrap_or(&base_attr_defs); + let account_ks = self.account_keyspace(account_id); + + let mut create_idx = 0usize; + let mut delete_idx = 0usize; + for update in updates { + if let Some(create) = &update.create { + let (index_id, _) = &gsi_creates[create_idx]; + create_idx += 1; + // TODO: backfill existing items into the new GSI. + self.create_index_data_table( + &account_ks, + index_id, + &create.key_schema, + effective_attr_defs, + &base_key_schema, + &base_attr_defs, + ) + .await?; + } + if update.delete.is_some() { + let index_id = &gsi_deletes[delete_idx]; + delete_idx += 1; + self.drop_index_data_table(&account_ks, index_id).await?; + } + } + } + + self.build_table_description(account_id, &input.table_name) + .await + } +} diff --git a/crates/storage-cassandra/src/worker_store.rs b/crates/storage-cassandra/src/worker_store.rs new file mode 100755 index 00000000..23388743 --- /dev/null +++ b/crates/storage-cassandra/src/worker_store.rs @@ -0,0 +1,179 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! `WorkerStore` trait implementation and control plane transition processing. + +use cdrs_tokio::types::IntoRustByName; +use futures::future::BoxFuture; + +use extenddb_storage::WorkerStore; +use extenddb_storage::error::StorageError; + +use crate::CassandraEngine; + +impl WorkerStore for CassandraEngine { + fn process_control_plane_transitions( + &self, + ) -> BoxFuture<'_, Result, StorageError>> { + Box::pin(async move { + // Delegate to the inherent method. + Self::process_control_plane_transitions(self).await + }) + } +} + +impl CassandraEngine { + /// Process pending control plane transitions (H-5). + /// + /// Tables in CREATING state whose `status_transition_at` has passed are + /// moved to ACTIVE. Tables in DELETING state whose transition time has + /// passed are removed (along with their indexes and tags). + /// + /// Called by the background poller in `cmd_serve`. Also called at startup + /// to recover in-flight operations from a previous server instance. + /// + /// Returns a list of `(table_name, transition)` pairs describing what + /// changed, so the caller can log meaningful state-change messages (D-4). + /// + /// # Errors + /// + /// Returns [`StorageError`] if the database is unreachable or a query fails. + pub async fn process_control_plane_transitions( + &self, + ) -> Result, StorageError> { + let mut transitions = Vec::new(); + let catalog_keyspace = self.catalog_keyspace(); + + // CREATING → ACTIVE + // Note: Cassandra requires ALLOW FILTERING for non-key columns in WHERE clause + let query = format!( + "SELECT account_id, table_name, table_id FROM {}.tables \ + WHERE table_status = 'CREATING' AND status_transition_at <= toTimestamp(now()) \ + ALLOW FILTERING", + catalog_keyspace + ); + + let result = self.session.query(&query).await.map_err(|e| { + StorageError::Internal(format!("Failed to query CREATING tables: {}", e)) + })?; + + let body = result.response_body().map_err(|e| { + StorageError::Internal(format!("Failed to parse CREATING tables response: {}", e)) + })?; + + let rows = body.into_rows().unwrap_or_default(); + + for row in rows { + let account_id: String = row.get_r_by_name("account_id").map_err(|e| { + StorageError::Internal(format!("Failed to parse account_id: {}", e)) + })?; + let table_name: String = row.get_r_by_name("table_name").map_err(|e| { + StorageError::Internal(format!("Failed to parse table_name: {}", e)) + })?; + + // Update to ACTIVE (PRIMARY KEY is account_id, table_name) + let update = format!( + "UPDATE {}.tables SET table_status = 'ACTIVE', status_transition_at = null \ + WHERE account_id = ? AND table_name = ?", + catalog_keyspace + ); + self.session + .query_with_values( + &update, + cdrs_tokio::query_values!(account_id.as_str(), table_name.as_str()), + ) + .await + .map_err(|e| StorageError::Internal(format!("Failed to activate table: {}", e)))?; + + transitions.push((table_name, "CREATING → active")); + } + + // DELETING → remove row (with tags and data table cleanup) + let query = format!( + "SELECT account_id, table_name, table_arn, table_id FROM {}.tables \ + WHERE table_status = 'DELETING' AND status_transition_at <= toTimestamp(now()) \ + ALLOW FILTERING", + catalog_keyspace + ); + + let result = self.session.query(&query).await.map_err(|e| { + StorageError::Internal(format!("Failed to query DELETING tables: {}", e)) + })?; + + let body = result.response_body().map_err(|e| { + StorageError::Internal(format!("Failed to parse DELETING tables response: {}", e)) + })?; + + let rows = body.into_rows().unwrap_or_default(); + + for row in rows { + let account_id: String = row.get_r_by_name("account_id").map_err(|e| { + StorageError::Internal(format!("Failed to parse account_id: {}", e)) + })?; + let table_name: String = row.get_r_by_name("table_name").map_err(|e| { + StorageError::Internal(format!("Failed to parse table_name: {}", e)) + })?; + let table_arn: String = row + .get_r_by_name("table_arn") + .map_err(|e| StorageError::Internal(format!("Failed to parse table_arn: {}", e)))?; + let table_id: String = row + .get_r_by_name("table_id") + .map_err(|e| StorageError::Internal(format!("Failed to parse table_id: {}", e)))?; + + // Delete tags + let tag_delete = format!( + "DELETE FROM {}.tags WHERE resource_arn = ?", + catalog_keyspace + ); + self.session + .query_with_values(&tag_delete, cdrs_tokio::query_values!(table_arn.as_str())) + .await + .map_err(|e| StorageError::Internal(format!("Failed to delete tags: {}", e)))?; + + // Delete indexes (catalog + data tables) + let account_keyspace = self.account_keyspace(&account_id); + crate::data::index::delete_indexes_for_table( + &self.session_arc(), + &catalog_keyspace, + &account_keyspace, + &table_id, + self, + ) + .await?; + + let continuous_backup_delete = format!( + "DELETE FROM {}.continuous_backups WHERE account_id = ? AND table_name = ?", + catalog_keyspace + ); + self.session + .query_with_values( + &continuous_backup_delete, + cdrs_tokio::query_values!(account_id.as_str(), table_name.as_str()), + ) + .await + .map_err(|e| { + StorageError::Internal(format!("Failed to delete continuous backup state: {e}")) + })?; + + // Delete table row (PRIMARY KEY is account_id, table_name) + let table_delete = format!( + "DELETE FROM {}.tables WHERE account_id = ? AND table_name = ?", + catalog_keyspace + ); + self.session + .query_with_values( + &table_delete, + cdrs_tokio::query_values!(account_id.as_str(), table_name.as_str()), + ) + .await + .map_err(|e| StorageError::Internal(format!("Failed to delete table: {}", e)))?; + + // Drop base table in account keyspace + self.drop_data_table(&account_keyspace, &table_id).await?; + + transitions.push((table_name, "DELETING → deleted")); + } + + Ok(transitions) + } +} diff --git a/crates/storage-cassandra/src/workers.rs b/crates/storage-cassandra/src/workers.rs new file mode 100644 index 00000000..58b9326d --- /dev/null +++ b/crates/storage-cassandra/src/workers.rs @@ -0,0 +1,428 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Cassandra-specific background workers. + +use std::sync::Arc; +use std::time::Duration; + +use std::sync::atomic::AtomicU64; + +use extenddb_storage::management_store::SettingsStore; + +use crate::CassandraEngine; + +/// Poll `gsi_propagation_delay_ms` from settings every 30 seconds and update +/// the in-memory atomic used by put_item/update_item/delete_item. +pub(crate) async fn poll_gsi_delay( + store: Arc, + gsi_delay: Arc, +) { + const POLL_INTERVAL: Duration = Duration::from_secs(30); + + loop { + tokio::time::sleep(POLL_INTERVAL).await; + + match store.get_setting("gsi_propagation_delay_ms").await { + Ok(Some(val)) => { + if let Ok(ms) = val.parse::() { + gsi_delay.store(ms, std::sync::atomic::Ordering::Relaxed); + } + } + Ok(None) => { + gsi_delay.store(10, std::sync::atomic::Ordering::Relaxed); + } + Err(e) => { + tracing::debug!("Failed to query gsi_propagation_delay_ms: {e:?}"); + } + } + } +} + +pub(crate) async fn poll_control_plane_transitions( + storage: Arc, + notify: Arc, + settings: Arc, +) { + const ACTIVE_POLL: Duration = Duration::from_secs(1); + const IDLE_TIMEOUT: Duration = Duration::from_secs(60); + const MARGIN_SECS: f64 = 5.0; + + loop { + // Idle: wait for a wake signal or timeout (defensive sweep) + let _ = tokio::time::timeout(IDLE_TIMEOUT, notify.notified()).await; + + // Read control_plane_delay_seconds from settings to compute active window + let delay_secs = read_control_plane_delay(&*settings).await; + let active_window = Duration::from_secs_f64(delay_secs + MARGIN_SECS); + + // Active: poll every second for active_window + let deadline = tokio::time::Instant::now() + active_window; + loop { + match storage.process_control_plane_transitions().await { + Ok(ref t) if t.is_empty() => {} + Ok(transitions) => { + for (name, transition) in &transitions { + tracing::info!("Table '{name}': {transition}"); + } + } + Err(e) => { + tracing::warn!("Control plane transition poll failed: {e}"); + break; + } + } + if tokio::time::Instant::now() >= deadline { + break; + } + tokio::time::sleep(ACTIVE_POLL).await; + } + } +} + +/// Background worker that detects and recovers stale transactions. +/// +/// Scans every account keyspace for transactions older than `timeout` and +/// resumes COMMIT or executes ROLLBACK as appropriate. Runs every +/// `scan_interval` seconds for the lifetime of the process. +pub(crate) async fn poll_transaction_recovery( + engine: Arc, + timeout: Duration, + scan_interval: Duration, +) { + loop { + tokio::time::sleep(scan_interval).await; + + let cutoff = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .saturating_sub(timeout) + .as_millis() as i64; + + let keyspaces = match list_account_keyspaces(&engine).await { + Ok(ks) => ks, + Err(e) => { + tracing::warn!("transaction_recovery: list keyspaces failed: {e}"); + continue; + } + }; + + for keyspace in &keyspaces { + let entries = match engine.scan_old_transactions(keyspace, cutoff).await { + Ok(e) => e, + Err(e) => { + tracing::warn!("transaction_recovery: scan {keyspace} failed: {e}"); + continue; + } + }; + + for entry in entries { + tracing::info!( + "transaction_recovery: recovering txn {} (state={}) in {keyspace}", + entry.txn_id, entry.state + ); + if let Err(e) = engine.recover_transaction(keyspace, entry.txn_id).await { + tracing::warn!( + "transaction_recovery: failed to recover txn {}: {e}", + entry.txn_id + ); + } + } + } + } +} + +/// List all account keyspaces for this engine (keyspaces matching `{prefix}_account_*`). +async fn list_account_keyspaces(engine: &CassandraEngine) -> Result, extenddb_storage::error::StorageError> { + let prefix = format!("{}_account_", engine.keyspace_prefix); + let rows = engine + .session + .query("SELECT keyspace_name FROM system_schema.keyspaces") + .await + .map_err(|e| extenddb_storage::error::StorageError::Internal(format!("list keyspaces: {e}")))? + .response_body() + .map_err(|e| extenddb_storage::error::StorageError::Internal(format!("list keyspaces body: {e}")))? + .into_rows() + .unwrap_or_default(); + + use cdrs_tokio::types::IntoRustByName as _; + let mut keyspaces = Vec::new(); + for row in rows { + let name: Result = row.get_r_by_name("keyspace_name"); + if let Ok(name) = name { + if name.starts_with(&prefix) { + keyspaces.push(name); + } + } + } + Ok(keyspaces) +} + +async fn read_control_plane_delay(store: &S) -> f64 { + store + .get_setting("control_plane_delay_seconds") + .await + .ok() + .flatten() + .and_then(|v| v.parse::().ok()) + .filter(|&v| v >= 0.0) + .unwrap_or(0.25) +} + +/// Handle that stops GSI workers when dropped. +pub struct GsiWorkerGuard { + shutdown: Arc, +} + +impl Drop for GsiWorkerGuard { + fn drop(&mut self) { + self.shutdown.store(true, std::sync::atomic::Ordering::Relaxed); + } +} + +/// Spawn GSI propagation workers — one per partition. +/// +/// Returns a `GsiWorkerGuard`; workers stop when it is dropped. +pub fn spawn_gsi_workers(engine: Arc) -> GsiWorkerGuard { + let shutdown = Arc::new(std::sync::atomic::AtomicBool::new(false)); + for worker_id in 0..crate::gsi_queue::NUM_WORKERS { + let engine = engine.clone(); + let shutdown = shutdown.clone(); + tokio::spawn(async move { + gsi_worker(worker_id, engine, shutdown).await; + }); + } + GsiWorkerGuard { shutdown } +} + +async fn gsi_worker(worker_id: u64, engine: Arc, shutdown: Arc) { + const MAX_IDLE: Duration = Duration::from_secs(1); + + tracing::debug!("GSI worker {worker_id} started"); + + loop { + if shutdown.load(std::sync::atomic::Ordering::Relaxed) { + tracing::debug!("GSI worker {worker_id} shutting down"); + return; + } + match gsi_process_batch(worker_id, &engine).await { + Ok(0) => { + // Nothing ready. Sleep until the next row is due or a write wakes us. + let wait = gsi_next_ready_wait(worker_id, &engine) + .await + .unwrap_or(MAX_IDLE) + .min(MAX_IDLE); + tokio::time::timeout(wait, engine.gsi_queue.notify.notified()) + .await + .ok(); + } + Ok(_) => continue, + Err(e) => { + tracing::error!("GSI worker {worker_id}: {e}"); + tokio::time::sleep(MAX_IDLE).await; + } + } + } +} + +/// Time until the earliest not-yet-due row in this partition becomes eligible. +async fn gsi_next_ready_wait( + worker_id: u64, + engine: &CassandraEngine, +) -> Option { + // We need to find the minimum ready_at across all account keyspaces. + // For simplicity, use MAX_IDLE as the wait — the worker will re-check + // promptly. A more precise implementation would query each keyspace. + // TODO: query MIN(ready_at) per keyspace and return the smallest delta. + let _ = (worker_id, engine); + None +} + +/// Claim and process up to 100 ready rows from this worker's partition across +/// all account keyspaces. Returns the total number applied. +async fn gsi_process_batch( + worker_id: u64, + engine: &CassandraEngine, +) -> Result { + use cdrs_tokio::query_values; + use cdrs_tokio::types::IntoRustByName as _; + use extenddb_core::types::Item; + + let keyspaces = list_account_keyspaces(engine).await?; + let mut total = 0usize; + + for keyspace in &keyspaces { + let query = format!( + "SELECT worker_partition, ready_at, id, table_id, old_item, new_item, index_context \ + FROM {keyspace}.gsi_pending \ + WHERE worker_partition = ? AND ready_at <= toTimestamp(now()) \ + ORDER BY ready_at ASC, id ASC \ + LIMIT 100" + ); + + let rows = match crate::cassandra_util::query_rows::( + &engine.session, + &query, + query_values!(worker_id as i32), + "gsi_worker", + ) + .await + { + Ok(rows) => rows, + Err(ref e) if is_table_not_found(e) => { + tracing::warn!("GSI worker {worker_id}: {keyspace}.gsi_pending not found, skipping keyspace"); + continue; + } + Err(e) => return Err(e), + }; + + for row in rows { + let ready_at: i64 = + crate::cassandra_util::get_column(&row, "ready_at", "gsi_worker")?; + let id: uuid::Uuid = + crate::cassandra_util::get_column(&row, "id", "gsi_worker")?; + let table_id: String = + crate::cassandra_util::get_column(&row, "table_id", "gsi_worker")?; + let old_json: Option = row.get_by_name("old_item").ok().flatten(); + let new_json: Option = row.get_by_name("new_item").ok().flatten(); + let ctx_json: String = + crate::cassandra_util::get_column(&row, "index_context", "gsi_worker")?; + + let old_item: Option = old_json + .map(|s| serde_json::from_str(&s)) + .transpose() + .map_err(|e| extenddb_storage::error::StorageError::Internal(e.to_string()))?; + let new_item: Option = new_json + .map(|s| serde_json::from_str(&s)) + .transpose() + .map_err(|e| extenddb_storage::error::StorageError::Internal(e.to_string()))?; + let context: crate::gsi_queue::GsiApplyContext = + serde_json::from_str(&ctx_json) + .map_err(|e| extenddb_storage::error::StorageError::Internal(e.to_string()))?; + + // Apply the index update. If the index table is gone (table deleted), + // log and skip — the row is still deleted below. + let apply_result = gsi_apply_index( + engine, + keyspace, + &table_id, + &context, + old_item.as_ref(), + new_item.as_ref(), + ) + .await; + + if let Err(ref e) = apply_result { + if is_table_not_found(e) { + tracing::debug!( + "GSI worker {worker_id}: index {} gone, skipping (table={table_id})", + context.index.index_id + ); + } else { + tracing::warn!( + "GSI worker {worker_id}: apply failed for table={table_id} index={}: {e}", + context.index.index_id + ); + continue; + } + } + + // Delete the processed row by full primary key. + let delete_cql = format!( + "DELETE FROM {keyspace}.gsi_pending \ + WHERE worker_partition = ? AND ready_at = ? AND id = ?" + ); + crate::cassandra_util::execute( + &engine.session, + &delete_cql, + query_values!(worker_id as i32, ready_at, id), + "gsi_worker_delete", + ) + .await?; + + total += 1; + } + } + + Ok(total) +} + +/// Apply a single GSI update (delete old row, insert new row) for one pending entry. +async fn gsi_apply_index( + engine: &CassandraEngine, + account_keyspace: &str, + _table_id: &str, + context: &crate::gsi_queue::GsiApplyContext, + old_item: Option<&extenddb_core::types::Item>, + new_item: Option<&extenddb_core::types::Item>, +) -> Result<(), extenddb_storage::error::StorageError> { + use crate::data::ddl::all_sort_key_info; + use crate::data::index::{ + delete_index_row_multi, insert_index_row_multi, item_has_index_keys, project_item_for_index, + }; + use cdrs_tokio::consistency::Consistency; + use cdrs_tokio::query::BatchQueryBuilder; + + let idx = &context.index; + let idx_table = crate::data::ddl::index_table_name(&idx.index_id); + let idx_sks = all_sort_key_info(&idx.key_schema, &context.attribute_definitions); + let base_sks = all_sort_key_info(&context.base_key_schema, &context.attribute_definitions); + + let mut batch = BatchQueryBuilder::new().with_consistency(Consistency::LocalQuorum); + + if let Some(old) = old_item { + if item_has_index_keys(old, &idx.key_schema) { + delete_index_row_multi( + &mut batch, + account_keyspace, + &idx_table, + old, + &idx.key_schema, + &context.base_key_schema, + &idx_sks, + &base_sks, + )?; + } + } + + if let Some(new) = new_item { + if item_has_index_keys(new, &idx.key_schema) { + let projected = + project_item_for_index(new, &idx.key_schema, &context.base_key_schema, &idx.projection); + insert_index_row_multi( + &mut batch, + account_keyspace, + &idx_table, + new, + &projected, + &idx.key_schema, + &context.base_key_schema, + &idx_sks, + &base_sks, + )?; + } + } + + // Only execute if there's something to do. + let built = batch + .build() + .map_err(|e| extenddb_storage::error::StorageError::Internal(e.to_string()))?; + if !built.request.queries.is_empty() { + engine + .session + .batch(built) + .await + .map_err(|e| extenddb_storage::error::StorageError::Internal(format!("gsi_apply: {e}")))?; + } + + Ok(()) +} + +/// Returns true if the error indicates the index table no longer exists. +fn is_table_not_found(err: &extenddb_storage::error::StorageError) -> bool { + match err { + extenddb_storage::error::StorageError::Internal(msg) => { + msg.contains("unconfigured table") || msg.contains("does not exist") + } + _ => false, + } +} diff --git a/crates/storage/src/config.rs b/crates/storage/src/config.rs index 3bdb1204..b7b6aca3 100644 --- a/crates/storage/src/config.rs +++ b/crates/storage/src/config.rs @@ -62,6 +62,19 @@ pub trait StorageConfig: Send + Sync + std::fmt::Debug { /// Clone this config into a boxed trait object. fn clone_box(&self) -> Box; + /// Set the server instance identifier for this backend. + /// + /// Called by the server before constructing the storage engine, passing + /// a stable unique identifier for this server process (typically the + /// bind address, e.g. "192.168.1.1:18443"). Backends that need per-instance + /// identity (e.g. for HLC node IDs) implement this; others can ignore it. + fn set_instance_id(&mut self, _instance_id: &str) {} + + /// Return the instance identifier set via `set_instance_id`, if any. + fn instance_id(&self) -> Option<&str> { + None + } + /// Enable downcasting to specific storage engine config types to allow /// access to engine-specific configuration (e.g. `keyspace_prefix` for /// the Cassandra backend). diff --git a/docs/design/11-high-availability.md b/docs/design/11-high-availability.md index 9c057276..b5106959 100755 --- a/docs/design/11-high-availability.md +++ b/docs/design/11-high-availability.md @@ -362,7 +362,7 @@ Any SQL statement that acquires locks (`SELECT ... FOR UPDATE`) routes to primar - **The `propagation_delay_ms` setting determines GSI consistency class.** A GSI with `propagation_delay_ms = 0` (or a future explicit `strongly_consistent = true` flag) commits synchronously. The HA design does not need to distinguish between "regular" and "strongly consistent" GSIs for routing purposes — the distinction is in the write path (sync vs. async commit), not the read path. -- **Async GSIs (non-zero propagation delay) have weaker replica guarantees.** An async GSI update is enqueued after the base table transaction commits. The GSI row is written in a separate transaction (by the GSI worker). On a replica, the base row and the async GSI row may appear at different times (different transactions, different WAL positions). A strongly consistent read on an async GSI would need to route to primary AND wait for the GSI worker to process the queue — which is impractical. Therefore: **strongly consistent reads are only supported on strongly consistent GSIs (zero propagation delay).** Attempting a strongly consistent read on an async GSI returns a `ValidationException` with message "Strongly consistent reads are not supported on eventually consistent indexes." This matches DynamoDB's approach of rejecting invalid consistency requests at the API layer rather than silently degrading. The caller explicitly asked for strong consistency; silently returning stale data would violate the principle of least surprise. +- **Async GSIs (non-zero propagation delay) have weaker replica guarantees.** An async GSI update is enqueued after the base table transaction commits. The GSI row is written in a separate transaction (by the GSI worker). On a replica, the base row and the async GSI row may appear at different times (different transactions, different WAL positions). A strongly consistent read on an async GSI would need to route to primary AND wait for the GSI worker to process the queue — which is impractical. Therefore: **strongly consistent reads are only supported on strongly consistent GSIs (zero propagation delay).** Attempting a strongly consistent read on an async GSI returns a `ValidationException` with message "Strongly cstonsistent reads are not supported on eventually consistent indexes." This matches DynamoDB's approach of rejecting invalid consistency requests at the API layer rather than silently degrading. The caller explicitly asked for strong consistency; silently returning stale data would violate the principle of least surprise. - **Stage 1 compatibility:** The `consistent_read: bool` parameter added in Stage 1 is sufficient. No additional parameters are needed for strongly consistent GSI support. The storage adapter's routing decision is the same: `true` → primary, `false` → replica. From 9af025dff5c2748a1674e3f29dad852434bbbd28 Mon Sep 17 00:00:00 2001 From: Joel Shepherd Date: Fri, 28 Aug 2026 18:00:20 +0000 Subject: [PATCH 05/48] chore: fix unused var binding; cargo fmt cleanup --- crates/storage-cassandra/src/catalog_store.rs | 15 +- crates/storage-cassandra/src/create_table.rs | 21 ++- .../storage-cassandra/src/data/delete_item.rs | 41 ++-- crates/storage-cassandra/src/data/index.rs | 13 +- .../src/data/put_get_item.rs | 46 ++--- crates/storage-cassandra/src/data/query.rs | 6 +- .../src/data/query_helpers.rs | 134 ++++++++++--- .../src/data/transactions.rs | 178 ++++++++++++------ .../storage-cassandra/src/data/update_item.rs | 27 +-- crates/storage-cassandra/src/engine.rs | 4 +- crates/storage-cassandra/src/gsi_queue.rs | 2 +- crates/storage-cassandra/src/lib.rs | 32 ++-- .../src/management_store/access_keys.rs | 2 +- .../src/management_store/accounts.rs | 24 +-- .../src/management_store/groups.rs | 4 +- .../src/management_store/users.rs | 2 +- .../storage-cassandra/src/metadata_engine.rs | 20 +- crates/storage-cassandra/src/stream_engine.rs | 81 ++++---- crates/storage-cassandra/src/stream_util.rs | 15 +- crates/storage-cassandra/src/update_table.rs | 42 +++-- crates/storage-cassandra/src/workers.rs | 60 +++--- 21 files changed, 501 insertions(+), 268 deletions(-) diff --git a/crates/storage-cassandra/src/catalog_store.rs b/crates/storage-cassandra/src/catalog_store.rs index 5d9d52e7..fa796e74 100644 --- a/crates/storage-cassandra/src/catalog_store.rs +++ b/crates/storage-cassandra/src/catalog_store.rs @@ -98,7 +98,8 @@ impl CassandraCatalogStore { let keyspace_name = self.account_keyspace(account_id); // Check if keyspace already exists to avoid re-running migrations on every call. - let exists_result = self.session + let exists_result = self + .session .query_with_values( "SELECT keyspace_name FROM system_schema.keyspaces WHERE keyspace_name = ?", cdrs_tokio::query_values!(keyspace_name.as_str()), @@ -116,7 +117,11 @@ impl CassandraCatalogStore { return crate::migrations::run_data_migrations(&self.session, &keyspace_name) .await .map_err(|e| { - tracing::error!("Failed to run data migrations for {}: {:?}", keyspace_name, e); + tracing::error!( + "Failed to run data migrations for {}: {:?}", + keyspace_name, + e + ); OpError::Internal("Failed to initialize account storage".to_owned()) }); } @@ -134,7 +139,11 @@ impl CassandraCatalogStore { crate::migrations::run_data_migrations(&self.session, &keyspace_name) .await .map_err(|e| { - tracing::error!("Failed to run data migrations for {}: {:?}", keyspace_name, e); + tracing::error!( + "Failed to run data migrations for {}: {:?}", + keyspace_name, + e + ); OpError::Internal("Failed to initialize account storage".to_owned()) })?; diff --git a/crates/storage-cassandra/src/create_table.rs b/crates/storage-cassandra/src/create_table.rs index 76f8f9d9..04be381c 100644 --- a/crates/storage-cassandra/src/create_table.rs +++ b/crates/storage-cassandra/src/create_table.rs @@ -53,13 +53,10 @@ impl CassandraEngine { // Note: values are interpolated rather than bound because Cassandra LOGGED BATCH // does not support parameterized statements spanning multiple tables. // All interpolated values are server-generated (UUIDs, timestamps, label from chrono). - self.session - .query(&batch) - .await - .map_err(|e| { - tracing::error!("init_stream_shards batch: {e}"); - StorageError::Internal(format!("Failed to initialize stream shards: {e}")) - })?; + self.session.query(&batch).await.map_err(|e| { + tracing::error!("init_stream_shards batch: {e}"); + StorageError::Internal(format!("Failed to initialize stream shards: {e}")) + })?; Ok(label) } @@ -351,7 +348,15 @@ impl CassandraEngine { .as_ref() .is_some_and(|s| s.stream_enabled) { - Some(self.init_stream_shards(account_id, &input.table_name, &account_keyspace, &table_id).await?) + Some( + self.init_stream_shards( + account_id, + &input.table_name, + &account_keyspace, + &table_id, + ) + .await?, + ) } else { None }; diff --git a/crates/storage-cassandra/src/data/delete_item.rs b/crates/storage-cassandra/src/data/delete_item.rs index e91f551c..1626959f 100644 --- a/crates/storage-cassandra/src/data/delete_item.rs +++ b/crates/storage-cassandra/src/data/delete_item.rs @@ -35,11 +35,13 @@ impl CassandraEngine { let pk_text = composite_pk_to_text(key, &key_info.key_schema)?; let catalog_keyspace = self.catalog_keyspace(); - let indexes = fetch_indexes_for_table(&key_info.table_id, &self.session, &catalog_keyspace).await?; + let indexes = + fetch_indexes_for_table(&key_info.table_id, &self.session, &catalog_keyspace).await?; let sys_delay = if indexes.is_empty() { 0 } else { - self.gsi_default_delay_ms.load(std::sync::atomic::Ordering::Relaxed) + self.gsi_default_delay_ms + .load(std::sync::atomic::Ordering::Relaxed) }; if let Some((sk_name, sk_type)) = @@ -175,20 +177,23 @@ impl CassandraEngine { old_item_opt.as_ref(), None, sys_delay, - ).await? + ) + .await? } else { 0 }; if let Some(stmt) = stream_stmt { - batch = batch.add_query( - stmt, - cdrs_tokio::query::QueryValues::SimpleValues(vec![]), - ); + batch = + batch.add_query(stmt, cdrs_tokio::query::QueryValues::SimpleValues(vec![])); } self.session - .batch(batch.build().map_err(|e| StorageError::Internal(e.to_string()))?) + .batch( + batch + .build() + .map_err(|e| StorageError::Internal(e.to_string()))?, + ) .await .map_err(|e| StorageError::Internal(format!("Batch execution: {}", e)))?; @@ -319,20 +324,23 @@ impl CassandraEngine { old_item_opt.as_ref(), None, sys_delay, - ).await? + ) + .await? } else { 0 }; if let Some(stmt) = stream_stmt { - batch = batch.add_query( - stmt, - cdrs_tokio::query::QueryValues::SimpleValues(vec![]), - ); + batch = + batch.add_query(stmt, cdrs_tokio::query::QueryValues::SimpleValues(vec![])); } self.session - .batch(batch.build().map_err(|e| StorageError::Internal(e.to_string()))?) + .batch( + batch + .build() + .map_err(|e| StorageError::Internal(e.to_string()))?, + ) .await .map_err(|e| StorageError::Internal(format!("Batch execution: {}", e)))?; @@ -371,10 +379,7 @@ impl CassandraEngine { let result = self .session - .query_with_values( - &query_null, - cdrs_tokio::query_values!(now_ms, pk), - ) + .query_with_values(&query_null, cdrs_tokio::query_values!(now_ms, pk)) .await .map_err(|e| { tracing::error!("update_partition_max_delete_timestamp (null): {e}"); diff --git a/crates/storage-cassandra/src/data/index.rs b/crates/storage-cassandra/src/data/index.rs index 6c9e8e59..c93e38fa 100644 --- a/crates/storage-cassandra/src/data/index.rs +++ b/crates/storage-cassandra/src/data/index.rs @@ -309,8 +309,8 @@ pub(crate) async fn enqueue_async_indexes( }, }; - let context_json = serde_json::to_string(&context) - .map_err(|e| StorageError::Internal(e.to_string()))?; + let context_json = + serde_json::to_string(&context).map_err(|e| StorageError::Internal(e.to_string()))?; let old_json = old_item .map(serde_json::to_string) .transpose() @@ -480,7 +480,8 @@ pub(crate) fn insert_index_row_multi( serde_json::to_string(projected).map_err(|e| StorageError::Internal(e.to_string()))?; let mut cols = vec!["pk".to_owned()]; - let mut values: Vec = vec![cdrs_tokio::types::value::Value::from(idx_pk_text.as_str())]; + let mut values: Vec = + vec![cdrs_tokio::types::value::Value::from(idx_pk_text.as_str())]; // Index SK values for (i, &(sk_name, sk_type)) in idx_sks.iter().enumerate() { @@ -532,9 +533,9 @@ pub(crate) fn sk_to_value(sk: &SortKeyValue) -> cdrs_tokio::types::value::Value match sk { SortKeyValue::S(s) => s.as_str().into(), SortKeyValue::N(n) => super::decimal_to_value(n), - SortKeyValue::B(b) => cdrs_tokio::types::value::Value::from( - cdrs_tokio::types::blob::Blob::new(b.to_vec()), - ), + SortKeyValue::B(b) => { + cdrs_tokio::types::value::Value::from(cdrs_tokio::types::blob::Blob::new(b.to_vec())) + } } } diff --git a/crates/storage-cassandra/src/data/put_get_item.rs b/crates/storage-cassandra/src/data/put_get_item.rs index b87295d7..62c70cf6 100644 --- a/crates/storage-cassandra/src/data/put_get_item.rs +++ b/crates/storage-cassandra/src/data/put_get_item.rs @@ -10,9 +10,7 @@ use extenddb_core::expression::{Expr, ExpressionMaps}; use extenddb_core::types::{Item, TableKeyInfo}; use extenddb_storage::StreamCapture; use extenddb_storage::error::StorageError; -use extenddb_storage::util::{ - composite_pk_to_text, parse_sk, pk_to_text, sk_column, sk_info, -}; +use extenddb_storage::util::{composite_pk_to_text, parse_sk, pk_to_text, sk_column, sk_info}; use super::ddl::data_table_name; use super::{json_to_item, query_with_pk_sk, query_with_pk_sk_item}; @@ -82,9 +80,9 @@ impl CassandraEngine { let (old_item_opt, has_prepared_txn) = if let Some(rows) = body.into_rows() { if let Some(row) = rows.into_iter().next() { - let item_data: String = row.get_r_by_name("item_data").map_err(|e| { - StorageError::Internal(format!("Parse item_data: {}", e)) - })?; + let item_data: String = row + .get_r_by_name("item_data") + .map_err(|e| StorageError::Internal(format!("Parse item_data: {}", e)))?; let prepared_txn_id: Option = row.get_by_name("prepared_txn_id").ok().flatten(); (Some(json_to_item(item_data)?), prepared_txn_id.is_some()) @@ -187,20 +185,23 @@ impl CassandraEngine { old_item_opt.as_ref(), Some(&item), sys_delay, - ).await? + ) + .await? } else { 0 }; if let Some(stmt) = stream_stmt { - batch = batch.add_query( - stmt, - cdrs_tokio::query::QueryValues::SimpleValues(vec![]), - ); + batch = + batch.add_query(stmt, cdrs_tokio::query::QueryValues::SimpleValues(vec![])); } self.session - .batch(batch.build().map_err(|e| StorageError::Internal(e.to_string()))?) + .batch( + batch + .build() + .map_err(|e| StorageError::Internal(e.to_string()))?, + ) .await .map_err(|e| StorageError::Internal(format!("Batch execution: {}", e)))?; @@ -234,9 +235,9 @@ impl CassandraEngine { let (old_item_opt, has_prepared_txn) = if let Some(rows) = body.into_rows() { if let Some(row) = rows.into_iter().next() { - let item_data: String = row.get_r_by_name("item_data").map_err(|e| { - StorageError::Internal(format!("Parse item_data: {}", e)) - })?; + let item_data: String = row + .get_r_by_name("item_data") + .map_err(|e| StorageError::Internal(format!("Parse item_data: {}", e)))?; let prepared_txn_id: Option = row.get_by_name("prepared_txn_id").ok().flatten(); (Some(json_to_item(item_data)?), prepared_txn_id.is_some()) @@ -337,20 +338,23 @@ impl CassandraEngine { old_item_opt.as_ref(), Some(&item), sys_delay, - ).await? + ) + .await? } else { 0 }; if let Some(stmt) = stream_stmt { - batch = batch.add_query( - stmt, - cdrs_tokio::query::QueryValues::SimpleValues(vec![]), - ); + batch = + batch.add_query(stmt, cdrs_tokio::query::QueryValues::SimpleValues(vec![])); } self.session - .batch(batch.build().map_err(|e| StorageError::Internal(e.to_string()))?) + .batch( + batch + .build() + .map_err(|e| StorageError::Internal(e.to_string()))?, + ) .await .map_err(|e| StorageError::Internal(format!("Batch execution: {}", e)))?; diff --git a/crates/storage-cassandra/src/data/query.rs b/crates/storage-cassandra/src/data/query.rs index b267a216..ae5d0b9f 100644 --- a/crates/storage-cassandra/src/data/query.rs +++ b/crates/storage-cassandra/src/data/query.rs @@ -25,7 +25,8 @@ impl SortKeyValueExt for SortKeyValue { use super::ddl::data_table_name; use super::query_helpers::{ - query_with_pk_pk_sk, query_with_pk_sk, query_with_pk_sk_pk, query_with_pk_sk_pk_sk, query_with_pk_sk_sk, query_with_pk_sk_sk_sk, + query_with_pk_pk_sk, query_with_pk_sk, query_with_pk_sk_pk, query_with_pk_sk_pk_sk, + query_with_pk_sk_sk, query_with_pk_sk_sk_sk, }; use super::{json_to_item, resolve_expr_to_av}; use crate::CassandraEngine; @@ -528,8 +529,7 @@ impl CassandraEngine { let dir = if forward { "ASC" } else { "DESC" }; format!( " ORDER BY {} {}, base_pk {}, {} {} LIMIT {}", - sk_col, dir, dir, base_sk_col, dir, - remaining + sk_col, dir, dir, base_sk_col, dir, remaining ) } else { format!( diff --git a/crates/storage-cassandra/src/data/query_helpers.rs b/crates/storage-cassandra/src/data/query_helpers.rs index d91ba5ef..30ad1a15 100644 --- a/crates/storage-cassandra/src/data/query_helpers.rs +++ b/crates/storage-cassandra/src/data/query_helpers.rs @@ -108,24 +108,92 @@ pub(super) async fn query_with_pk_sk_pk_sk( ) -> Result, StorageError> { use cdrs_tokio::query_values; match (idx_sk, base_sk) { - (SortKeyValue::S(isk), SortKeyValue::S(bsk)) => - cassandra_util::query_rows(session, query, query_values!(pk, isk.as_str(), base_pk, bsk.as_str()), label).await, - (SortKeyValue::S(isk), SortKeyValue::N(bsk)) => - cassandra_util::query_rows(session, query, query_values!(pk, isk.as_str(), base_pk, super::decimal_to_value(bsk)), label).await, - (SortKeyValue::S(isk), SortKeyValue::B(bsk)) => - cassandra_util::query_rows(session, query, query_values!(pk, isk.as_str(), base_pk, bsk.to_vec()), label).await, - (SortKeyValue::N(isk), SortKeyValue::S(bsk)) => - cassandra_util::query_rows(session, query, query_values!(pk, super::decimal_to_value(isk), base_pk, bsk.as_str()), label).await, - (SortKeyValue::N(isk), SortKeyValue::N(bsk)) => - cassandra_util::query_rows(session, query, query_values!(pk, super::decimal_to_value(isk), base_pk, super::decimal_to_value(bsk)), label).await, - (SortKeyValue::N(isk), SortKeyValue::B(bsk)) => - cassandra_util::query_rows(session, query, query_values!(pk, super::decimal_to_value(isk), base_pk, bsk.to_vec()), label).await, - (SortKeyValue::B(isk), SortKeyValue::S(bsk)) => - cassandra_util::query_rows(session, query, query_values!(pk, isk.to_vec(), base_pk, bsk.as_str()), label).await, - (SortKeyValue::B(isk), SortKeyValue::N(bsk)) => - cassandra_util::query_rows(session, query, query_values!(pk, isk.to_vec(), base_pk, super::decimal_to_value(bsk)), label).await, - (SortKeyValue::B(isk), SortKeyValue::B(bsk)) => - cassandra_util::query_rows(session, query, query_values!(pk, isk.to_vec(), base_pk, bsk.to_vec()), label).await, + (SortKeyValue::S(isk), SortKeyValue::S(bsk)) => { + cassandra_util::query_rows( + session, + query, + query_values!(pk, isk.as_str(), base_pk, bsk.as_str()), + label, + ) + .await + } + (SortKeyValue::S(isk), SortKeyValue::N(bsk)) => { + cassandra_util::query_rows( + session, + query, + query_values!(pk, isk.as_str(), base_pk, super::decimal_to_value(bsk)), + label, + ) + .await + } + (SortKeyValue::S(isk), SortKeyValue::B(bsk)) => { + cassandra_util::query_rows( + session, + query, + query_values!(pk, isk.as_str(), base_pk, bsk.to_vec()), + label, + ) + .await + } + (SortKeyValue::N(isk), SortKeyValue::S(bsk)) => { + cassandra_util::query_rows( + session, + query, + query_values!(pk, super::decimal_to_value(isk), base_pk, bsk.as_str()), + label, + ) + .await + } + (SortKeyValue::N(isk), SortKeyValue::N(bsk)) => { + cassandra_util::query_rows( + session, + query, + query_values!( + pk, + super::decimal_to_value(isk), + base_pk, + super::decimal_to_value(bsk) + ), + label, + ) + .await + } + (SortKeyValue::N(isk), SortKeyValue::B(bsk)) => { + cassandra_util::query_rows( + session, + query, + query_values!(pk, super::decimal_to_value(isk), base_pk, bsk.to_vec()), + label, + ) + .await + } + (SortKeyValue::B(isk), SortKeyValue::S(bsk)) => { + cassandra_util::query_rows( + session, + query, + query_values!(pk, isk.to_vec(), base_pk, bsk.as_str()), + label, + ) + .await + } + (SortKeyValue::B(isk), SortKeyValue::N(bsk)) => { + cassandra_util::query_rows( + session, + query, + query_values!(pk, isk.to_vec(), base_pk, super::decimal_to_value(bsk)), + label, + ) + .await + } + (SortKeyValue::B(isk), SortKeyValue::B(bsk)) => { + cassandra_util::query_rows( + session, + query, + query_values!(pk, isk.to_vec(), base_pk, bsk.to_vec()), + label, + ) + .await + } } } @@ -140,9 +208,33 @@ pub(super) async fn query_with_pk_sk_pk( label: &str, ) -> Result, StorageError> { match sk { - SortKeyValue::S(s) => cassandra_util::query_rows(session, query, cdrs_tokio::query_values!(pk, s.as_str(), base_pk), label).await, - SortKeyValue::N(n) => cassandra_util::query_rows(session, query, cdrs_tokio::query_values!(pk, super::decimal_to_value(n), base_pk), label).await, - SortKeyValue::B(b) => cassandra_util::query_rows(session, query, cdrs_tokio::query_values!(pk, b.to_vec(), base_pk), label).await, + SortKeyValue::S(s) => { + cassandra_util::query_rows( + session, + query, + cdrs_tokio::query_values!(pk, s.as_str(), base_pk), + label, + ) + .await + } + SortKeyValue::N(n) => { + cassandra_util::query_rows( + session, + query, + cdrs_tokio::query_values!(pk, super::decimal_to_value(n), base_pk), + label, + ) + .await + } + SortKeyValue::B(b) => { + cassandra_util::query_rows( + session, + query, + cdrs_tokio::query_values!(pk, b.to_vec(), base_pk), + label, + ) + .await + } } } diff --git a/crates/storage-cassandra/src/data/transactions.rs b/crates/storage-cassandra/src/data/transactions.rs index f95cf5b4..67c0d677 100644 --- a/crates/storage-cassandra/src/data/transactions.rs +++ b/crates/storage-cassandra/src/data/transactions.rs @@ -289,7 +289,10 @@ impl CassandraEngine { } Err(reasons) => { // T4.4: ROLLBACK phase - tracing::debug!("transact_write: PREPARE failed ({} reasons), rolling back txn {txn_id}", reasons.len()); + tracing::debug!( + "transact_write: PREPARE failed ({} reasons), rolling back txn {txn_id}", + reasons.len() + ); self.execute_rollback_phase(&account_keyspace, ops, txn_id) .await?; Err(StorageError::TransactionCanceled(reasons)) @@ -312,7 +315,10 @@ impl CassandraEngine { let mut any_failed = false; for op in ops { - match self.prepare_single_operation(op, txn_id, txn_timestamp).await { + match self + .prepare_single_operation(op, txn_id, txn_timestamp) + .await + { Ok(item_data) => { reasons.push(CancellationReason::none()); computed.push(item_data); @@ -325,7 +331,11 @@ impl CassandraEngine { } } - if any_failed { Err(reasons) } else { Ok(computed) } + if any_failed { + Err(reasons) + } else { + Ok(computed) + } } /// Prepare a single transactional operation. @@ -636,29 +646,28 @@ impl CassandraEngine { } }; let pk = composite_pk_to_text(key, &key_info.key_schema)?; - let (sk_col, sk_val) = - if let Some((sk_name, sk_type)) = - sk_info(&key_info.key_schema, &key_info.attribute_definitions) - { - let sk_value = key.get(sk_name).ok_or_else(|| { - StorageError::Internal("missing sort key".to_owned()) - })?; - let sk = parse_sk(sk_value, sk_type)?; - let col = sk_column(sk_type).to_owned(); - // Store as the text representation used in Cassandra queries. - // sk_col ("sk_s"/"sk_n"/"sk_b") encodes the type; no separate type tag needed. - let val = match &sk { - SortKeyValue::S(s) => s.clone(), - SortKeyValue::N(n) => n.to_string(), - SortKeyValue::B(b) => { - use base64::Engine as _; - base64::engine::general_purpose::STANDARD.encode(b) - } - }; - (Some(col), Some(val)) - } else { - (None, None) + let (sk_col, sk_val) = if let Some((sk_name, sk_type)) = + sk_info(&key_info.key_schema, &key_info.attribute_definitions) + { + let sk_value = key + .get(sk_name) + .ok_or_else(|| StorageError::Internal("missing sort key".to_owned()))?; + let sk = parse_sk(sk_value, sk_type)?; + let col = sk_column(sk_type).to_owned(); + // Store as the text representation used in Cassandra queries. + // sk_col ("sk_s"/"sk_n"/"sk_b") encodes the type; no separate type tag needed. + let val = match &sk { + SortKeyValue::S(s) => s.clone(), + SortKeyValue::N(n) => n.to_string(), + SortKeyValue::B(b) => { + use base64::Engine as _; + base64::engine::general_purpose::STANDARD.encode(b) + } }; + (Some(col), Some(val)) + } else { + (None, None) + }; // For PUT, item_data is known immediately. // For UPDATE, item_data is filled in after PREPARE (update_ledger_blob). // For DELETE and CHECK, item_data is never needed. @@ -1245,7 +1254,10 @@ impl CassandraEngine { match state { Some(TransactionState::Committing) => { for op in &ops { - if let Err(e) = self.recover_commit_op(keyspace, op, txn_id, txn_timestamp).await { + if let Err(e) = self + .recover_commit_op(keyspace, op, txn_id, txn_timestamp) + .await + { tracing::error!("recover_transaction commit op {txn_id}: {e}"); return Err(e); } @@ -1289,7 +1301,8 @@ impl CassandraEngine { "PUT" | "UPDATE" => { let item_data = op.item_data.as_deref().ok_or_else(|| { StorageError::Internal(format!( - "ledger op {} missing item_data for {}", op.op, txn_id + "ledger op {} missing item_data for {}", + op.op, txn_id )) })?; @@ -1300,18 +1313,30 @@ impl CassandraEngine { IF prepared_txn_id = ?", ); query_with_item_ts_pk_sk_txnid( - &self.session, &query, item_data, txn_timestamp, - &op.pk, sk_val, txn_id_bytes, - ).await + &self.session, + &query, + item_data, + txn_timestamp, + &op.pk, + sk_val, + txn_id_bytes, + ) + .await } else { let query = format!( "UPDATE {keyspace}.{table} SET item_data = ?, prepared_txn_id = NULL, \ last_committed_txn_timestamp = ? WHERE pk = ? IF prepared_txn_id = ?", ); self.session - .query_with_values(&query, cdrs_tokio::query_values!( - item_data, txn_timestamp, op.pk.as_str(), txn_id_bytes - )) + .query_with_values( + &query, + cdrs_tokio::query_values!( + item_data, + txn_timestamp, + op.pk.as_str(), + txn_id_bytes + ), + ) .await .map_err(|e| StorageError::Internal(e.to_string())) } @@ -1330,24 +1355,36 @@ impl CassandraEngine { "UPDATE {keyspace}.{table} SET partition_max_delete_timestamp = ? \ WHERE pk = ? IF partition_max_delete_timestamp = null", ); - let r1 = self.session - .query_with_values(&update_null, cdrs_tokio::query_values!(txn_timestamp, op.pk.as_str())) + let r1 = self + .session + .query_with_values( + &update_null, + cdrs_tokio::query_values!(txn_timestamp, op.pk.as_str()), + ) .await .map_err(|e| StorageError::Internal(e.to_string())) - .map_err(|e| { tracing::error!("recover_commit_op delete max_ts null: {e}"); e })?; + .map_err(|e| { + tracing::error!("recover_commit_op delete max_ts null: {e}"); + e + })?; if check_lwt_applied(&r1, "recover_commit delete max_ts null").is_err() { let update_cmp = format!( "UPDATE {keyspace}.{table} SET partition_max_delete_timestamp = ? \ WHERE pk = ? IF partition_max_delete_timestamp < ?", ); - let _ = self.session - .query_with_values(&update_cmp, cdrs_tokio::query_values!( - txn_timestamp, op.pk.as_str(), txn_timestamp - )) + let _ = self + .session + .query_with_values( + &update_cmp, + cdrs_tokio::query_values!(txn_timestamp, op.pk.as_str(), txn_timestamp), + ) .await .map_err(|e| StorageError::Internal(e.to_string())) - .map_err(|e| { tracing::error!("recover_commit_op delete max_ts cmp: {e}"); e })?; + .map_err(|e| { + tracing::error!("recover_commit_op delete max_ts cmp: {e}"); + e + })?; } // Step 2: delete the row @@ -1356,13 +1393,17 @@ impl CassandraEngine { "DELETE FROM {keyspace}.{table} WHERE pk = ? AND {sk_col} = ? \ IF prepared_txn_id = ?", ); - query_with_pk_sk_txnid(&self.session, &query, &op.pk, sk_val, txn_id_bytes).await + query_with_pk_sk_txnid(&self.session, &query, &op.pk, sk_val, txn_id_bytes) + .await } else { let query = format!( "DELETE FROM {keyspace}.{table} WHERE pk = ? IF prepared_txn_id = ?", ); self.session - .query_with_values(&query, cdrs_tokio::query_values!(op.pk.as_str(), txn_id_bytes)) + .query_with_values( + &query, + cdrs_tokio::query_values!(op.pk.as_str(), txn_id_bytes), + ) .await .map_err(|e| StorageError::Internal(e.to_string())) } @@ -1374,7 +1415,9 @@ impl CassandraEngine { let _ = check_lwt_applied(&result, "recover_commit DELETE"); Ok(()) } - other => Err(StorageError::Internal(format!("unknown op in ledger: {other}"))), + other => Err(StorageError::Internal(format!( + "unknown op in ledger: {other}" + ))), } } @@ -1406,12 +1449,13 @@ impl CassandraEngine { "UPDATE {keyspace}.{table} SET prepared_txn_id = null \ WHERE pk = ? AND {sk_col} = ? IF prepared_txn_id = ?", ); - let dr = query_with_pk_sk_txnid( - &self.session, &dq, &op.pk, sk_val, txn_id_bytes.clone(), - ).await.map_err(|e| { - tracing::error!("recover_rollback_op delete: {e}"); - StorageError::Internal("Database error".to_owned()) - })?; + let dr = + query_with_pk_sk_txnid(&self.session, &dq, &op.pk, sk_val, txn_id_bytes.clone()) + .await + .map_err(|e| { + tracing::error!("recover_rollback_op delete: {e}"); + StorageError::Internal("Database error".to_owned()) + })?; (dr, uq) } else { let dq = format!( @@ -1422,11 +1466,18 @@ impl CassandraEngine { "UPDATE {keyspace}.{table} SET prepared_txn_id = null \ WHERE pk = ? IF prepared_txn_id = ?", ); - let dr = self.session - .query_with_values(&dq, cdrs_tokio::query_values!(op.pk.as_str(), txn_id_bytes.clone())) + let dr = self + .session + .query_with_values( + &dq, + cdrs_tokio::query_values!(op.pk.as_str(), txn_id_bytes.clone()), + ) .await .map_err(|e| StorageError::Internal(e.to_string())) - .map_err(|e| { tracing::error!("recover_rollback_op delete: {e}"); e })?; + .map_err(|e| { + tracing::error!("recover_rollback_op delete: {e}"); + e + })?; (dr, uq) }; @@ -1435,11 +1486,14 @@ impl CassandraEngine { } // Item was pre-existing - clear the transaction marker - let result = if let Some((sk_val, sk_col)) = &sk { + let result = if let Some((sk_val, _sk_col)) = &sk { query_with_pk_sk_txnid(&self.session, &update_query, &op.pk, sk_val, txn_id_bytes).await } else { self.session - .query_with_values(&update_query, cdrs_tokio::query_values!(op.pk.as_str(), txn_id_bytes)) + .query_with_values( + &update_query, + cdrs_tokio::query_values!(op.pk.as_str(), txn_id_bytes), + ) .await .map_err(|e| StorageError::Internal(e.to_string())) } @@ -1498,7 +1552,9 @@ fn check_lwt_applied(result: &Envelope, context: &str) -> Result<(), Cancellatio } /// Extract sort key from a `LedgerOp` as `Option<(SortKeyValue, col_name)>`. -fn ledger_sk(op: &crate::data::transaction_ledger::LedgerOp) -> Result, StorageError> { +fn ledger_sk( + op: &crate::data::transaction_ledger::LedgerOp, +) -> Result, StorageError> { match (&op.sk_col, &op.sk_val) { (Some(col), Some(val)) => Ok(Some((ledger_sk_to_sort_key(col, val)?, col.clone()))), _ => Ok(None), @@ -1512,9 +1568,9 @@ fn ledger_sk_to_sort_key(sk_col: &str, sk_val: &str) -> Result Ok(SortKeyValue::S(sk_val.to_owned())), "sk_n" => { - let d = sk_val - .parse::() - .map_err(|e| StorageError::Internal(format!("invalid numeric sk in ledger: {e}")))?; + let d = sk_val.parse::().map_err(|e| { + StorageError::Internal(format!("invalid numeric sk in ledger: {e}")) + })?; Ok(SortKeyValue::N(d)) } "sk_b" => { @@ -1524,6 +1580,8 @@ fn ledger_sk_to_sort_key(sk_col: &str, sk_val: &str) -> Result Err(StorageError::Internal(format!("unknown sk_col in ledger: {other}"))), + other => Err(StorageError::Internal(format!( + "unknown sk_col in ledger: {other}" + ))), } } diff --git a/crates/storage-cassandra/src/data/update_item.rs b/crates/storage-cassandra/src/data/update_item.rs index e8089c0b..937d1023 100644 --- a/crates/storage-cassandra/src/data/update_item.rs +++ b/crates/storage-cassandra/src/data/update_item.rs @@ -55,7 +55,8 @@ impl CassandraEngine { let sys_delay = if indexes.is_empty() { 0 } else { - self.gsi_default_delay_ms.load(std::sync::atomic::Ordering::Relaxed) + self.gsi_default_delay_ms + .load(std::sync::atomic::Ordering::Relaxed) }; // Fetch existing item (including prepared_txn_id for transaction conflict detection) @@ -276,14 +277,16 @@ impl CassandraEngine { }; if let Some(stmt) = stream_stmt { - batch = batch.add_query( - stmt, - cdrs_tokio::query::QueryValues::SimpleValues(vec![]), - ); + batch = + batch.add_query(stmt, cdrs_tokio::query::QueryValues::SimpleValues(vec![])); } self.session - .batch(batch.build().map_err(|e| StorageError::Internal(e.to_string()))?) + .batch( + batch + .build() + .map_err(|e| StorageError::Internal(e.to_string()))?, + ) .await .map_err(|e| StorageError::Internal(format!("Batch execution: {}", e)))?; @@ -357,14 +360,16 @@ impl CassandraEngine { }; if let Some(stmt) = stream_stmt { - batch = batch.add_query( - stmt, - cdrs_tokio::query::QueryValues::SimpleValues(vec![]), - ); + batch = + batch.add_query(stmt, cdrs_tokio::query::QueryValues::SimpleValues(vec![])); } self.session - .batch(batch.build().map_err(|e| StorageError::Internal(e.to_string()))?) + .batch( + batch + .build() + .map_err(|e| StorageError::Internal(e.to_string()))?, + ) .await .map_err(|e| StorageError::Internal(format!("Batch execution: {}", e)))?; diff --git a/crates/storage-cassandra/src/engine.rs b/crates/storage-cassandra/src/engine.rs index 1a20b4ed..218956ce 100644 --- a/crates/storage-cassandra/src/engine.rs +++ b/crates/storage-cassandra/src/engine.rs @@ -72,7 +72,9 @@ impl CassandraEngine { control_plane_notify: Arc::new(tokio::sync::Notify::new()), gsi_default_delay_ms: Arc::new(std::sync::atomic::AtomicU64::new(1000)), // Default 1 second gsi_queue: crate::gsi_queue::GsiQueue::new(), - hlc: crate::stream_util::new_shared_hlc(config.instance_id.as_deref().unwrap_or("default")), + hlc: crate::stream_util::new_shared_hlc( + config.instance_id.as_deref().unwrap_or("default"), + ), stream_retention_seconds: 108_000, // 30 hours; overridden by spawn_workers (Step 7) }) } diff --git a/crates/storage-cassandra/src/gsi_queue.rs b/crates/storage-cassandra/src/gsi_queue.rs index 4533701f..a53ce761 100644 --- a/crates/storage-cassandra/src/gsi_queue.rs +++ b/crates/storage-cassandra/src/gsi_queue.rs @@ -13,8 +13,8 @@ use extenddb_core::types::{AttributeDefinition, KeySchemaElement, Projection}; use serde::{Deserialize, Serialize}; -use tokio::sync::Notify; use std::sync::Arc; +use tokio::sync::Notify; /// Number of worker partitions. Fixed: changing while the queue is non-empty /// would split a key's rows across workers. diff --git a/crates/storage-cassandra/src/lib.rs b/crates/storage-cassandra/src/lib.rs index 85f2d816..96519ea9 100644 --- a/crates/storage-cassandra/src/lib.rs +++ b/crates/storage-cassandra/src/lib.rs @@ -27,8 +27,8 @@ pub mod operations; mod stream_engine; pub mod stream_util; pub mod table_engine; -mod update_table; mod table_helpers; +mod update_table; mod worker_store; pub mod workers; @@ -80,15 +80,20 @@ impl ServerRuntimeHooks for CassandraRuntimeHooks { // Read the initial GSI delay immediately so the atomic is correct from // the first request, not after the first 30s sleep. let gsi_delay = self.engine.gsi_default_delay_ms.clone(); - if let Ok(Some(val)) = ctx.catalog_store.get_setting("gsi_propagation_delay_ms").await { + if let Ok(Some(val)) = ctx + .catalog_store + .get_setting("gsi_propagation_delay_ms") + .await + { if let Ok(ms) = val.parse::() { gsi_delay.store(ms, std::sync::atomic::Ordering::Relaxed); } } let catalog_store_for_gsi = ctx.catalog_store.clone(); - let gsi_delay_poller = tokio::spawn(async move { - workers::poll_gsi_delay(catalog_store_for_gsi, gsi_delay).await - }); + let gsi_delay_poller = + tokio::spawn( + async move { workers::poll_gsi_delay(catalog_store_for_gsi, gsi_delay).await }, + ); // GSI propagation workers (one per partition). let guard = workers::spawn_gsi_workers(self.engine.clone()); @@ -136,8 +141,13 @@ pub fn backend() -> extenddb_storage::Backend { Box::pin(async move { let catalog_store = make_catalog_store_from_connection_string(&connection_string) .await - .map_err(extenddb_storage::settings_store::SettingsStoreError::ConnectionFailed)?; - Ok(Box::new(catalog_store) as Box) + .map_err( + extenddb_storage::settings_store::SettingsStoreError::ConnectionFailed, + )?; + Ok(Box::new(catalog_store) + as Box< + dyn extenddb_storage::management_store::SettingsStore, + >) }) }, diagnostics_store: |connection_string| { @@ -146,7 +156,8 @@ pub fn backend() -> extenddb_storage::Backend { let catalog_store = make_catalog_store_from_connection_string(&connection_string) .await .map_err(extenddb_storage::diagnostics_store::DiagnosticsStoreError::ConnectionFailed)?; - Ok(Box::new(catalog_store) as Box) + Ok(Box::new(catalog_store) + as Box) }) }, server_components: server_components_factory, @@ -240,9 +251,8 @@ fn server_components_factory( // Load encryption key from catalog. let catalog_keyspace = format!("{}_catalog", cassandra_config.keyspace_prefix); - let enc_key_query = format!( - "SELECT value FROM {catalog_keyspace}.settings WHERE key = 'encryption_key'" - ); + let enc_key_query = + format!("SELECT value FROM {catalog_keyspace}.settings WHERE key = 'encryption_key'"); let enc_key_result = engine.session.query(&enc_key_query).await.map_err(|e| { BackendError::InitializationFailed(format!("Failed to load encryption key: {e}")) })?; diff --git a/crates/storage-cassandra/src/management_store/access_keys.rs b/crates/storage-cassandra/src/management_store/access_keys.rs index 4e37b867..17161161 100755 --- a/crates/storage-cassandra/src/management_store/access_keys.rs +++ b/crates/storage-cassandra/src/management_store/access_keys.rs @@ -3,9 +3,9 @@ //! Access key, session, and caller-tag operations for `CassandraCatalogStore`. +use crate::catalog_store::CassandraCatalogStore; use cdrs_tokio::types::blob::Blob; use extenddb_storage::management_store::{AccessKeyCreated, OpError, OpResult}; -use crate::catalog_store::CassandraCatalogStore; impl CassandraCatalogStore { // ── Access keys ──────────────────────────────────────────────── diff --git a/crates/storage-cassandra/src/management_store/accounts.rs b/crates/storage-cassandra/src/management_store/accounts.rs index 3b106b93..846ff472 100644 --- a/crates/storage-cassandra/src/management_store/accounts.rs +++ b/crates/storage-cassandra/src/management_store/accounts.rs @@ -405,9 +405,8 @@ impl CassandraCatalogStore { pub(crate) async fn get_default_account_id_impl(&self) -> OpResult> { let keyspace = self.catalog_keyspace(); - let query = format!( - "SELECT value FROM {keyspace}.settings WHERE key = 'default_account_id'" - ); + let query = + format!("SELECT value FROM {keyspace}.settings WHERE key = 'default_account_id'"); let rows = crate::cassandra_util::query_rows::( &self.session(), &query, @@ -419,16 +418,13 @@ impl CassandraCatalogStore { tracing::error!("default_account_id: {e}"); extenddb_storage::management_store::OpError::Internal("Database error".to_owned()) })?; - Ok(rows - .into_iter() - .next() - .and_then(|row| { - crate::cassandra_util::get_column::( - &row, - "value", - "default_account_id", - ) - .ok() - })) + Ok(rows.into_iter().next().and_then(|row| { + crate::cassandra_util::get_column::( + &row, + "value", + "default_account_id", + ) + .ok() + })) } } diff --git a/crates/storage-cassandra/src/management_store/groups.rs b/crates/storage-cassandra/src/management_store/groups.rs index bc8c2cd6..381c4bd8 100644 --- a/crates/storage-cassandra/src/management_store/groups.rs +++ b/crates/storage-cassandra/src/management_store/groups.rs @@ -35,7 +35,9 @@ impl CassandraCatalogStore { .await?; if !applied { - return Err(OpError::AlreadyExists("IAM group already exists".to_owned())); + return Err(OpError::AlreadyExists( + "IAM group already exists".to_owned(), + )); } Ok(()) diff --git a/crates/storage-cassandra/src/management_store/users.rs b/crates/storage-cassandra/src/management_store/users.rs index 07ec138a..3a994c2b 100644 --- a/crates/storage-cassandra/src/management_store/users.rs +++ b/crates/storage-cassandra/src/management_store/users.rs @@ -3,9 +3,9 @@ //! User management operations for `CassandraCatalogStore`. +use crate::catalog_store::CassandraCatalogStore; use cdrs_tokio::types::IntoRustByName; use extenddb_storage::management_store::{OpError, OpResult, UserDetail}; -use crate::catalog_store::CassandraCatalogStore; impl CassandraCatalogStore { pub(crate) async fn create_user_impl( diff --git a/crates/storage-cassandra/src/metadata_engine.rs b/crates/storage-cassandra/src/metadata_engine.rs index 24238f62..e07872c6 100755 --- a/crates/storage-cassandra/src/metadata_engine.rs +++ b/crates/storage-cassandra/src/metadata_engine.rs @@ -40,7 +40,14 @@ impl MetadataEngine for CassandraEngine { catalog ); self.session_arc() - .query_with_values(&query, cdrs_tokio::query_values!(arn.as_str(), tag.key.as_str(), tag.value.as_str())) + .query_with_values( + &query, + cdrs_tokio::query_values!( + arn.as_str(), + tag.key.as_str(), + tag.value.as_str() + ), + ) .await .map_err(|e| { tracing::error!("tag_resource: {e}"); @@ -66,7 +73,10 @@ impl MetadataEngine for CassandraEngine { catalog ); self.session_arc() - .query_with_values(&query, cdrs_tokio::query_values!(arn.as_str(), key.as_str())) + .query_with_values( + &query, + cdrs_tokio::query_values!(arn.as_str(), key.as_str()), + ) .await .map_err(|e| { tracing::error!("untag_resource: {e}"); @@ -85,7 +95,8 @@ impl MetadataEngine for CassandraEngine { "SELECT tag_key, tag_value FROM {}.tags WHERE resource_arn = ?", catalog ); - let result = self.session_arc() + let result = self + .session_arc() .query_with_values(&query, cdrs_tokio::query_values!(arn.as_str())) .await .map_err(|e| { @@ -102,7 +113,8 @@ impl MetadataEngine for CassandraEngine { let mut tags = Vec::with_capacity(rows.len()); for row in rows { let key: String = crate::cassandra_util::get_column(&row, "tag_key", "list_tags")?; - let value: String = crate::cassandra_util::get_column(&row, "tag_value", "list_tags")?; + let value: String = + crate::cassandra_util::get_column(&row, "tag_value", "list_tags")?; tags.push(Tag { key, value }); } Ok(tags) diff --git a/crates/storage-cassandra/src/stream_engine.rs b/crates/storage-cassandra/src/stream_engine.rs index 5647eaef..c503f03d 100755 --- a/crates/storage-cassandra/src/stream_engine.rs +++ b/crates/storage-cassandra/src/stream_engine.rs @@ -33,9 +33,7 @@ impl CassandraEngine { })?; let catalog_keyspace = self.catalog_keyspace(); - let query = format!( - "SELECT account_id FROM {catalog_keyspace}.tables WHERE table_id = ?" - ); + let query = format!("SELECT account_id FROM {catalog_keyspace}.tables WHERE table_id = ?"); let result = self .session .query_with_values(&query, cdrs_tokio::query_values!(table_id)) @@ -50,7 +48,9 @@ impl CassandraEngine { .unwrap_or_default() .into_iter() .next() - .ok_or_else(|| StorageError::TableNotFound(format!("No table found for shard {shard_id}")))? + .ok_or_else(|| { + StorageError::TableNotFound(format!("No table found for shard {shard_id}")) + })? .get_r_by_name("account_id") .map_err(|e| StorageError::Internal(e.to_string()))?; @@ -66,17 +66,16 @@ impl CassandraEngine { shard_id: &str, caller_account_id: &str, ) -> Result { - let without_prefix = shard_id.strip_prefix("shardId-").ok_or_else(|| { - StorageError::Validation("Invalid ShardIterator".to_owned()) - })?; - let table_id = without_prefix.rsplitn(2, '-').nth(1).ok_or_else(|| { - StorageError::Validation("Invalid ShardIterator".to_owned()) - })?; + let without_prefix = shard_id + .strip_prefix("shardId-") + .ok_or_else(|| StorageError::Validation("Invalid ShardIterator".to_owned()))?; + let table_id = without_prefix + .rsplitn(2, '-') + .nth(1) + .ok_or_else(|| StorageError::Validation("Invalid ShardIterator".to_owned()))?; let catalog_keyspace = self.catalog_keyspace(); - let query = format!( - "SELECT account_id FROM {catalog_keyspace}.tables WHERE table_id = ?" - ); + let query = format!("SELECT account_id FROM {catalog_keyspace}.tables WHERE table_id = ?"); let result = self .session .query_with_values(&query, cdrs_tokio::query_values!(table_id)) @@ -157,7 +156,10 @@ impl StreamEngine for CassandraEngine { let result = if let Some(ref after) = after_sequence { self.session - .query_with_values(&query, cdrs_tokio::query_values!(shard_id.as_str(), after.as_str())) + .query_with_values( + &query, + cdrs_tokio::query_values!(shard_id.as_str(), after.as_str()), + ) .await } else { self.session @@ -238,10 +240,8 @@ impl StreamEngine for CassandraEngine { let ks_json: String = row .get_r_by_name("key_schema") .map_err(|e| StorageError::Internal(e.to_string()))?; - let stream_spec_json: Option = row - .get_by_name("stream_specification") - .ok() - .flatten(); + let stream_spec_json: Option = + row.get_by_name("stream_specification").ok().flatten(); let table_status: String = row .get_r_by_name("table_status") .map_err(|e| StorageError::Internal(e.to_string()))?; @@ -288,10 +288,7 @@ impl StreamEngine for CassandraEngine { .await } else { self.session - .query_with_values( - &shard_query, - cdrs_tokio::query_values!(table_id.as_str()), - ) + .query_with_values(&shard_query, cdrs_tokio::query_values!(table_id.as_str())) .await } .map_err(|e| StorageError::Internal(e.to_string()))?; @@ -306,7 +303,9 @@ impl StreamEngine for CassandraEngine { let limit_usize = limit_val as usize; let last_shard = if shard_rows.len() > limit_usize { let row = &shard_rows[limit_usize - 1]; - let id: String = row.get_r_by_name("shard_id").map_err(|e| StorageError::Internal(e.to_string()))?; + let id: String = row + .get_r_by_name("shard_id") + .map_err(|e| StorageError::Internal(e.to_string()))?; Some(id) } else { None @@ -316,10 +315,16 @@ impl StreamEngine for CassandraEngine { .into_iter() .take(limit_usize) .map(|row| { - let shard_id: String = row.get_r_by_name("shard_id").map_err(|e| StorageError::Internal(e.to_string()))?; - let parent_shard_id: Option = row.get_by_name("parent_shard_id").ok().flatten(); - let starting: String = row.get_r_by_name("starting_sequence_number").map_err(|e| StorageError::Internal(e.to_string()))?; - let ending: Option = row.get_by_name("ending_sequence_number").ok().flatten(); + let shard_id: String = row + .get_r_by_name("shard_id") + .map_err(|e| StorageError::Internal(e.to_string()))?; + let parent_shard_id: Option = + row.get_by_name("parent_shard_id").ok().flatten(); + let starting: String = row + .get_r_by_name("starting_sequence_number") + .map_err(|e| StorageError::Internal(e.to_string()))?; + let ending: Option = + row.get_by_name("ending_sequence_number").ok().flatten(); Ok(Shard { shard_id, parent_shard_id, @@ -409,14 +414,15 @@ impl StreamEngine for CassandraEngine { // Sort for stable pagination (table_name, stream_label) summaries.sort_by(|a, b| { - a.table_name.cmp(&b.table_name).then(a.stream_label.cmp(&b.stream_label)) + a.table_name + .cmp(&b.table_name) + .then(a.stream_label.cmp(&b.stream_label)) }); // Apply cursor if let Some((start_table, start_label)) = start_cursor { - summaries.retain(|s| { - (&s.table_name, &s.stream_label) > (&start_table, &start_label) - }); + summaries + .retain(|s| (&s.table_name, &s.stream_label) > (&start_table, &start_label)); } #[allow(clippy::cast_sign_loss)] @@ -461,7 +467,11 @@ impl StreamEngine for CassandraEngine { } fn next_sequence_number(&self, _shard_id: &str) -> BoxFuture<'_, Result> { - let seq = self.hlc.lock().unwrap_or_else(|e| e.into_inner()).generate(); + let seq = self + .hlc + .lock() + .unwrap_or_else(|e| e.into_inner()) + .generate(); Box::pin(async move { Ok(seq) }) } @@ -570,9 +580,10 @@ impl StreamEngine for CassandraEngine { .into_rows() .unwrap_or_default(); - Ok(rows.into_iter().next().map(|row| { - row.get_r_by_name("sequence_number").unwrap_or_default() - })) + Ok(rows + .into_iter() + .next() + .map(|row| row.get_r_by_name("sequence_number").unwrap_or_default())) }) } } diff --git a/crates/storage-cassandra/src/stream_util.rs b/crates/storage-cassandra/src/stream_util.rs index 4f49341b..be58ec18 100644 --- a/crates/storage-cassandra/src/stream_util.rs +++ b/crates/storage-cassandra/src/stream_util.rs @@ -241,13 +241,19 @@ mod tests { fn test_sequence_numeric() { let mut clock = HybridClock::new(9999); let seq = clock.generate(); - assert!(seq.chars().all(|c| c.is_ascii_digit()), "sequence must be numeric: {seq}"); + assert!( + seq.chars().all(|c| c.is_ascii_digit()), + "sequence must be numeric: {seq}" + ); } #[test] fn test_node_id_range() { let id = HybridClock::derive_node_id("localhost:9042"); - assert!((1..=9999).contains(&id), "node_id must be in 1..=9999, got {id}"); + assert!( + (1..=9999).contains(&id), + "node_id must be in 1..=9999, got {id}" + ); } #[test] @@ -269,6 +275,9 @@ mod tests { #[test] fn test_assign_shard_id_format() { let s = assign_shard_id("pk", "my-table-id"); - assert!(s.starts_with("shardId-my-table-id-"), "unexpected format: {s}"); + assert!( + s.starts_with("shardId-my-table-id-"), + "unexpected format: {s}" + ); } } diff --git a/crates/storage-cassandra/src/update_table.rs b/crates/storage-cassandra/src/update_table.rs index b14c12fc..7791819a 100644 --- a/crates/storage-cassandra/src/update_table.rs +++ b/crates/storage-cassandra/src/update_table.rs @@ -41,8 +41,7 @@ impl CassandraEngine { if status != "ACTIVE" { return Err(StorageError::TableNotActive(input.table_name.clone())); } - let table_id: String = - crate::cassandra_util::get_column(&row, "table_id", "update_table")?; + let table_id: String = crate::cassandra_util::get_column(&row, "table_id", "update_table")?; let ks_json: String = crate::cassandra_util::get_column(&row, "key_schema", "update_table")?; let ad_json: String = @@ -61,8 +60,14 @@ impl CassandraEngine { let (cur_rcu, cur_wcu) = current_pt_str .and_then(|s| serde_json::from_str::(&s).ok()) .map(|v| { - let rcu = v.get("ReadCapacityUnits").and_then(|x| x.as_i64()).unwrap_or(0); - let wcu = v.get("WriteCapacityUnits").and_then(|x| x.as_i64()).unwrap_or(0); + let rcu = v + .get("ReadCapacityUnits") + .and_then(|x| x.as_i64()) + .unwrap_or(0); + let wcu = v + .get("WriteCapacityUnits") + .and_then(|x| x.as_i64()) + .unwrap_or(0); (rcu, wcu) }) .unwrap_or((0, 0)); @@ -114,8 +119,8 @@ impl CassandraEngine { } if let Some(pt) = &input.provisioned_throughput { - let pt_json = serde_json::to_string(pt) - .map_err(|e| StorageError::Internal(e.to_string()))?; + let pt_json = + serde_json::to_string(pt).map_err(|e| StorageError::Internal(e.to_string()))?; add_update!("provisioned_throughput", pt_json.as_str()); } @@ -128,8 +133,8 @@ impl CassandraEngine { } if let Some(odt) = &input.on_demand_throughput { - let odt_json = serde_json::to_string(odt) - .map_err(|e| StorageError::Internal(e.to_string()))?; + let odt_json = + serde_json::to_string(odt).map_err(|e| StorageError::Internal(e.to_string()))?; add_update!("on_demand_throughput", odt_json.as_str()); } @@ -138,8 +143,8 @@ impl CassandraEngine { let mut needs_shard_init = false; let mut needs_label_restore = false; if let Some(spec) = &input.stream_specification { - let spec_json = serde_json::to_string(spec) - .map_err(|e| StorageError::Internal(e.to_string()))?; + let spec_json = + serde_json::to_string(spec).map_err(|e| StorageError::Internal(e.to_string()))?; add_update!("stream_specification", spec_json.as_str()); if spec.stream_enabled { @@ -215,10 +220,7 @@ impl CassandraEngine { "SELECT index_name FROM {catalog_ks}.indexes \ WHERE table_id = ? AND index_name = ? ALLOW FILTERING" ), - cdrs_tokio::query_values!( - table_id.as_str(), - create.index_name.as_str() - ), + cdrs_tokio::query_values!(table_id.as_str(), create.index_name.as_str()), "update_table gsi duplicate check", ) .await?; @@ -271,16 +273,16 @@ impl CassandraEngine { "SELECT index_id FROM {catalog_ks}.indexes \ WHERE table_id = ? AND index_name = ? ALLOW FILTERING" ), - cdrs_tokio::query_values!( - table_id.as_str(), - delete.index_name.as_str() - ), + cdrs_tokio::query_values!(table_id.as_str(), delete.index_name.as_str()), "update_table gsi delete check", ) .await? .ok_or_else(|| StorageError::IndexNotFound(delete.index_name.clone()))?; - let index_id: String = - crate::cassandra_util::get_column(&existing, "index_id", "update_table gsi delete")?; + let index_id: String = crate::cassandra_util::get_column( + &existing, + "index_id", + "update_table gsi delete", + )?; batch = batch.add_query( format!( diff --git a/crates/storage-cassandra/src/workers.rs b/crates/storage-cassandra/src/workers.rs index 58b9326d..6a258801 100644 --- a/crates/storage-cassandra/src/workers.rs +++ b/crates/storage-cassandra/src/workers.rs @@ -118,7 +118,8 @@ pub(crate) async fn poll_transaction_recovery( for entry in entries { tracing::info!( "transaction_recovery: recovering txn {} (state={}) in {keyspace}", - entry.txn_id, entry.state + entry.txn_id, + entry.state ); if let Err(e) = engine.recover_transaction(keyspace, entry.txn_id).await { tracing::warn!( @@ -132,15 +133,21 @@ pub(crate) async fn poll_transaction_recovery( } /// List all account keyspaces for this engine (keyspaces matching `{prefix}_account_*`). -async fn list_account_keyspaces(engine: &CassandraEngine) -> Result, extenddb_storage::error::StorageError> { +async fn list_account_keyspaces( + engine: &CassandraEngine, +) -> Result, extenddb_storage::error::StorageError> { let prefix = format!("{}_account_", engine.keyspace_prefix); let rows = engine .session .query("SELECT keyspace_name FROM system_schema.keyspaces") .await - .map_err(|e| extenddb_storage::error::StorageError::Internal(format!("list keyspaces: {e}")))? + .map_err(|e| { + extenddb_storage::error::StorageError::Internal(format!("list keyspaces: {e}")) + })? .response_body() - .map_err(|e| extenddb_storage::error::StorageError::Internal(format!("list keyspaces body: {e}")))? + .map_err(|e| { + extenddb_storage::error::StorageError::Internal(format!("list keyspaces body: {e}")) + })? .into_rows() .unwrap_or_default(); @@ -175,7 +182,8 @@ pub struct GsiWorkerGuard { impl Drop for GsiWorkerGuard { fn drop(&mut self) { - self.shutdown.store(true, std::sync::atomic::Ordering::Relaxed); + self.shutdown + .store(true, std::sync::atomic::Ordering::Relaxed); } } @@ -194,7 +202,11 @@ pub fn spawn_gsi_workers(engine: Arc) -> GsiWorkerGuard { GsiWorkerGuard { shutdown } } -async fn gsi_worker(worker_id: u64, engine: Arc, shutdown: Arc) { +async fn gsi_worker( + worker_id: u64, + engine: Arc, + shutdown: Arc, +) { const MAX_IDLE: Duration = Duration::from_secs(1); tracing::debug!("GSI worker {worker_id} started"); @@ -225,10 +237,7 @@ async fn gsi_worker(worker_id: u64, engine: Arc, shutdown: Arc< } /// Time until the earliest not-yet-due row in this partition becomes eligible. -async fn gsi_next_ready_wait( - worker_id: u64, - engine: &CassandraEngine, -) -> Option { +async fn gsi_next_ready_wait(worker_id: u64, engine: &CassandraEngine) -> Option { // We need to find the minimum ready_at across all account keyspaces. // For simplicity, use MAX_IDLE as the wait — the worker will re-check // promptly. A more precise implementation would query each keyspace. @@ -269,17 +278,17 @@ async fn gsi_process_batch( { Ok(rows) => rows, Err(ref e) if is_table_not_found(e) => { - tracing::warn!("GSI worker {worker_id}: {keyspace}.gsi_pending not found, skipping keyspace"); + tracing::warn!( + "GSI worker {worker_id}: {keyspace}.gsi_pending not found, skipping keyspace" + ); continue; } Err(e) => return Err(e), }; for row in rows { - let ready_at: i64 = - crate::cassandra_util::get_column(&row, "ready_at", "gsi_worker")?; - let id: uuid::Uuid = - crate::cassandra_util::get_column(&row, "id", "gsi_worker")?; + let ready_at: i64 = crate::cassandra_util::get_column(&row, "ready_at", "gsi_worker")?; + let id: uuid::Uuid = crate::cassandra_util::get_column(&row, "id", "gsi_worker")?; let table_id: String = crate::cassandra_util::get_column(&row, "table_id", "gsi_worker")?; let old_json: Option = row.get_by_name("old_item").ok().flatten(); @@ -295,9 +304,8 @@ async fn gsi_process_batch( .map(|s| serde_json::from_str(&s)) .transpose() .map_err(|e| extenddb_storage::error::StorageError::Internal(e.to_string()))?; - let context: crate::gsi_queue::GsiApplyContext = - serde_json::from_str(&ctx_json) - .map_err(|e| extenddb_storage::error::StorageError::Internal(e.to_string()))?; + let context: crate::gsi_queue::GsiApplyContext = serde_json::from_str(&ctx_json) + .map_err(|e| extenddb_storage::error::StorageError::Internal(e.to_string()))?; // Apply the index update. If the index table is gone (table deleted), // log and skip — the row is still deleted below. @@ -386,8 +394,12 @@ async fn gsi_apply_index( if let Some(new) = new_item { if item_has_index_keys(new, &idx.key_schema) { - let projected = - project_item_for_index(new, &idx.key_schema, &context.base_key_schema, &idx.projection); + let projected = project_item_for_index( + new, + &idx.key_schema, + &context.base_key_schema, + &idx.projection, + ); insert_index_row_multi( &mut batch, account_keyspace, @@ -407,11 +419,9 @@ async fn gsi_apply_index( .build() .map_err(|e| extenddb_storage::error::StorageError::Internal(e.to_string()))?; if !built.request.queries.is_empty() { - engine - .session - .batch(built) - .await - .map_err(|e| extenddb_storage::error::StorageError::Internal(format!("gsi_apply: {e}")))?; + engine.session.batch(built).await.map_err(|e| { + extenddb_storage::error::StorageError::Internal(format!("gsi_apply: {e}")) + })?; } Ok(()) From aa0531419c33a377f583af08f371b7b5cbf5a739 Mon Sep 17 00:00:00 2001 From: Joel Shepherd Date: Fri, 28 Aug 2026 19:53:12 +0000 Subject: [PATCH 06/48] chore: clippy pass on storage-postgres --- .../src/authorization_store.rs | 5 +-- crates/storage-cassandra/src/bootstrapper.rs | 5 +-- crates/storage-cassandra/src/catalog_store.rs | 17 ++++----- crates/storage-cassandra/src/config.rs | 4 +- crates/storage-cassandra/src/create_table.rs | 5 +-- crates/storage-cassandra/src/data/index.rs | 10 ++--- .../src/data/put_get_item.rs | 10 ++--- crates/storage-cassandra/src/data/query.rs | 2 +- crates/storage-cassandra/src/data/scan.rs | 5 +-- .../src/data/transaction_ledger.rs | 7 ++-- .../src/data/transactions.rs | 21 +++++----- .../storage-cassandra/src/data/update_item.rs | 2 +- crates/storage-cassandra/src/lib.rs | 4 +- .../src/management_store/accounts.rs | 2 +- .../storage-cassandra/src/metadata_engine.rs | 38 +++++++++---------- crates/storage-cassandra/src/operations.rs | 5 +-- crates/storage-cassandra/src/stream_engine.rs | 13 +++---- crates/storage-cassandra/src/update_table.rs | 6 +-- crates/storage-cassandra/src/workers.rs | 15 +++----- 19 files changed, 76 insertions(+), 100 deletions(-) diff --git a/crates/storage-cassandra/src/authorization_store.rs b/crates/storage-cassandra/src/authorization_store.rs index bf792aa7..3d059e44 100755 --- a/crates/storage-cassandra/src/authorization_store.rs +++ b/crates/storage-cassandra/src/authorization_store.rs @@ -306,8 +306,8 @@ impl AuthorizationStore for CassandraCatalogStore { let session_tags_text: Option = row.get_r_by_name("session_tags").ok(); let mut session_tags = Vec::new(); - if let Some(tags_text) = session_tags_text { - if let Ok(tags_val) = serde_json::from_str::(&tags_text) { + if let Some(tags_text) = session_tags_text + && let Ok(tags_val) = serde_json::from_str::(&tags_text) { if let Some(arr) = tags_val.as_array() { for tag in arr { if let (Some(k), Some(v)) = ( @@ -325,7 +325,6 @@ impl AuthorizationStore for CassandraCatalogStore { } } } - } Ok(Some(SessionData { session_policy, diff --git a/crates/storage-cassandra/src/bootstrapper.rs b/crates/storage-cassandra/src/bootstrapper.rs index 0bfd2779..44da55de 100644 --- a/crates/storage-cassandra/src/bootstrapper.rs +++ b/crates/storage-cassandra/src/bootstrapper.rs @@ -133,14 +133,13 @@ impl CassandraBootstrapper { "--extenddb-pass", )?; - if let Some(ref cli_prefix) = keyspace_prefix { - if cli_prefix != &config.keyspace_prefix { + if let Some(ref cli_prefix) = keyspace_prefix + && cli_prefix != &config.keyspace_prefix { return Err(StorageError::Internal(format!( "--keyspace-prefix '{}' conflicts with config file keyspace prefix '{}'", cli_prefix, config.keyspace_prefix ))); } - } if let Some(ref cli_rf) = replication_factor { let cli_rf_val = cli_rf.parse::().map_err(|_| { diff --git a/crates/storage-cassandra/src/catalog_store.rs b/crates/storage-cassandra/src/catalog_store.rs index fa796e74..47bbd623 100644 --- a/crates/storage-cassandra/src/catalog_store.rs +++ b/crates/storage-cassandra/src/catalog_store.rs @@ -107,7 +107,7 @@ impl CassandraCatalogStore { .await .ok() .and_then(|r| r.response_body().ok()) - .map(|b| b.into_rows().unwrap_or_default().len() > 0) + .map(|b| !b.into_rows().unwrap_or_default().is_empty()) .unwrap_or(false); if exists_result { @@ -311,14 +311,13 @@ impl extenddb_storage::diagnostics::DiagnosticsStore for CassandraCatalogStore { extenddb_storage::diagnostics::DiagError::QueryFailed(e.to_string()) })?; - if let Some(rows) = body.into_rows() { - if let Some(row) = rows.first() { + if let Some(rows) = body.into_rows() + && let Some(row) = rows.first() { let count: i64 = row.get_r_by_name("count").map_err(|e| { extenddb_storage::diagnostics::DiagError::QueryFailed(e.to_string()) })?; return Ok(count); } - } Ok(0) }) @@ -337,14 +336,13 @@ impl extenddb_storage::diagnostics::DiagnosticsStore for CassandraCatalogStore { extenddb_storage::diagnostics::DiagError::QueryFailed(e.to_string()) })?; - if let Some(rows) = body.into_rows() { - if let Some(row) = rows.first() { + if let Some(rows) = body.into_rows() + && let Some(row) = rows.first() { let count: i64 = row.get_r_by_name("count").map_err(|e| { extenddb_storage::diagnostics::DiagError::QueryFailed(e.to_string()) })?; return Ok(count); } - } Ok(0) }) @@ -374,8 +372,8 @@ impl extenddb_storage::diagnostics::DiagnosticsStore for CassandraCatalogStore { extenddb_storage::diagnostics::DiagError::QueryFailed(e.to_string()) })?; - if let Some(rows) = body.into_rows() { - if let Some(row) = rows.first() { + if let Some(rows) = body.into_rows() + && let Some(row) = rows.first() { let account_id: String = row.get_r_by_name("account_id").map_err(|e| { extenddb_storage::diagnostics::DiagError::QueryFailed(e.to_string()) })?; @@ -394,7 +392,6 @@ impl extenddb_storage::diagnostics::DiagnosticsStore for CassandraCatalogStore { return Ok(account_keyspace); } - } // No accounts exist yet - that's okay, just return a message Ok("No account keyspaces exist yet".to_string()) diff --git a/crates/storage-cassandra/src/config.rs b/crates/storage-cassandra/src/config.rs index f63ab5aa..de38e0bf 100644 --- a/crates/storage-cassandra/src/config.rs +++ b/crates/storage-cassandra/src/config.rs @@ -117,9 +117,7 @@ impl extenddb_storage::config::StorageConfig for CassandraStorageConfig { fn connection_config(&self) -> &str { // Return JDBC-style connection string: host1,host2/keyspace_prefix // This allows factories to parse both contact points and keyspace - self.cached_connection_string - .as_ref() - .map(|s| s.as_str()) + self.cached_connection_string.as_deref() .unwrap_or("(no connection string)") } diff --git a/crates/storage-cassandra/src/create_table.rs b/crates/storage-cassandra/src/create_table.rs index 04be381c..18ca7c54 100644 --- a/crates/storage-cassandra/src/create_table.rs +++ b/crates/storage-cassandra/src/create_table.rs @@ -212,8 +212,8 @@ impl CassandraEngine { .response_body() .map_err(|e| StorageError::Internal(format!("Failed to get response body: {}", e)))?; - if let Some(rows) = body.into_rows() { - if let Some(row) = rows.first() { + if let Some(rows) = body.into_rows() + && let Some(row) = rows.first() { use cdrs_tokio::types::IntoRustByName; let applied: bool = row.get_r_by_name("[applied]").map_err(|e| { StorageError::Internal(format!("Failed to parse [applied]: {}", e)) @@ -223,7 +223,6 @@ impl CassandraEngine { return Err(StorageError::TableAlreadyExists(input.table_name.clone())); } } - } // Insert GSI metadata let mut gsi_index_ids: Vec = Vec::new(); diff --git a/crates/storage-cassandra/src/data/index.rs b/crates/storage-cassandra/src/data/index.rs index c93e38fa..dbf7fc5e 100644 --- a/crates/storage-cassandra/src/data/index.rs +++ b/crates/storage-cassandra/src/data/index.rs @@ -231,8 +231,8 @@ pub fn sync_indexes( let base_sks = all_sort_key_info(base_key_schema, attr_defs); // Delete old index row if the old item had index keys - if let Some(old) = old_item { - if item_has_index_keys(old, &idx.key_schema) { + if let Some(old) = old_item + && item_has_index_keys(old, &idx.key_schema) { delete_index_row_multi( batch, account_keyspace, @@ -244,11 +244,10 @@ pub fn sync_indexes( &base_sks, )?; } - } // Insert new index row if the new item has index keys - if let Some(new) = new_item { - if item_has_index_keys(new, &idx.key_schema) { + if let Some(new) = new_item + && item_has_index_keys(new, &idx.key_schema) { let projected = project_item_for_index(new, &idx.key_schema, base_key_schema, &idx.projection); insert_index_row_multi( @@ -263,7 +262,6 @@ pub fn sync_indexes( &base_sks, )?; } - } } Ok(()) } diff --git a/crates/storage-cassandra/src/data/put_get_item.rs b/crates/storage-cassandra/src/data/put_get_item.rs index 62c70cf6..ed6ffc09 100644 --- a/crates/storage-cassandra/src/data/put_get_item.rs +++ b/crates/storage-cassandra/src/data/put_get_item.rs @@ -403,14 +403,13 @@ impl CassandraEngine { .response_body() .map_err(|e| StorageError::Internal(format!("Parse response: {}", e)))?; - if let Some(rows) = body.into_rows() { - if let Some(row) = rows.into_iter().next() { + if let Some(rows) = body.into_rows() + && let Some(row) = rows.into_iter().next() { let item_data: String = row .get_r_by_name("item_data") .map_err(|e| StorageError::Internal(format!("Parse item_data: {}", e)))?; return Ok(Some(json_to_item(item_data)?)); } - } Ok(None) } else { @@ -430,14 +429,13 @@ impl CassandraEngine { .response_body() .map_err(|e| StorageError::Internal(format!("Parse response: {}", e)))?; - if let Some(rows) = body.into_rows() { - if let Some(row) = rows.into_iter().next() { + if let Some(rows) = body.into_rows() + && let Some(row) = rows.into_iter().next() { let item_data: String = row .get_r_by_name("item_data") .map_err(|e| StorageError::Internal(format!("Parse item_data: {}", e)))?; return Ok(Some(json_to_item(item_data)?)); } - } Ok(None) } diff --git a/crates/storage-cassandra/src/data/query.rs b/crates/storage-cassandra/src/data/query.rs index ae5d0b9f..701d79e1 100644 --- a/crates/storage-cassandra/src/data/query.rs +++ b/crates/storage-cassandra/src/data/query.rs @@ -498,7 +498,7 @@ impl CassandraEngine { &self.session_arc(), &query1, &pk_text, - &base_pk_text, + base_pk_text, base_sk, "same_sk", ) diff --git a/crates/storage-cassandra/src/data/scan.rs b/crates/storage-cassandra/src/data/scan.rs index bb120908..cd20d435 100644 --- a/crates/storage-cassandra/src/data/scan.rs +++ b/crates/storage-cassandra/src/data/scan.rs @@ -379,12 +379,11 @@ impl crate::CassandraEngine { cols.push("base_pk".to_owned()); binds.push(Value::from(base_pk_text)); - if let Some((base_sk_name, base_sk_type)) = base_sk_info_opt { - if let Some(av) = start_key.get(base_sk_name) { + if let Some((base_sk_name, base_sk_type)) = base_sk_info_opt + && let Some(av) = start_key.get(base_sk_name) { cols.push(format!("base_{}", sk_column(base_sk_type))); binds.push(sk_to_value(&parse_sk(av, base_sk_type)?)); } - } Ok(Some((format_clustering_comparison(&cols), binds))) } diff --git a/crates/storage-cassandra/src/data/transaction_ledger.rs b/crates/storage-cassandra/src/data/transaction_ledger.rs index bc4fbaf7..ef4129d1 100644 --- a/crates/storage-cassandra/src/data/transaction_ledger.rs +++ b/crates/storage-cassandra/src/data/transaction_ledger.rs @@ -132,9 +132,9 @@ impl CassandraEngine { StorageError::Internal("Database error".to_owned()) })?; - if let Some(rows) = body.into_rows() { - if let Some(row) = rows.first() { - let applied: bool = get_column(&row, "[applied]", "write_ledger_entry")?; + if let Some(rows) = body.into_rows() + && let Some(row) = rows.first() { + let applied: bool = get_column(row, "[applied]", "write_ledger_entry")?; if !applied { tracing::error!("write_ledger_entry: transaction ID already exists"); return Err(StorageError::Internal( @@ -142,7 +142,6 @@ impl CassandraEngine { )); } } - } Ok(()) } diff --git a/crates/storage-cassandra/src/data/transactions.rs b/crates/storage-cassandra/src/data/transactions.rs index 67c0d677..9ac52bdb 100644 --- a/crates/storage-cassandra/src/data/transactions.rs +++ b/crates/storage-cassandra/src/data/transactions.rs @@ -88,14 +88,13 @@ impl CassandraEngine { // Check for prepared transactions in Phase 1 data for (i, result) in phase1_results.iter().enumerate() { - if let Some((_, _, prepared_txn_id)) = result { - if prepared_txn_id.is_some() { + if let Some((_, _, prepared_txn_id)) = result + && prepared_txn_id.is_some() { // Item is in a prepared transaction - conflict let mut reasons = vec![CancellationReason::none(); ops.len()]; reasons[i] = transaction_conflict_reason(); return Err(StorageError::TransactionCanceled(reasons)); } - } } // Phase 2: Verify timestamps haven't changed @@ -850,13 +849,12 @@ impl CassandraEngine { .get_by_name("partition_max_delete_timestamp") .ok() .flatten(); - if let Some(max_ts) = max_ts { - if txn_timestamp <= max_ts { + if let Some(max_ts) = max_ts + && txn_timestamp <= max_ts { return Err(CancellationReason::validation_error( "Item was deleted at a later timestamp".to_owned(), )); } - } } Ok(()) @@ -1295,7 +1293,7 @@ impl CassandraEngine { let table = data_table_name(&op.table_id); let txn_id_bytes = Bytes::new(txn_id.as_bytes().to_vec()); - let sk = ledger_sk(&op)?; + let sk = ledger_sk(op)?; match op.op.as_str() { "PUT" | "UPDATE" => { @@ -1438,7 +1436,7 @@ impl CassandraEngine { let table = data_table_name(&op.table_id); let txn_id_bytes = Bytes::new(txn_id.as_bytes().to_vec()); - let sk = ledger_sk(&op)?; + let sk = ledger_sk(op)?; let (delete_result, update_query) = if let Some((sk_val, sk_col)) = &sk { let dq = format!( @@ -1538,15 +1536,14 @@ fn check_lwt_applied(result: &Envelope, context: &str) -> Result<(), Cancellatio CancellationReason::validation_error("Database error".to_owned()) })?; - if let Some(rows) = body.into_rows() { - if let Some(row) = rows.first() { - let applied: bool = get_column::(&row, "[applied]", context) + if let Some(rows) = body.into_rows() + && let Some(row) = rows.first() { + let applied: bool = get_column::(row, "[applied]", context) .map_err(|_| transaction_conflict_reason())?; if !applied { return Err(transaction_conflict_reason()); } } - } Ok(()) } diff --git a/crates/storage-cassandra/src/data/update_item.rs b/crates/storage-cassandra/src/data/update_item.rs index 937d1023..06e00ca0 100644 --- a/crates/storage-cassandra/src/data/update_item.rs +++ b/crates/storage-cassandra/src/data/update_item.rs @@ -106,7 +106,7 @@ impl CassandraEngine { ])); } let item_data: String = - crate::cassandra_util::get_column(&row, "item_data", "update_item")?; + crate::cassandra_util::get_column(row, "item_data", "update_item")?; Some(json_to_item(item_data)?) } else { None diff --git a/crates/storage-cassandra/src/lib.rs b/crates/storage-cassandra/src/lib.rs index 96519ea9..a5a9284c 100644 --- a/crates/storage-cassandra/src/lib.rs +++ b/crates/storage-cassandra/src/lib.rs @@ -84,11 +84,9 @@ impl ServerRuntimeHooks for CassandraRuntimeHooks { .catalog_store .get_setting("gsi_propagation_delay_ms") .await - { - if let Ok(ms) = val.parse::() { + && let Ok(ms) = val.parse::() { gsi_delay.store(ms, std::sync::atomic::Ordering::Relaxed); } - } let catalog_store_for_gsi = ctx.catalog_store.clone(); let gsi_delay_poller = tokio::spawn( diff --git a/crates/storage-cassandra/src/management_store/accounts.rs b/crates/storage-cassandra/src/management_store/accounts.rs index 846ff472..50353bbe 100644 --- a/crates/storage-cassandra/src/management_store/accounts.rs +++ b/crates/storage-cassandra/src/management_store/accounts.rs @@ -408,7 +408,7 @@ impl CassandraCatalogStore { let query = format!("SELECT value FROM {keyspace}.settings WHERE key = 'default_account_id'"); let rows = crate::cassandra_util::query_rows::( - &self.session(), + self.session(), &query, cdrs_tokio::query_values!(), "default_account_id", diff --git a/crates/storage-cassandra/src/metadata_engine.rs b/crates/storage-cassandra/src/metadata_engine.rs index e07872c6..f684d3cf 100755 --- a/crates/storage-cassandra/src/metadata_engine.rs +++ b/crates/storage-cassandra/src/metadata_engine.rs @@ -13,18 +13,18 @@ use crate::CassandraEngine; impl MetadataEngine for CassandraEngine { fn describe_ttl( &self, - account_id: &str, - table_name: &str, + _account_id: &str, + _table_name: &str, ) -> BoxFuture<'_, Result> { Box::pin(async move { todo!("describe_ttl not implemented") }) } fn update_ttl( &self, - account_id: &str, - table_name: &str, - attribute_name: &str, - enabled: bool, + _account_id: &str, + _table_name: &str, + _attribute_name: &str, + _enabled: bool, ) -> BoxFuture<'_, Result<(), StorageError>> { Box::pin(async move { todo!("update_ttl not implemented") }) } @@ -123,7 +123,7 @@ impl MetadataEngine for CassandraEngine { fn tables_with_ttl( &self, - account_id: &str, + _account_id: &str, ) -> BoxFuture<'_, Result, StorageError>> { Box::pin(async move { todo!("tables_with_ttl not implemented") }) } @@ -142,42 +142,42 @@ impl MetadataEngine for CassandraEngine { fn create_ttl_index( &self, - account_id: &str, - table_name: &str, - ttl_attribute: &str, + _account_id: &str, + _table_name: &str, + _ttl_attribute: &str, ) -> BoxFuture<'_, Result<(), StorageError>> { Box::pin(async move { todo!("create_ttl_index not implemented") }) } fn drop_ttl_index( &self, - account_id: &str, - table_name: &str, + _account_id: &str, + _table_name: &str, ) -> BoxFuture<'_, Result<(), StorageError>> { Box::pin(async move { todo!("drop_ttl_index not implemented") }) } fn find_expired_items_indexed( &self, - account_id: &str, - table_name: &str, - ttl_attribute: &str, - limit: usize, + _account_id: &str, + _table_name: &str, + _ttl_attribute: &str, + _limit: usize, ) -> BoxFuture<'_, Result, StorageError>> { Box::pin(async move { todo!("find_expired_items_indexed not implemented") }) } fn refresh_table_size( &self, - account_id: &str, - table_name: &str, + _account_id: &str, + _table_name: &str, ) -> BoxFuture<'_, Result<(), StorageError>> { Box::pin(async move { todo!("refresh_table_size not implemented") }) } fn list_active_table_names( &self, - account_id: &str, + _account_id: &str, ) -> BoxFuture<'_, Result, StorageError>> { Box::pin(async move { todo!("list_active_table_names not implemented") }) } diff --git a/crates/storage-cassandra/src/operations.rs b/crates/storage-cassandra/src/operations.rs index db8f8f77..d7f68dba 100644 --- a/crates/storage-cassandra/src/operations.rs +++ b/crates/storage-cassandra/src/operations.rs @@ -73,14 +73,13 @@ impl OperationsEngine for CassandraOperationsEngine { } // Check first character (cannot be digit) - if let Some(first_char) = name.chars().next() { - if first_char.is_ascii_digit() { + if let Some(first_char) = name.chars().next() + && first_char.is_ascii_digit() { return Err(StorageError::Internal(format!( "{} '{}' cannot start with a digit", label, name ))); } - } // Check all characters (alphanumeric + underscore only) if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { diff --git a/crates/storage-cassandra/src/stream_engine.rs b/crates/storage-cassandra/src/stream_engine.rs index c503f03d..7bd37a4c 100755 --- a/crates/storage-cassandra/src/stream_engine.rs +++ b/crates/storage-cassandra/src/stream_engine.rs @@ -28,7 +28,7 @@ impl CassandraEngine { let without_prefix = shard_id.strip_prefix("shardId-").ok_or_else(|| { StorageError::Internal(format!("Invalid shard_id format: {shard_id}")) })?; - let table_id = without_prefix.rsplitn(2, '-').nth(1).ok_or_else(|| { + let table_id = without_prefix.rsplit_once('-').map(|x| x.0).ok_or_else(|| { StorageError::Internal(format!("Invalid shard_id format: {shard_id}")) })?; @@ -69,9 +69,7 @@ impl CassandraEngine { let without_prefix = shard_id .strip_prefix("shardId-") .ok_or_else(|| StorageError::Validation("Invalid ShardIterator".to_owned()))?; - let table_id = without_prefix - .rsplitn(2, '-') - .nth(1) + let table_id = without_prefix.rsplit_once('-').map(|x| x.0) .ok_or_else(|| StorageError::Validation("Invalid ShardIterator".to_owned()))?; let catalog_keyspace = self.catalog_keyspace(); @@ -119,6 +117,7 @@ impl StreamEngine for CassandraEngine { }) } + #[allow(unused_variables)] // `after` and `start` are used in query_with_values below fn get_stream_records( &self, account_id: &str, @@ -190,6 +189,7 @@ impl StreamEngine for CassandraEngine { }) } + #[allow(unused_variables)] // `start` is used in query_with_values below fn describe_stream( &self, account_id: &str, @@ -399,11 +399,10 @@ impl StreamEngine for CassandraEngine { let tn: String = row.get_r_by_name("table_name").ok()?; let label: Option = row.get_by_name("stream_label").ok().flatten(); let label = label?; // skip tables without streams - if let Some(ref filter_tn) = table_name { - if &tn != filter_tn { + if let Some(ref filter_tn) = table_name + && &tn != filter_tn { return None; } - } Some(StreamSummary { stream_arn: stream_arn(&self.region, &account_id, &tn, &label), stream_label: label, diff --git a/crates/storage-cassandra/src/update_table.rs b/crates/storage-cassandra/src/update_table.rs index 7791819a..9814c6a2 100644 --- a/crates/storage-cassandra/src/update_table.rs +++ b/crates/storage-cassandra/src/update_table.rs @@ -175,9 +175,9 @@ impl CassandraEngine { ) .await?; let has_label = label_row - .and_then(|r| { + .map(|r| { let v: Option = r.get_by_name("stream_label").ok().flatten(); - Some(v.is_some()) + v.is_some() }) .unwrap_or(false); if !has_label { @@ -236,7 +236,7 @@ impl CassandraEngine { let pt_json = create .provisioned_throughput .as_ref() - .map(|pt| serde_json::to_string(pt)) + .map(serde_json::to_string) .transpose() .map_err(|e| StorageError::Internal(e.to_string()))? .unwrap_or_default(); diff --git a/crates/storage-cassandra/src/workers.rs b/crates/storage-cassandra/src/workers.rs index 6a258801..3798e4f8 100644 --- a/crates/storage-cassandra/src/workers.rs +++ b/crates/storage-cassandra/src/workers.rs @@ -155,11 +155,10 @@ async fn list_account_keyspaces( let mut keyspaces = Vec::new(); for row in rows { let name: Result = row.get_r_by_name("keyspace_name"); - if let Ok(name) = name { - if name.starts_with(&prefix) { + if let Ok(name) = name + && name.starts_with(&prefix) { keyspaces.push(name); } - } } Ok(keyspaces) } @@ -377,8 +376,8 @@ async fn gsi_apply_index( let mut batch = BatchQueryBuilder::new().with_consistency(Consistency::LocalQuorum); - if let Some(old) = old_item { - if item_has_index_keys(old, &idx.key_schema) { + if let Some(old) = old_item + && item_has_index_keys(old, &idx.key_schema) { delete_index_row_multi( &mut batch, account_keyspace, @@ -390,10 +389,9 @@ async fn gsi_apply_index( &base_sks, )?; } - } - if let Some(new) = new_item { - if item_has_index_keys(new, &idx.key_schema) { + if let Some(new) = new_item + && item_has_index_keys(new, &idx.key_schema) { let projected = project_item_for_index( new, &idx.key_schema, @@ -412,7 +410,6 @@ async fn gsi_apply_index( &base_sks, )?; } - } // Only execute if there's something to do. let built = batch From 6fc60dad0b6c52daae58b23e7ec94bab982ccbc9 Mon Sep 17 00:00:00 2001 From: Joel Shepherd Date: Fri, 28 Aug 2026 21:13:11 +0000 Subject: [PATCH 07/48] chore(storage-cassandra): clippy fixes - format stings, doc cleanup, must_use, unwrap, casing --- crates/storage-cassandra/src/admin_store.rs | 20 +-- .../src/authorization_store.rs | 48 +++---- crates/storage-cassandra/src/backup_engine.rs | 73 +++++----- crates/storage-cassandra/src/bootstrapper.rs | 76 +++++------ .../storage-cassandra/src/cassandra_util.rs | 126 +++++++----------- crates/storage-cassandra/src/catalog_store.rs | 40 +++--- crates/storage-cassandra/src/create_table.rs | 49 ++++--- .../storage-cassandra/src/credential_store.rs | 6 +- crates/storage-cassandra/src/data/ddl.rs | 61 ++++----- .../storage-cassandra/src/data/delete_item.rs | 36 ++--- crates/storage-cassandra/src/data/index.rs | 23 ++-- crates/storage-cassandra/src/data/mod.rs | 36 ++--- .../src/data/put_get_item.rs | 50 +++---- crates/storage-cassandra/src/data/query.rs | 61 +++++---- .../src/data/query_helpers.rs | 20 +-- crates/storage-cassandra/src/data/scan.rs | 7 +- .../src/data/transaction_ledger.rs | 20 +-- .../src/data/transactions.rs | 81 ++++------- .../storage-cassandra/src/data/update_item.rs | 18 +-- crates/storage-cassandra/src/delete_table.rs | 29 ++-- crates/storage-cassandra/src/engine.rs | 17 ++- .../src/management_store/access_keys.rs | 28 ++-- .../src/management_store/accounts.rs | 51 +++---- .../src/management_store/groups.rs | 36 ++--- .../src/management_store/mod.rs | 2 +- .../src/management_store/policies.rs | 40 +++--- .../src/management_store/users.rs | 57 +++----- .../storage-cassandra/src/metadata_engine.rs | 9 +- crates/storage-cassandra/src/migrations.rs | 26 ++-- crates/storage-cassandra/src/operations.rs | 16 +-- crates/storage-cassandra/src/stream_engine.rs | 5 +- crates/storage-cassandra/src/stream_util.rs | 4 +- crates/storage-cassandra/src/table_engine.rs | 9 +- crates/storage-cassandra/src/table_helpers.rs | 43 +++--- crates/storage-cassandra/src/update_table.rs | 14 +- crates/storage-cassandra/src/worker_store.rs | 50 +++---- crates/storage-cassandra/src/workers.rs | 7 +- 37 files changed, 544 insertions(+), 750 deletions(-) diff --git a/crates/storage-cassandra/src/admin_store.rs b/crates/storage-cassandra/src/admin_store.rs index c16d46e8..1044a9b9 100755 --- a/crates/storage-cassandra/src/admin_store.rs +++ b/crates/storage-cassandra/src/admin_store.rs @@ -15,8 +15,7 @@ async fn admin_exists( admin_name: &str, ) -> OpResult { let query = format!( - "SELECT admin_name FROM {}.admin_users WHERE admin_name = ?", - catalog_keyspace + "SELECT admin_name FROM {catalog_keyspace}.admin_users WHERE admin_name = ?" ); let row = crate::cassandra_util::query_optional( session, @@ -36,9 +35,8 @@ impl extenddb_storage::management_store::AdminStore for CassandraCatalogStore { let catalog_keyspace = self.catalog_keyspace(); Box::pin(async move { let query = format!( - "INSERT INTO {}.admin_users (admin_name, password_hash, created_at) \ - VALUES (?, ?, toTimestamp(now())) IF NOT EXISTS", - catalog_keyspace + "INSERT INTO {catalog_keyspace}.admin_users (admin_name, password_hash, created_at) \ + VALUES (?, ?, toTimestamp(now())) IF NOT EXISTS" ); let result = session @@ -79,8 +77,7 @@ impl extenddb_storage::management_store::AdminStore for CassandraCatalogStore { let catalog_keyspace = self.catalog_keyspace(); Box::pin(async move { let query = format!( - "SELECT admin_name, created_at FROM {}.admin_users", - catalog_keyspace + "SELECT admin_name, created_at FROM {catalog_keyspace}.admin_users" ); let rows = crate::cassandra_util::query_rows( &session, @@ -123,8 +120,7 @@ impl extenddb_storage::management_store::AdminStore for CassandraCatalogStore { } let query = format!( - "DELETE FROM {}.admin_users WHERE admin_name = ?", - catalog_keyspace + "DELETE FROM {catalog_keyspace}.admin_users WHERE admin_name = ?" ); crate::cassandra_util::execute( &session, @@ -151,8 +147,7 @@ impl extenddb_storage::management_store::AdminStore for CassandraCatalogStore { } let query = format!( - "UPDATE {}.admin_users SET password_hash = ? WHERE admin_name = ?", - catalog_keyspace + "UPDATE {catalog_keyspace}.admin_users SET password_hash = ? WHERE admin_name = ?" ); crate::cassandra_util::execute( &session, @@ -175,8 +170,7 @@ impl extenddb_storage::management_store::AdminStore for CassandraCatalogStore { let catalog_keyspace = self.catalog_keyspace(); Box::pin(async move { let query = format!( - "SELECT password_hash FROM {}.admin_users WHERE admin_name = ?", - catalog_keyspace + "SELECT password_hash FROM {catalog_keyspace}.admin_users WHERE admin_name = ?" ); let row = crate::cassandra_util::query_optional( diff --git a/crates/storage-cassandra/src/authorization_store.rs b/crates/storage-cassandra/src/authorization_store.rs index 3d059e44..176cc1d2 100755 --- a/crates/storage-cassandra/src/authorization_store.rs +++ b/crates/storage-cassandra/src/authorization_store.rs @@ -22,9 +22,8 @@ impl AuthorizationStore for CassandraCatalogStore { let catalog_keyspace = self.catalog_keyspace(); Box::pin(async move { let query = format!( - "SELECT policy_document FROM {}.iam_policies \ - WHERE account_id = ? AND principal_type = 'user' AND principal_name = ?", - catalog_keyspace + "SELECT policy_document FROM {catalog_keyspace}.iam_policies \ + WHERE account_id = ? AND principal_type = 'user' AND principal_name = ?" ); let result = session .query_with_values( @@ -70,9 +69,8 @@ impl AuthorizationStore for CassandraCatalogStore { // TODO: Consider denormalizing to iam_user_groups table for scale // See notes/performance-considerations.md let groups_query = format!( - "SELECT group_name FROM {}.iam_group_members \ - WHERE account_id = ? AND user_name = ? ALLOW FILTERING", - catalog_keyspace + "SELECT group_name FROM {catalog_keyspace}.iam_group_members \ + WHERE account_id = ? AND user_name = ? ALLOW FILTERING" ); let groups_result = session .query_with_values( @@ -101,9 +99,8 @@ impl AuthorizationStore for CassandraCatalogStore { })?; let policy_query = format!( - "SELECT policy_document FROM {}.iam_policies \ - WHERE account_id = ? AND principal_type = 'group' AND principal_name = ?", - catalog_keyspace + "SELECT policy_document FROM {catalog_keyspace}.iam_policies \ + WHERE account_id = ? AND principal_type = 'group' AND principal_name = ?" ); let policy_result = session .query_with_values( @@ -150,9 +147,8 @@ impl AuthorizationStore for CassandraCatalogStore { let catalog_keyspace = self.catalog_keyspace(); Box::pin(async move { let query = format!( - "SELECT policy_document FROM {}.iam_permissions_boundaries \ - WHERE account_id = ? AND principal_type = 'user' AND principal_name = ?", - catalog_keyspace + "SELECT policy_document FROM {catalog_keyspace}.iam_permissions_boundaries \ + WHERE account_id = ? AND principal_type = 'user' AND principal_name = ?" ); let result = session .query_with_values( @@ -194,9 +190,8 @@ impl AuthorizationStore for CassandraCatalogStore { let catalog_keyspace = self.catalog_keyspace(); Box::pin(async move { let query = format!( - "SELECT policy_document FROM {}.iam_policies \ - WHERE account_id = ? AND principal_type = 'role' AND principal_name = ?", - catalog_keyspace + "SELECT policy_document FROM {catalog_keyspace}.iam_policies \ + WHERE account_id = ? AND principal_type = 'role' AND principal_name = ?" ); let rows = crate::cassandra_util::query_rows( &session, @@ -227,9 +222,8 @@ impl AuthorizationStore for CassandraCatalogStore { let catalog_keyspace = self.catalog_keyspace(); Box::pin(async move { let query = format!( - "SELECT policy_document FROM {}.iam_permissions_boundaries \ - WHERE account_id = ? AND principal_type = 'role' AND principal_name = ?", - catalog_keyspace + "SELECT policy_document FROM {catalog_keyspace}.iam_permissions_boundaries \ + WHERE account_id = ? AND principal_type = 'role' AND principal_name = ?" ); let row = crate::cassandra_util::query_optional( &session, @@ -267,9 +261,8 @@ impl AuthorizationStore for CassandraCatalogStore { Box::pin(async move { let query = format!( - "SELECT session_policy, session_tags FROM {}.iam_sessions \ - WHERE account_id = ? AND role_name = ? AND session_name = ? ALLOW FILTERING", - catalog_keyspace + "SELECT session_policy, session_tags FROM {catalog_keyspace}.iam_sessions \ + WHERE account_id = ? AND role_name = ? AND session_name = ? ALLOW FILTERING" ); let result = session @@ -345,9 +338,8 @@ impl AuthorizationStore for CassandraCatalogStore { Box::pin(async move { let query = format!( - "SELECT tag_key, tag_value FROM {}.iam_user_tags \ - WHERE account_id = ? AND user_name = ?", - catalog_keyspace + "SELECT tag_key, tag_value FROM {catalog_keyspace}.iam_user_tags \ + WHERE account_id = ? AND user_name = ?" ); let result = session @@ -400,9 +392,8 @@ impl AuthorizationStore for CassandraCatalogStore { Box::pin(async move { let query = format!( - "SELECT tag_key, tag_value FROM {}.iam_role_tags \ - WHERE account_id = ? AND role_name = ?", - catalog_keyspace + "SELECT tag_key, tag_value FROM {catalog_keyspace}.iam_role_tags \ + WHERE account_id = ? AND role_name = ?" ); let result = session @@ -450,8 +441,7 @@ impl AuthorizationStore for CassandraCatalogStore { Box::pin(async move { let query = format!( - "SELECT tag_key, tag_value FROM {}.tags WHERE resource_arn = ?", - catalog_keyspace + "SELECT tag_key, tag_value FROM {catalog_keyspace}.tags WHERE resource_arn = ?" ); let result = session diff --git a/crates/storage-cassandra/src/backup_engine.rs b/crates/storage-cassandra/src/backup_engine.rs index dd7af985..20faa21f 100755 --- a/crates/storage-cassandra/src/backup_engine.rs +++ b/crates/storage-cassandra/src/backup_engine.rs @@ -45,10 +45,6 @@ fn backup_id() -> String { format!("{}-{suffix:08x}", epoch_millis()) } -fn epoch_seconds(timestamp_millis: i64) -> f64 { - timestamp_millis as f64 / 1_000.0 -} - fn bucket_count(item_count: i64) -> i64 { if item_count <= 0 { 0 @@ -134,7 +130,7 @@ impl StoredBackup { backup_status: self.backup_status.clone(), backup_type: self.backup_type.clone(), backup_size_bytes: self.backup_size_bytes, - backup_creation_date_time: epoch_seconds(self.created_at), + backup_creation_date_time: crate::cassandra_util::millis_to_seconds_f64(self.created_at), } } @@ -147,7 +143,7 @@ impl StoredBackup { backup_status: self.backup_status.clone(), backup_type: self.backup_type.clone(), backup_size_bytes: self.backup_size_bytes, - backup_creation_date_time: epoch_seconds(self.created_at), + backup_creation_date_time: crate::cassandra_util::millis_to_seconds_f64(self.created_at), } } @@ -164,7 +160,7 @@ impl StoredBackup { item_count: self.item_count, table_size_bytes: self.backup_size_bytes, billing_mode: Some(self.billing_mode.clone()), - table_creation_date_time: epoch_seconds(self.table_created_at), + table_creation_date_time: crate::cassandra_util::millis_to_seconds_f64(self.table_created_at), }, }) } @@ -221,12 +217,13 @@ impl CassandraEngine { ) -> Result<(), StorageError> { let account_keyspace = self.account_keyspace(account_id); let query = format!( - "DELETE FROM {}.backup_items WHERE backup_arn = ? AND bucket = ?", - account_keyspace + "DELETE FROM {account_keyspace}.backup_items WHERE backup_arn = ? AND bucket = ?" ); for bucket in 0..bucket_count(item_count) { + #[allow(clippy::cast_possible_truncation)] + let bucket_i32 = bucket as i32; self.session - .query_with_values(&query, cdrs_tokio::query_values!(backup_arn, bucket as i32)) + .query_with_values(&query, cdrs_tokio::query_values!(backup_arn, bucket_i32)) .await .map_err(|e| StorageError::Internal(format!("Delete backup payload: {e}")))?; } @@ -236,8 +233,7 @@ impl CassandraEngine { async fn remove_backup_index_rows(&self, backup: &StoredBackup) -> Result<(), StorageError> { let catalog = self.catalog_keyspace(); let by_account = format!( - "DELETE FROM {}.backups_by_account WHERE account_id = ? AND created_at = ? AND backup_arn = ?", - catalog + "DELETE FROM {catalog}.backups_by_account WHERE account_id = ? AND created_at = ? AND backup_arn = ?" ); self.session .query_with_values( @@ -252,8 +248,7 @@ impl CassandraEngine { .map_err(|e| StorageError::Internal(format!("Delete account backup index: {e}")))?; let by_table = format!( - "DELETE FROM {}.backups_by_table WHERE account_id = ? AND table_name = ? AND created_at = ? AND backup_arn = ?", - catalog + "DELETE FROM {catalog}.backups_by_table WHERE account_id = ? AND table_name = ? AND created_at = ? AND backup_arn = ?" ); self.session .query_with_values( @@ -361,6 +356,7 @@ impl BackupEngine for CassandraEngine { id = backup_id() ); let created_at = chrono::Utc::now().timestamp_millis(); + #[allow(clippy::cast_possible_truncation)] let table_created_at = (table.creation_date_time * 1_000.0) as i64; let billing_mode = if table.billing_mode_summary.is_some() { "PAY_PER_REQUEST" @@ -386,8 +382,7 @@ impl BackupEngine for CassandraEngine { let catalog = self.catalog_keyspace(); let insert_metadata = format!( - "INSERT INTO {}.backups_by_arn (backup_arn, account_id, backup_name, table_id, table_name, table_arn, backup_status, backup_type, backup_size_bytes, item_count, key_schema, attribute_definitions, billing_mode, provisioned_throughput, stream_specification, table_created_at, created_at) VALUES (?, ?, ?, ?, ?, ?, 'CREATING', 'USER', ?, 0, ?, ?, ?, ?, ?, ?, ?) IF NOT EXISTS", - catalog + "INSERT INTO {catalog}.backups_by_arn (backup_arn, account_id, backup_name, table_id, table_name, table_arn, backup_status, backup_type, backup_size_bytes, item_count, key_schema, attribute_definitions, billing_mode, provisioned_throughput, stream_specification, table_created_at, created_at) VALUES (?, ?, ?, ?, ?, ?, 'CREATING', 'USER', ?, 0, ?, ?, ?, ?, ?, ?, ?) IF NOT EXISTS" ); self.session .query_with_values( @@ -414,8 +409,7 @@ impl BackupEngine for CassandraEngine { let account_keyspace = self.account_keyspace(&account_id); let insert_item = format!( - "INSERT INTO {}.backup_items (backup_arn, bucket, item_index, item_data) VALUES (?, ?, ?, ?)", - account_keyspace + "INSERT INTO {account_keyspace}.backup_items (backup_arn, bucket, item_index, item_data) VALUES (?, ?, ?, ?)" ); let mut item_count = 0_i64; let mut payload_size = 0_i64; @@ -438,12 +432,14 @@ impl BackupEngine for CassandraEngine { StorageError::Internal(format!("Serialize backup item: {e}")) })?; let bucket = item_count / BACKUP_ITEMS_PER_BUCKET; + #[allow(clippy::cast_possible_truncation)] + let bucket_i32 = bucket as i32; self.session .query_with_values( &insert_item, cdrs_tokio::query_values!( backup_arn.as_str(), - bucket as i32, + bucket_i32, item_count, item_data.as_str() ), @@ -453,7 +449,9 @@ impl BackupEngine for CassandraEngine { StorageError::Internal(format!("Write backup item: {e}")) })?; item_count += 1; - payload_size = payload_size.saturating_add(item_data.len() as i64); + #[allow(clippy::cast_possible_wrap)] + let item_len = item_data.len() as i64; + payload_size = payload_size.saturating_add(item_len); } match next_key { Some(key) => start_key = Some(key), @@ -469,8 +467,7 @@ impl BackupEngine for CassandraEngine { .cleanup_backup_payload(&account_id, &backup_arn, item_count) .await; let delete_metadata = format!( - "DELETE FROM {}.backups_by_arn WHERE account_id = ? AND backup_arn = ?", - catalog + "DELETE FROM {catalog}.backups_by_arn WHERE account_id = ? AND backup_arn = ?" ); let _ = self .session @@ -484,16 +481,13 @@ impl BackupEngine for CassandraEngine { let backup_size_bytes = table.table_size_bytes.max(payload_size); let insert_by_account = format!( - "INSERT INTO {}.backups_by_account (account_id, created_at, backup_arn, backup_name, table_id, table_name, table_arn, backup_size_bytes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", - catalog + "INSERT INTO {catalog}.backups_by_account (account_id, created_at, backup_arn, backup_name, table_id, table_name, table_arn, backup_size_bytes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" ); let insert_by_table = format!( - "INSERT INTO {}.backups_by_table (account_id, table_name, created_at, backup_arn, backup_name, table_id, table_arn, backup_size_bytes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", - catalog + "INSERT INTO {catalog}.backups_by_table (account_id, table_name, created_at, backup_arn, backup_name, table_id, table_arn, backup_size_bytes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" ); let publish = format!( - "UPDATE {}.backups_by_arn SET backup_status = 'AVAILABLE', backup_size_bytes = ?, item_count = ? WHERE account_id = ? AND backup_arn = ?", - catalog + "UPDATE {catalog}.backups_by_arn SET backup_status = 'AVAILABLE', backup_size_bytes = ?, item_count = ? WHERE account_id = ? AND backup_arn = ?" ); // Publication is a fixed three-statement operation. A logged batch // prevents readers from observing list rows without the matching @@ -556,8 +550,7 @@ impl BackupEngine for CassandraEngine { .cleanup_backup_payload(&account_id, &backup_arn, item_count) .await; let delete_metadata = format!( - "DELETE FROM {}.backups_by_arn WHERE account_id = ? AND backup_arn = ?", - catalog + "DELETE FROM {catalog}.backups_by_arn WHERE account_id = ? AND backup_arn = ?" ); let _ = self .session @@ -575,7 +568,7 @@ impl BackupEngine for CassandraEngine { backup_status: "AVAILABLE".to_owned(), backup_type: "USER".to_owned(), backup_size_bytes, - backup_creation_date_time: epoch_seconds(created_at), + backup_creation_date_time: crate::cassandra_util::millis_to_seconds_f64(created_at), }) }) } @@ -606,8 +599,7 @@ impl BackupEngine for CassandraEngine { let catalog = self.catalog_keyspace(); let (query, result) = if let Some(table_name) = table_name.as_deref() { let query = format!( - "SELECT backup_arn, backup_name, table_name, table_arn, backup_size_bytes, created_at FROM {}.backups_by_table WHERE account_id = ? AND table_name = ?", - catalog + "SELECT backup_arn, backup_name, table_name, table_arn, backup_size_bytes, created_at FROM {catalog}.backups_by_table WHERE account_id = ? AND table_name = ?" ); let result = self .session @@ -619,8 +611,7 @@ impl BackupEngine for CassandraEngine { (query, result) } else { let query = format!( - "SELECT backup_arn, backup_name, table_name, table_arn, backup_size_bytes, created_at FROM {}.backups_by_account WHERE account_id = ?", - catalog + "SELECT backup_arn, backup_name, table_name, table_arn, backup_size_bytes, created_at FROM {catalog}.backups_by_account WHERE account_id = ?" ); let result = self .session @@ -790,17 +781,18 @@ impl BackupEngine for CassandraEngine { let restore_result: Result<(i64, i64), StorageError> = async { let account_keyspace = self.account_keyspace(&account_id); let query = format!( - "SELECT item_data FROM {}.backup_items WHERE backup_arn = ? AND bucket = ?", - account_keyspace + "SELECT item_data FROM {account_keyspace}.backup_items WHERE backup_arn = ? AND bucket = ?" ); let mut restored_count = 0_i64; let mut restored_size = 0_i64; for bucket in 0..bucket_count(backup.item_count) { + #[allow(clippy::cast_possible_truncation)] + let bucket_i32 = bucket as i32; let rows = self .session .query_with_values( &query, - cdrs_tokio::query_values!(backup_arn.as_str(), bucket as i32), + cdrs_tokio::query_values!(backup_arn.as_str(), bucket_i32), ) .await .map_err(|e| StorageError::Internal(format!("Read backup payload: {e}")))? @@ -818,7 +810,9 @@ impl BackupEngine for CassandraEngine { self.put_item_impl(&key_info, item, false, None, &Default::default(), None) .await?; restored_count += 1; - restored_size = restored_size.saturating_add(item_data.len() as i64); + #[allow(clippy::cast_possible_wrap)] + let item_len = item_data.len() as i64; + restored_size = restored_size.saturating_add(item_len); } } if restored_count != backup.item_count { @@ -901,6 +895,7 @@ impl BackupEngine for CassandraEngine { value.ok() }) .unwrap_or(false); + #[allow(clippy::cast_precision_loss)] let now = epoch_millis() as f64 / 1_000.0; Ok(ContinuousBackupsDescription { continuous_backups_status: "ENABLED".to_owned(), diff --git a/crates/storage-cassandra/src/bootstrapper.rs b/crates/storage-cassandra/src/bootstrapper.rs index 44da55de..07774cbc 100644 --- a/crates/storage-cassandra/src/bootstrapper.rs +++ b/crates/storage-cassandra/src/bootstrapper.rs @@ -82,7 +82,7 @@ impl CassandraBootstrapper { ) .exists() { - println!("--- Loading defaults from {}", config_path); + println!("--- Loading defaults from {config_path}"); // Parse Cassandra config from file let config_content = std::fs::read_to_string(config_path) @@ -109,7 +109,7 @@ impl CassandraBootstrapper { if let Some(ref cli_cp) = cassandra_contact_points { let cli_list: Vec<&str> = cli_cp.split(',').collect(); let config_list: Vec<&str> = - config.contact_points.iter().map(|s| s.as_str()).collect(); + config.contact_points.iter().map(std::string::String::as_str).collect(); if cli_list != config_list { return Err(StorageError::Internal(format!( "--cassandra-contact-points '{}' conflicts with config file contact points '{}'", @@ -144,8 +144,7 @@ impl CassandraBootstrapper { if let Some(ref cli_rf) = replication_factor { let cli_rf_val = cli_rf.parse::().map_err(|_| { StorageError::Internal(format!( - "Invalid --replication-factor '{}': must be a positive integer", - cli_rf + "Invalid --replication-factor '{cli_rf}': must be a positive integer" )) })?; if cli_rf_val != config.replication_factor { @@ -178,14 +177,12 @@ impl CassandraBootstrapper { // CLI args override config (or use config values if no CLI arg provided) let resolved_contact_points = cassandra_contact_points - .map(|cp| cp.split(',').map(|s| s.to_string()).collect()) - .unwrap_or(contact_points); + .map_or(contact_points, |cp| cp.split(',').map(std::string::ToString::to_string).collect()); let resolved_admin_user = cassandra_user .unwrap_or_else(|| std::env::var("USER").unwrap_or_else(|_| "cassandra".to_owned())); let resolved_keyspace_prefix = keyspace_prefix.unwrap_or(prefix); let resolved_replication_factor = replication_factor - .map(|rf| rf.parse::().unwrap_or(1)) - .unwrap_or(rf_from_config); + .map_or(rf_from_config, |rf| rf.parse::().unwrap_or(1)); let resolved_app_user = extenddb_user.unwrap_or(user); let resolved_app_password = extenddb_pass.unwrap_or(password); @@ -209,7 +206,7 @@ impl CassandraBootstrapper { admin_password: cassandra_pass.clone(), app_user: resolved_app_user, app_password: resolved_app_password, - catalog_db: format!("{}_catalog", resolved_keyspace_prefix), + catalog_db: format!("{resolved_keyspace_prefix}_catalog"), data_db: String::new(), // Not used for Cassandra }; @@ -250,9 +247,8 @@ impl Bootstrapper for CassandraBootstrapper { .await .ok() .and_then(|frame| frame.response_body().ok()) - .and_then(|body| body.into_rows()) - .map(|rows| !rows.is_empty()) - .unwrap_or(false); + .and_then(cdrs_tokio::frame::message_response::ResponseBody::into_rows) + .is_some_and(|rows| !rows.is_empty()); if exists { println!(" User '{user}' already exists."); @@ -273,14 +269,13 @@ impl Bootstrapper for CassandraBootstrapper { // Create user with password let create_cql = format!( - "CREATE ROLE IF NOT EXISTS '{}' WITH PASSWORD = '{}' AND LOGIN = true", - user, password + "CREATE ROLE IF NOT EXISTS '{user}' WITH PASSWORD = '{password}' AND LOGIN = true" ); self.engine .session() .query(create_cql) .await - .map_err(|e| OpError::Internal(format!("Create user: {}", e)))?; + .map_err(|e| OpError::Internal(format!("Create user: {e}")))?; println!(" Created user '{user}'."); Ok(()) @@ -307,9 +302,8 @@ impl Bootstrapper for CassandraBootstrapper { .await .ok() .and_then(|frame| frame.response_body().ok()) - .and_then(|body| body.into_rows()) - .map(|rows| !rows.is_empty()) - .unwrap_or(false); + .and_then(cdrs_tokio::frame::message_response::ResponseBody::into_rows) + .is_some_and(|rows| !rows.is_empty()); if already_granted { return Ok(()); @@ -330,7 +324,7 @@ impl Bootstrapper for CassandraBootstrapper { async fn create_catalog_db(&self) -> OpResult<()> { let keyspace = self.engine.catalog_keyspace(); - println!("--- Creating catalog keyspace '{}'...", keyspace); + println!("--- Creating catalog keyspace '{keyspace}'..."); if self .engine @@ -414,8 +408,7 @@ impl Bootstrapper for CassandraBootstrapper { // Check if key already exists let check_cql = format!( - "SELECT value FROM {}.settings WHERE key = 'encryption_key'", - keyspace + "SELECT value FROM {keyspace}.settings WHERE key = 'encryption_key'" ); let exists = self .engine @@ -424,9 +417,8 @@ impl Bootstrapper for CassandraBootstrapper { .await .ok() .and_then(|frame| frame.response_body().ok()) - .and_then(|body| body.into_rows()) - .map(|rows| !rows.is_empty()) - .unwrap_or(false); + .and_then(cdrs_tokio::frame::message_response::ResponseBody::into_rows) + .is_some_and(|rows| !rows.is_empty()); if exists { println!("--- Encryption key already exists, skipping."); @@ -437,8 +429,7 @@ impl Bootstrapper for CassandraBootstrapper { // Store key let insert_cql = format!( - "INSERT INTO {}.settings (key, value) VALUES (?, ?)", - keyspace + "INSERT INTO {keyspace}.settings (key, value) VALUES (?, ?)" ); self.engine .session() @@ -457,7 +448,7 @@ impl Bootstrapper for CassandraBootstrapper { let keyspace = self.engine.catalog_keyspace(); // Check if any accounts exist - let check_cql = format!("SELECT account_id FROM {}.accounts LIMIT 1", keyspace); + let check_cql = format!("SELECT account_id FROM {keyspace}.accounts LIMIT 1"); let has_accounts = self .engine .session() @@ -465,9 +456,8 @@ impl Bootstrapper for CassandraBootstrapper { .await .ok() .and_then(|frame| frame.response_body().ok()) - .and_then(|body| body.into_rows()) - .map(|rows| !rows.is_empty()) - .unwrap_or(false); + .and_then(cdrs_tokio::frame::message_response::ResponseBody::into_rows) + .is_some_and(|rows| !rows.is_empty()); if has_accounts { println!("--- Default account already exists, skipping."); @@ -480,8 +470,7 @@ impl Bootstrapper for CassandraBootstrapper { let account_name = "default"; let insert_cql = format!( - "INSERT INTO {}.accounts (account_id, account_name, created_at) VALUES (?, ?, toTimestamp(now()))", - keyspace + "INSERT INTO {keyspace}.accounts (account_id, account_name, created_at) VALUES (?, ?, toTimestamp(now()))" ); self.engine @@ -522,8 +511,7 @@ impl Bootstrapper for CassandraBootstrapper { // Check if user exists let check_cql = format!( - "SELECT admin_name FROM {}.admin_users WHERE admin_name = ?", - keyspace + "SELECT admin_name FROM {keyspace}.admin_users WHERE admin_name = ?" ); let exists = self .engine @@ -532,9 +520,8 @@ impl Bootstrapper for CassandraBootstrapper { .await .ok() .and_then(|frame| frame.response_body().ok()) - .and_then(|body| body.into_rows()) - .map(|rows| !rows.is_empty()) - .unwrap_or(false); + .and_then(cdrs_tokio::frame::message_response::ResponseBody::into_rows) + .is_some_and(|rows| !rows.is_empty()); if exists { println!("--- Admin user '{username}' already exists, skipping."); @@ -560,8 +547,7 @@ impl Bootstrapper for CassandraBootstrapper { // Insert admin user let insert_cql = format!( - "INSERT INTO {}.admin_users (admin_name, password_hash) VALUES (?, ?)", - keyspace + "INSERT INTO {keyspace}.admin_users (admin_name, password_hash) VALUES (?, ?)" ); self.engine @@ -592,14 +578,13 @@ impl Bootstrapper for CassandraBootstrapper { let keyspace = self.engine.catalog_keyspace(); let cql = format!( - "SELECT table_name FROM {}.tables ORDER BY table_name", - keyspace + "SELECT table_name FROM {keyspace}.tables ORDER BY table_name" ); let rows = match self.engine.session().query(cql).await { Ok(frame) => frame .response_body() .ok() - .and_then(|body| body.into_rows()) + .and_then(cdrs_tokio::frame::message_response::ResponseBody::into_rows) .unwrap_or_default(), Err(_) => return Ok(Vec::new()), }; @@ -653,7 +638,7 @@ impl Bootstrapper for CassandraBootstrapper { .collect(); for keyspace in keyspaces { - println!(" Dropping keyspace: {}", keyspace); + println!(" Dropping keyspace: {keyspace}"); self.engine .drop_keyspace(&keyspace) .await @@ -672,8 +657,7 @@ impl Bootstrapper for CassandraBootstrapper { } let query = format!( - "SELECT value FROM {}.settings WHERE key = 'catalog_version'", - keyspace + "SELECT value FROM {keyspace}.settings WHERE key = 'catalog_version'" ); let version = self .engine @@ -682,7 +666,7 @@ impl Bootstrapper for CassandraBootstrapper { .await .ok() .and_then(|frame| frame.response_body().ok()) - .and_then(|body| body.into_rows()) + .and_then(cdrs_tokio::frame::message_response::ResponseBody::into_rows) .and_then(|mut rows| rows.pop()) .and_then(|row| TryFromRowTrait::try_from_row(row).ok()) .map(|r: SettingRow| r.value); diff --git a/crates/storage-cassandra/src/cassandra_util.rs b/crates/storage-cassandra/src/cassandra_util.rs index 77294d5e..0f5aad72 100644 --- a/crates/storage-cassandra/src/cassandra_util.rs +++ b/crates/storage-cassandra/src/cassandra_util.rs @@ -18,7 +18,9 @@ pub type CassandraSession = Session< >; /// Trait for errors that can be constructed from database operation failures. -/// Implemented by OpError, StorageError, and DynamoDbError. +/// Implemented by [`extenddb_storage::management_store::OpError`], +/// [`extenddb_storage::error::StorageError`], and +/// [`extenddb_core::error::DynamoDbError`]. pub trait FromDbError: Sized { fn db_error(msg: String) -> Self; } @@ -43,12 +45,14 @@ impl FromDbError for extenddb_core::error::DynamoDbError { /// Check if an error is a unique constraint violation. /// For Cassandra, this is always false in stub implementations. +#[must_use] pub fn is_unique_violation(_err: &cdrs_tokio::error::Error) -> bool { false } /// Check if an error is a foreign key constraint violation. /// For Cassandra, this is always false in stub implementations. +#[must_use] pub fn is_fk_violation(_err: &cdrs_tokio::error::Error) -> bool { false } @@ -59,16 +63,8 @@ pub fn is_fk_violation(_err: &cdrs_tokio::error::Error) -> bool { /// Execute a query and return all rows with standardized error handling. /// -/// Generic over error type - works with OpError, StorageError, or DynamoDbError. -/// -/// # Arguments -/// * `session` - Cassandra session -/// * `query` - CQL query string -/// * `values` - Query parameters -/// * `context` - Context string for error logging (e.g., "list_access_keys") -/// -/// # Returns -/// Vec of rows, or error if query fails +/// # Errors +/// Returns an error if the Cassandra query fails or the response cannot be parsed. pub async fn query_rows( session: &Arc, query: &str, @@ -93,17 +89,10 @@ pub async fn query_rows( /// Execute a query and return the first row, if any. /// -/// Generic over error type - works with OpError, StorageError, or DynamoDbError. -/// Similar to sqlx::query().fetch_optional(). +/// Similar to `sqlx::query().fetch_optional()`. /// -/// # Arguments -/// * `session` - Cassandra session -/// * `query` - CQL query string -/// * `values` - Query parameters -/// * `context` - Context string for error logging -/// -/// # Returns -/// Option, or error if query fails +/// # Errors +/// Returns an error if the Cassandra query fails or the response cannot be parsed. pub async fn query_optional( session: &Arc, query: &str, @@ -116,16 +105,8 @@ pub async fn query_optional( /// Execute a non-query statement (INSERT/UPDATE/DELETE). /// -/// Generic over error type - works with OpError, StorageError, or DynamoDbError. -/// -/// # Arguments -/// * `session` - Cassandra session -/// * `query` - CQL statement -/// * `values` - Query parameters -/// * `context` - Context string for error logging -/// -/// # Returns -/// Ok(()) if successful, error if query fails +/// # Errors +/// Returns an error if the Cassandra statement fails. pub async fn execute( session: &Arc, query: &str, @@ -147,18 +128,10 @@ pub async fn execute( // Type Conversion Helpers // ══════════════════════════════════════════════════════════════════════════════ -/// Extract a typed column value from a Row with standardized error handling. -/// -/// Generic over error type - works with OpError, StorageError, or DynamoDbError. -/// The return type T must be specified at the call site. +/// Extract a typed column value from a `Row` with standardized error handling. /// -/// # Arguments -/// * `row` - Row to extract from -/// * `column` - Column name -/// * `context` - Context string for error logging -/// -/// # Returns -/// Typed value T, or error if parsing fails +/// # Errors +/// Returns an error if the column cannot be extracted or parsed as type `T`. /// /// # Example /// ```ignore @@ -176,18 +149,12 @@ where }) } -/// Convert Cassandra timestamp (milliseconds) to OffsetDateTime. -/// -/// Generic over error type - works with OpError, StorageError, or DynamoDbError. -/// Cassandra stores timestamps as milliseconds since Unix epoch. -/// This helper divides by 1000 and converts to OffsetDateTime. +/// Convert a Cassandra timestamp (milliseconds since Unix epoch) to [`time::OffsetDateTime`]. /// -/// # Arguments -/// * `millis` - Timestamp in milliseconds -/// * `context` - Context string for error logging +/// Cassandra stores timestamps as milliseconds; this divides by 1000 before converting. /// -/// # Returns -/// OffsetDateTime, or error if conversion fails +/// # Errors +/// Returns an error if the timestamp value is out of range. pub fn timestamp_to_datetime( millis: i64, context: &str, @@ -198,18 +165,12 @@ pub fn timestamp_to_datetime( }) } -/// Extract a timestamp column and convert to OffsetDateTime. +/// Extract a timestamp column and convert to [`time::OffsetDateTime`]. /// -/// Generic over error type - works with OpError, StorageError, or DynamoDbError. -/// Convenience helper combining get_column + timestamp_to_datetime. +/// Convenience helper combining `get_column` + `timestamp_to_datetime`. /// -/// # Arguments -/// * `row` - Row to extract from -/// * `column` - Column name -/// * `context` - Context string for error logging -/// -/// # Returns -/// OffsetDateTime, or error if extraction/conversion fails +/// # Errors +/// Returns an error if the column cannot be extracted or the timestamp is out of range. /// /// # Example /// ```ignore @@ -224,28 +185,18 @@ pub fn get_timestamp( timestamp_to_datetime(millis, context) } -/// Map rows to Vec with a mapper function, collecting errors. -/// -/// Generic over error type - works with OpError, StorageError, or DynamoDbError. -/// Reduces boilerplate when transforming Vec to Vec. +/// Map rows to `Vec` with a mapper function, collecting errors. /// -/// # Arguments -/// * `rows` - Rows to map -/// * `mapper` - Function to transform each row -/// * `context` - Context string for error logging -/// -/// # Returns -/// Vec, or error if any mapping fails +/// # Errors +/// Returns an error if any row fails to map. /// /// # Example /// ```ignore /// let users = map_rows(rows, |row| { -/// Ok(( -/// get_column(&row, "user_name", "list_users")?, -/// get_column(&row, "email", "list_users")?, -/// )) +/// Ok(get_column(&row, "user_name", "list_users")?) /// }, "list_users")?; /// ``` +#[allow(clippy::needless_pass_by_value)] // Vec matches query_rows return type; &[Row] would require .as_slice() at 28 call sites pub fn map_rows( rows: Vec, mapper: F, @@ -257,10 +208,29 @@ where rows.iter().map(mapper).collect() } -/// Execute an LWT statement and return whether it was applied. +/// Convert millisecond timestamp to seconds as `f64` (for `creation_date_time` fields). +#[allow(clippy::cast_precision_loss)] +pub fn millis_to_seconds_f64(timestamp_millis: i64) -> f64 { + timestamp_millis as f64 / 1_000.0 +} + +/// Return the current time as milliseconds since Unix epoch as `i64`. +/// +/// `SystemTime::as_millis()` returns `u128`; this cast is safe for all +/// timestamps within the range of `i64` (until year 292,277,026). +#[allow(clippy::cast_possible_truncation)] +pub fn now_millis() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 +} /// /// Parses the `[applied]` column from the Cassandra LWT response. /// Use for `INSERT ... IF NOT EXISTS` and `UPDATE ... IF ...` statements. +/// +/// # Errors +/// Returns an error if the Cassandra statement fails or the response cannot be parsed. pub async fn apply_lwt( session: &Arc, query: &str, diff --git a/crates/storage-cassandra/src/catalog_store.rs b/crates/storage-cassandra/src/catalog_store.rs index 47bbd623..cc0b1247 100644 --- a/crates/storage-cassandra/src/catalog_store.rs +++ b/crates/storage-cassandra/src/catalog_store.rs @@ -107,8 +107,7 @@ impl CassandraCatalogStore { .await .ok() .and_then(|r| r.response_body().ok()) - .map(|b| !b.into_rows().unwrap_or_default().is_empty()) - .unwrap_or(false); + .is_some_and(|b| !b.into_rows().unwrap_or_default().is_empty()); if exists_result { // Keyspace exists but may have been created by a concurrent caller that @@ -153,7 +152,7 @@ impl CassandraCatalogStore { /// Drop an account keyspace (idempotent). pub(crate) async fn drop_account_keyspace(&self, account_id: &str) -> OpResult<()> { let keyspace_name = self.account_keyspace(account_id); - let cql = format!("DROP KEYSPACE IF EXISTS {}", keyspace_name); + let cql = format!("DROP KEYSPACE IF EXISTS {keyspace_name}"); self.session.query(cql).await.map_err(|e| { tracing::error!("Failed to drop account keyspace {}: {}", keyspace_name, e); @@ -167,8 +166,7 @@ impl CassandraCatalogStore { pub(crate) async fn account_exists(&self, account_id: &str) -> Result { let catalog_keyspace = self.catalog_keyspace(); let query = format!( - "SELECT account_id FROM {}.accounts WHERE account_id = ?", - catalog_keyspace + "SELECT account_id FROM {catalog_keyspace}.accounts WHERE account_id = ?" ); let rows = crate::cassandra_util::query_rows( @@ -190,8 +188,7 @@ impl CassandraCatalogStore { ) -> Result { let catalog_keyspace = self.catalog_keyspace(); let query = format!( - "SELECT user_name FROM {}.iam_users WHERE account_id = ? AND user_name = ?", - catalog_keyspace + "SELECT user_name FROM {catalog_keyspace}.iam_users WHERE account_id = ? AND user_name = ?" ); let rows = crate::cassandra_util::query_rows( @@ -215,8 +212,7 @@ impl extenddb_storage::management_store::SettingsStore for CassandraCatalogStore let catalog_keyspace = self.catalog_keyspace(); Box::pin(async move { let query = format!( - "SELECT value FROM {}.settings WHERE key = ?", - catalog_keyspace + "SELECT value FROM {catalog_keyspace}.settings WHERE key = ?" ); let row = crate::cassandra_util::query_optional( @@ -244,8 +240,7 @@ impl extenddb_storage::management_store::SettingsStore for CassandraCatalogStore let catalog_keyspace = self.catalog_keyspace(); Box::pin(async move { let query = format!( - "INSERT INTO {}.settings (key, value) VALUES (?, ?)", - catalog_keyspace + "INSERT INTO {catalog_keyspace}.settings (key, value) VALUES (?, ?)" ); crate::cassandra_util::execute( @@ -262,7 +257,7 @@ impl extenddb_storage::management_store::SettingsStore for CassandraCatalogStore let session = self.session.clone(); let catalog_keyspace = self.catalog_keyspace(); Box::pin(async move { - let query = format!("SELECT key, value FROM {}.settings", catalog_keyspace); + let query = format!("SELECT key, value FROM {catalog_keyspace}.settings"); let rows = crate::cassandra_util::query_rows( &session, @@ -291,7 +286,7 @@ impl extenddb_storage::management_store::SettingsStore for CassandraCatalogStore } fn cached_encryption_key(&self) -> Option { - self.encryption_key.as_ref().map(|k| k.to_string()) + self.encryption_key.as_ref().map(std::string::ToString::to_string) } } @@ -302,7 +297,7 @@ impl extenddb_storage::diagnostics::DiagnosticsStore for CassandraCatalogStore { let session = self.session.clone(); let catalog_keyspace = self.catalog_keyspace(); Box::pin(async move { - let query = format!("SELECT COUNT(*) FROM {}.tables", catalog_keyspace); + let query = format!("SELECT COUNT(*) FROM {catalog_keyspace}.tables"); let result = session.query(&query).await.map_err(|e| { extenddb_storage::diagnostics::DiagError::QueryFailed(e.to_string()) })?; @@ -327,7 +322,7 @@ impl extenddb_storage::diagnostics::DiagnosticsStore for CassandraCatalogStore { let session = self.session.clone(); let catalog_keyspace = self.catalog_keyspace(); Box::pin(async move { - let query = format!("SELECT COUNT(*) FROM {}.indexes", catalog_keyspace); + let query = format!("SELECT COUNT(*) FROM {catalog_keyspace}.indexes"); let result = session.query(&query).await.map_err(|e| { extenddb_storage::diagnostics::DiagError::QueryFailed(e.to_string()) })?; @@ -358,13 +353,11 @@ impl extenddb_storage::diagnostics::DiagnosticsStore for CassandraCatalogStore { // For Cassandra, we test connection to account keyspaces // Get a sample account keyspace name from accounts table let query = format!( - "SELECT account_id FROM {}.accounts LIMIT 1", - catalog_keyspace + "SELECT account_id FROM {catalog_keyspace}.accounts LIMIT 1" ); let result = session.query(&query).await.map_err(|e| { extenddb_storage::diagnostics::DiagError::QueryFailed(format!( - "Failed to query accounts: {}", - e + "Failed to query accounts: {e}" )) })?; @@ -379,14 +372,13 @@ impl extenddb_storage::diagnostics::DiagnosticsStore for CassandraCatalogStore { })?; // Test connection to account keyspace by querying schema_history - let account_keyspace = format!("{}_account_{}", keyspace_prefix, account_id); + let account_keyspace = format!("{keyspace_prefix}_account_{account_id}"); let test_query = - format!("SELECT COUNT(*) FROM {}.schema_history", account_keyspace); + format!("SELECT COUNT(*) FROM {account_keyspace}.schema_history"); session.query(&test_query).await.map_err(|e| { extenddb_storage::diagnostics::DiagError::ConnectionFailed(format!( - "Failed to query account keyspace {}: {}", - account_keyspace, e + "Failed to query account keyspace {account_keyspace}: {e}" )) })?; @@ -453,6 +445,6 @@ impl MetricsStore for CassandraCatalogStore { impl extenddb_storage::CatalogStore for CassandraCatalogStore { fn cached_encryption_key(&self) -> Option { - self.encryption_key.as_ref().map(|k| k.to_string()) + self.encryption_key.as_ref().map(std::string::ToString::to_string) } } diff --git a/crates/storage-cassandra/src/create_table.rs b/crates/storage-cassandra/src/create_table.rs index 18ca7c54..2311e433 100644 --- a/crates/storage-cassandra/src/create_table.rs +++ b/crates/storage-cassandra/src/create_table.rs @@ -122,8 +122,7 @@ impl CassandraEngine { // Read control_plane_delay_seconds from settings let catalog_keyspace = self.catalog_keyspace(); let delay_query = format!( - "SELECT value FROM {}.settings WHERE key = ?", - catalog_keyspace + "SELECT value FROM {catalog_keyspace}.settings WHERE key = ?" ); let delay_seconds: f64 = self .session @@ -134,7 +133,7 @@ impl CassandraEngine { .await .ok() .and_then(|frame| frame.response_body().ok()) - .and_then(|body| body.into_rows()) + .and_then(cdrs_tokio::frame::message_response::ResponseBody::into_rows) .and_then(|rows| rows.first().cloned()) .and_then(|row| { use cdrs_tokio::types::IntoRustByName; @@ -150,8 +149,9 @@ impl CassandraEngine { } else if delay_seconds == 0.0 { ("ACTIVE", None) } else { - let transition_at = chrono::Utc::now() - + chrono::Duration::milliseconds((delay_seconds * 1000.0) as i64); + #[allow(clippy::cast_possible_truncation)] + let delay_ms = (delay_seconds * 1000.0) as i64; + let transition_at = chrono::Utc::now() + chrono::Duration::milliseconds(delay_ms); ("CREATING", Some(transition_at.timestamp_millis())) }; @@ -161,18 +161,16 @@ impl CassandraEngine { let account_keyspace = self.account_keyspace(account_id); if !self.keyspace_exists(&account_keyspace).await? { return Err(StorageError::Internal(format!( - "Account keyspace '{}' does not exist. Account must be provisioned first.", - account_keyspace + "Account keyspace '{account_keyspace}' does not exist. Account must be provisioned first." ))); } // Insert table metadata with LWT (IF NOT EXISTS) let insert_table_cql = format!( - "INSERT INTO {}.tables (account_id, table_name, table_id, table_arn, key_schema, \ + "INSERT INTO {catalog_keyspace}.tables (account_id, table_name, table_id, table_arn, key_schema, \ attribute_definitions, billing_mode, provisioned_throughput, stream_specification, \ table_status, created_at, deletion_protection_enabled, status_transition_at) \ - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) IF NOT EXISTS", - catalog_keyspace + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) IF NOT EXISTS" ); let result = self @@ -204,19 +202,19 @@ impl CassandraEngine { .await .map_err(|e| { tracing::error!("create_table insert table: {e}"); - StorageError::Internal(format!("Failed to insert table: {}", e)) + StorageError::Internal(format!("Failed to insert table: {e}")) })?; // Check if LWT succeeded let body = result .response_body() - .map_err(|e| StorageError::Internal(format!("Failed to get response body: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Failed to get response body: {e}")))?; if let Some(rows) = body.into_rows() && let Some(row) = rows.first() { use cdrs_tokio::types::IntoRustByName; let applied: bool = row.get_r_by_name("[applied]").map_err(|e| { - StorageError::Internal(format!("Failed to parse [applied]: {}", e)) + StorageError::Internal(format!("Failed to parse [applied]: {e}")) })?; if !applied { @@ -249,10 +247,9 @@ impl CassandraEngine { let index_id = uuid::Uuid::new_v4().to_string(); let insert_index_cql = format!( - "INSERT INTO {}.indexes (table_id, index_name, index_id, index_type, \ + "INSERT INTO {catalog_keyspace}.indexes (table_id, index_name, index_id, index_type, \ key_schema, projection, index_status, provisioned_throughput) \ - VALUES (?, ?, ?, 'GSI', ?, ?, 'ACTIVE', ?)", - catalog_keyspace + VALUES (?, ?, ?, 'GSI', ?, ?, 'ACTIVE', ?)" ); self.session @@ -273,7 +270,7 @@ impl CassandraEngine { .await .map_err(|e| { tracing::error!("create_table insert GSI: {e}"); - StorageError::Internal(format!("Failed to insert GSI: {}", e)) + StorageError::Internal(format!("Failed to insert GSI: {e}")) })?; gsi_index_ids.push(index_id); @@ -291,10 +288,9 @@ impl CassandraEngine { let index_id = uuid::Uuid::new_v4().to_string(); let insert_index_cql = format!( - "INSERT INTO {}.indexes (table_id, index_name, index_id, index_type, \ + "INSERT INTO {catalog_keyspace}.indexes (table_id, index_name, index_id, index_type, \ key_schema, projection, index_status, provisioned_throughput) \ - VALUES (?, ?, ?, 'LSI', ?, ?, 'ACTIVE', ?)", - catalog_keyspace + VALUES (?, ?, ?, 'LSI', ?, ?, 'ACTIVE', ?)" ); self.session @@ -312,7 +308,7 @@ impl CassandraEngine { .await .map_err(|e| { tracing::error!("create_table insert LSI: {e}"); - StorageError::Internal(format!("Failed to insert LSI: {}", e)) + StorageError::Internal(format!("Failed to insert LSI: {e}")) })?; lsi_index_ids.push(index_id); @@ -323,8 +319,7 @@ impl CassandraEngine { if let Some(tags) = &input.tags { for tag in tags { let insert_tag_cql = format!( - "INSERT INTO {}.tags (resource_arn, tag_key, tag_value) VALUES (?, ?, ?)", - catalog_keyspace + "INSERT INTO {catalog_keyspace}.tags (resource_arn, tag_key, tag_value) VALUES (?, ?, ?)" ); self.session @@ -337,7 +332,7 @@ impl CassandraEngine { ), ) .await - .map_err(|e| StorageError::Internal(format!("Failed to insert tag: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Failed to insert tag: {e}")))?; } } @@ -457,7 +452,9 @@ impl CassandraEngine { let billing_mode_summary = if billing_mode == BillingMode::PayPerRequest { Some(BillingModeSummary { billing_mode: BillingMode::PayPerRequest, - last_update_to_pay_per_request_date_time: Some(creation_timestamp as f64 / 1000.0), + last_update_to_pay_per_request_date_time: Some( + crate::cassandra_util::millis_to_seconds_f64(creation_timestamp), + ), }) } else { None @@ -483,7 +480,7 @@ impl CassandraEngine { key_schema: input.key_schema, attribute_definitions: input.attribute_definitions, table_status: response_status, - creation_date_time: creation_timestamp as f64 / 1000.0, + creation_date_time: crate::cassandra_util::millis_to_seconds_f64(creation_timestamp), table_size_bytes: 0, item_count: 0, table_arn, diff --git a/crates/storage-cassandra/src/credential_store.rs b/crates/storage-cassandra/src/credential_store.rs index 9703e8f3..e1ae4b7f 100644 --- a/crates/storage-cassandra/src/credential_store.rs +++ b/crates/storage-cassandra/src/credential_store.rs @@ -116,8 +116,7 @@ impl CassandraCredentialStore { let catalog_keyspace = self.catalog_keyspace(); let query = format!( "SELECT secret_key_encrypted, account_id, user_name, is_active \ - FROM {}.access_keys WHERE access_key_id = ?", - catalog_keyspace + FROM {catalog_keyspace}.access_keys WHERE access_key_id = ?" ); let result = self @@ -192,8 +191,7 @@ impl CassandraCredentialStore { let query = format!( "SELECT secret_key_encrypted, account_id, role_name, session_name, \ session_token, expires_at \ - FROM {}.iam_sessions WHERE access_key_id = ? ALLOW FILTERING", - catalog_keyspace + FROM {catalog_keyspace}.iam_sessions WHERE access_key_id = ? ALLOW FILTERING" ); let result = self diff --git a/crates/storage-cassandra/src/data/ddl.rs b/crates/storage-cassandra/src/data/ddl.rs index dd56f6a0..67bebd4a 100644 --- a/crates/storage-cassandra/src/data/ddl.rs +++ b/crates/storage-cassandra/src/data/ddl.rs @@ -15,12 +15,12 @@ use crate::CassandraEngine; /// CQL table name for a DynamoDB table in an account keyspace. pub(crate) fn data_table_name(table_id: &str) -> String { - format!("items_{}", table_id.replace("-", "_")) + format!("items_{}", table_id.replace('-', "_")) } /// CQL table name for a GSI/LSI data table. pub fn index_table_name(index_id: &str) -> String { - format!("index_{}", index_id.replace("-", "_")) + format!("index_{}", index_id.replace('-', "_")) } /// Look up all RANGE key attribute definitions from the key schema (preserving order). @@ -59,19 +59,18 @@ impl CassandraEngine { let query = format!( "SELECT key_schema, attribute_definitions, table_status, table_id, stream_specification \ - FROM {}.tables WHERE account_id = ? AND table_name = ?", - catalog_keyspace + FROM {catalog_keyspace}.tables WHERE account_id = ? AND table_name = ?" ); let result = self .session .query_with_values(&query, cdrs_tokio::query_values!(account_id, table_name)) .await - .map_err(|e| StorageError::Internal(format!("Query table: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Query table: {e}")))?; let body = result .response_body() - .map_err(|e| StorageError::Internal(format!("Parse response: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Parse response: {e}")))?; let rows = body .into_rows() @@ -84,16 +83,16 @@ impl CassandraEngine { let ks_text: String = row .get_r_by_name("key_schema") - .map_err(|e| StorageError::Internal(format!("Parse key_schema: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Parse key_schema: {e}")))?; let ad_text: String = row .get_r_by_name("attribute_definitions") - .map_err(|e| StorageError::Internal(format!("Parse attribute_definitions: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Parse attribute_definitions: {e}")))?; let status: String = row .get_r_by_name("table_status") - .map_err(|e| StorageError::Internal(format!("Parse table_status: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Parse table_status: {e}")))?; let table_id: String = row .get_r_by_name("table_id") - .map_err(|e| StorageError::Internal(format!("Parse table_id: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Parse table_id: {e}")))?; let stream_spec_text: Option = row.get_by_name("stream_specification").ok().flatten(); @@ -180,7 +179,7 @@ impl CassandraEngine { // Hash-only table (no clustering columns) // partition_max_delete_timestamp is a regular column (not STATIC) since there's only one row per partition format!( - "CREATE TABLE {}.{} (\ + "CREATE TABLE {account_keyspace}.{ddb_table} (\ pk text PRIMARY KEY, \ partition_max_delete_timestamp bigint, \ item_data text, \ @@ -188,14 +187,13 @@ impl CassandraEngine { prepared_txn_timestamp bigint, \ last_committed_txn_timestamp bigint, \ created_to_prepare boolean\ - )", - account_keyspace, ddb_table + )" ) } else if sk_infos.len() == 1 { // Single sort key - use typed columns let sk_col = sk_column(sk_infos[0].1); format!( - "CREATE TABLE {}.{} (\ + "CREATE TABLE {account_keyspace}.{ddb_table} (\ pk text, \ partition_max_delete_timestamp bigint STATIC, \ sk_s text, \ @@ -206,9 +204,8 @@ impl CassandraEngine { prepared_txn_timestamp bigint, \ last_committed_txn_timestamp bigint, \ created_to_prepare boolean, \ - PRIMARY KEY (pk, {})\ - )", - account_keyspace, ddb_table, sk_col + PRIMARY KEY (pk, {sk_col})\ + )" ) } else { // Multi-part RANGE key @@ -224,9 +221,9 @@ impl CassandraEngine { col_defs.push("sk_b blob".to_owned()); } else { let n = i + 1; - col_defs.push(format!("sk{}_s text", n)); - col_defs.push(format!("sk{}_n decimal", n)); - col_defs.push(format!("sk{}_b blob", n)); + col_defs.push(format!("sk{n}_s text")); + col_defs.push(format!("sk{n}_n decimal")); + col_defs.push(format!("sk{n}_b blob")); } pk_cols.push(col); } @@ -249,7 +246,7 @@ impl CassandraEngine { self.session .query(&ddl) .await - .map_err(|e| StorageError::Internal(format!("Failed to create data table: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Failed to create data table: {e}")))?; Ok(()) } @@ -261,12 +258,12 @@ impl CassandraEngine { table_id: &str, ) -> Result<(), StorageError> { let ddb_table = data_table_name(table_id); - let ddl = format!("DROP TABLE IF EXISTS {}.{}", account_keyspace, ddb_table); + let ddl = format!("DROP TABLE IF EXISTS {account_keyspace}.{ddb_table}"); self.session .query(&ddl) .await - .map_err(|e| StorageError::Internal(format!("Failed to drop data table: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Failed to drop data table: {e}")))?; Ok(()) } @@ -278,10 +275,10 @@ impl CassandraEngine { index_id: &str, ) -> Result<(), StorageError> { let idx_table = index_table_name(index_id); - let ddl = format!("DROP TABLE IF EXISTS {}.{}", account_keyspace, idx_table); + let ddl = format!("DROP TABLE IF EXISTS {account_keyspace}.{idx_table}"); self.session.query(&ddl).await.map_err(|e| { - StorageError::Internal(format!("Failed to drop index data table: {}", e)) + StorageError::Internal(format!("Failed to drop index data table: {e}")) })?; Ok(()) @@ -325,9 +322,9 @@ impl CassandraEngine { col_defs.push("sk_b blob".to_owned()); } else { let n = i + 1; - col_defs.push(format!("sk{}_s text", n)); - col_defs.push(format!("sk{}_n decimal", n)); - col_defs.push(format!("sk{}_b blob", n)); + col_defs.push(format!("sk{n}_s text")); + col_defs.push(format!("sk{n}_n decimal")); + col_defs.push(format!("sk{n}_b blob")); } } @@ -340,9 +337,9 @@ impl CassandraEngine { col_defs.push("base_sk_b blob".to_owned()); } else { let n = i + 1; - col_defs.push(format!("base_sk{}_s text", n)); - col_defs.push(format!("base_sk{}_n decimal", n)); - col_defs.push(format!("base_sk{}_b blob", n)); + col_defs.push(format!("base_sk{n}_s text")); + col_defs.push(format!("base_sk{n}_n decimal")); + col_defs.push(format!("base_sk{n}_b blob")); } } @@ -387,7 +384,7 @@ impl CassandraEngine { self.session .query(&ddl) .await - .map_err(|e| StorageError::Internal(format!("Failed to create index table: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Failed to create index table: {e}")))?; Ok(()) } diff --git a/crates/storage-cassandra/src/data/delete_item.rs b/crates/storage-cassandra/src/data/delete_item.rs index 1626959f..15562640 100644 --- a/crates/storage-cassandra/src/data/delete_item.rs +++ b/crates/storage-cassandra/src/data/delete_item.rs @@ -56,8 +56,7 @@ impl CassandraEngine { // Always read to check prepared_txn_id for transaction conflict detection. let select_query = format!( - "SELECT item_data, prepared_txn_id FROM {}.{} WHERE pk = ? AND {} = ?", - data_keyspace, ddb_table, sk_col + "SELECT item_data, prepared_txn_id FROM {data_keyspace}.{ddb_table} WHERE pk = ? AND {sk_col} = ?" ); let old_result = @@ -65,7 +64,7 @@ impl CassandraEngine { let body = old_result .response_body() - .map_err(|e| StorageError::Internal(format!("Parse response: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Parse response: {e}")))?; let (old_item_opt, has_prepared_txn) = if let Some(rows) = body.into_rows() { if let Some(row) = rows.into_iter().next() { @@ -117,8 +116,7 @@ impl CassandraEngine { // Delete the item (with index updates if needed). let delete_cql = format!( - "DELETE FROM {}.{} WHERE pk = ? AND {} = ?", - data_keyspace, ddb_table, sk_col + "DELETE FROM {data_keyspace}.{ddb_table} WHERE pk = ? AND {sk_col} = ?" ); // Update partition_max_delete_timestamp before the batch (must precede delete). @@ -195,7 +193,7 @@ impl CassandraEngine { .map_err(|e| StorageError::Internal(e.to_string()))?, ) .await - .map_err(|e| StorageError::Internal(format!("Batch execution: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Batch execution: {e}")))?; if async_enqueued > 0 { self.gsi_queue.notify_workers(); @@ -208,8 +206,7 @@ impl CassandraEngine { // Always read to check prepared_txn_id for transaction conflict detection let select_query = format!( - "SELECT item_data, prepared_txn_id FROM {}.{} WHERE pk = ?", - data_keyspace, ddb_table + "SELECT item_data, prepared_txn_id FROM {data_keyspace}.{ddb_table} WHERE pk = ?" ); let row = crate::cassandra_util::query_optional( @@ -265,7 +262,7 @@ impl CassandraEngine { } // Delete the item (with index updates if needed). - let delete_cql = format!("DELETE FROM {}.{} WHERE pk = ?", data_keyspace, ddb_table); + let delete_cql = format!("DELETE FROM {data_keyspace}.{ddb_table} WHERE pk = ?"); // Update partition_max_delete_timestamp before the batch (must precede delete). if old_item_opt.is_some() { @@ -292,7 +289,7 @@ impl CassandraEngine { self.session .query_with_values(&delete_cql, cdrs_tokio::query_values!(pk_text.as_str())) .await - .map_err(|e| StorageError::Internal(format!("Delete failed: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Delete failed: {e}")))?; } else { let delete_qv = cdrs_tokio::query::QueryValues::SimpleValues(vec![ cdrs_tokio::types::value::Value::from(pk_text.as_str()), @@ -342,7 +339,7 @@ impl CassandraEngine { .map_err(|e| StorageError::Internal(e.to_string()))?, ) .await - .map_err(|e| StorageError::Internal(format!("Batch execution: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Batch execution: {e}")))?; if async_enqueued > 0 { self.gsi_queue.notify_workers(); @@ -365,16 +362,12 @@ impl CassandraEngine { table: &str, pk: &str, ) -> Result<(), StorageError> { - let now_ms = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as i64; + let now_ms = crate::cassandra_util::now_millis(); // Step 1: Try to set if null let query_null = format!( - "UPDATE {}.{} SET partition_max_delete_timestamp = ? WHERE pk = ? \ - IF partition_max_delete_timestamp = null", - keyspace, table + "UPDATE {keyspace}.{table} SET partition_max_delete_timestamp = ? WHERE pk = ? \ + IF partition_max_delete_timestamp = null" ); let result = self @@ -390,7 +383,7 @@ impl CassandraEngine { let applied = result .response_body() .ok() - .and_then(|body| body.into_rows()) + .and_then(cdrs_tokio::frame::message_response::ResponseBody::into_rows) .and_then(|rows| rows.into_iter().next()) .and_then(|row| { use cdrs_tokio::types::IntoRustByName; @@ -402,9 +395,8 @@ impl CassandraEngine { if !applied { // Step 2: Column already has a value - update only if ours is higher let query_compare = format!( - "UPDATE {}.{} SET partition_max_delete_timestamp = ? WHERE pk = ? \ - IF partition_max_delete_timestamp < ?", - keyspace, table + "UPDATE {keyspace}.{table} SET partition_max_delete_timestamp = ? WHERE pk = ? \ + IF partition_max_delete_timestamp < ?" ); self.session diff --git a/crates/storage-cassandra/src/data/index.rs b/crates/storage-cassandra/src/data/index.rs index dbf7fc5e..75cf3f18 100644 --- a/crates/storage-cassandra/src/data/index.rs +++ b/crates/storage-cassandra/src/data/index.rs @@ -114,7 +114,7 @@ pub async fn fetch_index_by_name( let row = rows .into_iter() .next() - .ok_or_else(|| StorageError::IndexNotFound(format!("Index {} not found", index_name)))?; + .ok_or_else(|| StorageError::IndexNotFound(format!("Index {index_name} not found")))?; let index_id: String = get_column(&row, "index_id", "fetch_index_by_name")?; let index_type: String = get_column(&row, "index_type", "fetch_index_by_name")?; @@ -198,6 +198,7 @@ pub(crate) fn item_has_index_keys(item: &Item, index_ks: &[KeySchemaElement]) -> pub(super) fn effective_delay(idx: &IndexMeta, system_default: u64) -> u64 { match idx.propagation_delay_ms { Some(0) => 0, + #[allow(clippy::cast_sign_loss)] Some(ms) if ms > 0 => ms as u64, Some(_) => system_default, // Negative values treated as "use system default". None => system_default, @@ -345,10 +346,8 @@ pub(crate) async fn enqueue_async_indexes( .unwrap_or(0) }; - let now_ms = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as i64; + let now_ms = crate::cassandra_util::now_millis(); + #[allow(clippy::cast_possible_wrap)] let jittered_ms = now_ms + jitter_delay_ms(delay) as i64; // Clamp: ready_at must be >= last_ready_at + 1 to preserve causal ordering // across concurrent ExtendDB instances. last_ready_at is updated atomically @@ -532,7 +531,7 @@ pub(crate) fn sk_to_value(sk: &SortKeyValue) -> cdrs_tokio::types::value::Value SortKeyValue::S(s) => s.as_str().into(), SortKeyValue::N(n) => super::decimal_to_value(n), SortKeyValue::B(b) => { - cdrs_tokio::types::value::Value::from(cdrs_tokio::types::blob::Blob::new(b.to_vec())) + cdrs_tokio::types::value::Value::from(cdrs_tokio::types::blob::Blob::new(b.clone())) } } } @@ -553,8 +552,7 @@ pub(crate) async fn delete_indexes_for_table( ) -> Result, StorageError> { // Collect index IDs let index_query = format!( - "SELECT index_id FROM {}.indexes WHERE table_id = ?", - catalog_keyspace + "SELECT index_id FROM {catalog_keyspace}.indexes WHERE table_id = ?" ); let rows = query_rows( @@ -573,8 +571,7 @@ pub(crate) async fn delete_indexes_for_table( // Delete index metadata from catalog let delete_query = format!( - "DELETE FROM {}.indexes WHERE table_id = ?", - catalog_keyspace + "DELETE FROM {catalog_keyspace}.indexes WHERE table_id = ?" ); crate::cassandra_util::execute( @@ -610,8 +607,7 @@ pub(crate) async fn delete_index_by_name( ) -> Result { // Get index_id first let query = format!( - "SELECT index_id FROM {}.indexes WHERE table_id = ? AND index_name = ?", - catalog_keyspace + "SELECT index_id FROM {catalog_keyspace}.indexes WHERE table_id = ? AND index_name = ?" ); let row = crate::cassandra_util::query_optional( @@ -627,8 +623,7 @@ pub(crate) async fn delete_index_by_name( // Delete from catalog let delete_query = format!( - "DELETE FROM {}.indexes WHERE table_id = ? AND index_name = ?", - catalog_keyspace + "DELETE FROM {catalog_keyspace}.indexes WHERE table_id = ? AND index_name = ?" ); crate::cassandra_util::execute( diff --git a/crates/storage-cassandra/src/data/mod.rs b/crates/storage-cassandra/src/data/mod.rs index 9657af56..3b822b54 100644 --- a/crates/storage-cassandra/src/data/mod.rs +++ b/crates/storage-cassandra/src/data/mod.rs @@ -67,7 +67,9 @@ pub(crate) fn bigdecimal_to_cql_decimal( n: &bigdecimal::BigDecimal, ) -> cdrs_tokio::types::decimal::Decimal { let (unscaled, scale) = n.as_bigint_and_exponent(); - cdrs_tokio::types::decimal::Decimal::new(unscaled, scale as i32) + #[allow(clippy::cast_possible_truncation)] + let scale_i32 = scale as i32; + cdrs_tokio::types::decimal::Decimal::new(unscaled, scale_i32) } /// Bind a DynamoDB numeric value (`BigDecimal`) as a Cassandra `decimal` bound @@ -102,11 +104,11 @@ pub(crate) async fn query_with_pk_sk( } SortKeyValue::B(b) => { session - .query_with_values(query, cdrs_tokio::query_values!(pk, b.to_vec())) + .query_with_values(query, cdrs_tokio::query_values!(pk, b.clone())) .await } } - .map_err(|e| StorageError::Internal(format!("Query failed: {}", e))) + .map_err(|e| StorageError::Internal(format!("Query failed: {e}"))) } /// Execute a query with pk, sort key, and item_data, returning the result. @@ -135,11 +137,11 @@ pub(crate) async fn query_with_pk_sk_item( } SortKeyValue::B(b) => { session - .query_with_values(query, cdrs_tokio::query_values!(pk, b.to_vec(), item_text)) + .query_with_values(query, cdrs_tokio::query_values!(pk, b.clone(), item_text)) .await } } - .map_err(|e| StorageError::Internal(format!("Query failed: {}", e))) + .map_err(|e| StorageError::Internal(format!("Query failed: {e}"))) } /// Execute a query with `(pk, sk, txn_id_bytes)` bound parameters. @@ -168,11 +170,11 @@ pub(crate) async fn query_with_pk_sk_txnid( } SortKeyValue::B(b) => { session - .query_with_values(query, cdrs_tokio::query_values!(pk, b.to_vec(), txn_id)) + .query_with_values(query, cdrs_tokio::query_values!(pk, b.clone(), txn_id)) .await } } - .map_err(|e| StorageError::Internal(format!("Query failed: {}", e))) + .map_err(|e| StorageError::Internal(format!("Query failed: {e}"))) } /// Execute a query with `(txn_id_bytes, txn_timestamp, pk, sk)` bound parameters. @@ -207,12 +209,12 @@ pub(crate) async fn query_with_txnid_ts_pk_sk( session .query_with_values( query, - cdrs_tokio::query_values!(txn_id, txn_timestamp, pk, b.to_vec()), + cdrs_tokio::query_values!(txn_id, txn_timestamp, pk, b.clone()), ) .await } } - .map_err(|e| StorageError::Internal(format!("Query failed: {}", e))) + .map_err(|e| StorageError::Internal(format!("Query failed: {e}"))) } /// Execute a query with `(pk, sk, item_text, txn_id_bytes, txn_timestamp)` bound parameters. @@ -254,12 +256,12 @@ pub(crate) async fn query_with_pk_sk_item_txnid_ts( session .query_with_values( query, - cdrs_tokio::query_values!(pk, b.to_vec(), item_text, txn_id, txn_timestamp), + cdrs_tokio::query_values!(pk, b.clone(), item_text, txn_id, txn_timestamp), ) .await } } - .map_err(|e| StorageError::Internal(format!("Query failed: {}", e))) + .map_err(|e| StorageError::Internal(format!("Query failed: {e}"))) } /// Execute a query with `(item_text, txn_timestamp, pk, sk, txn_id_bytes)` bound parameters. @@ -301,12 +303,12 @@ pub(crate) async fn query_with_item_ts_pk_sk_txnid( session .query_with_values( query, - cdrs_tokio::query_values!(item_text, txn_timestamp, pk, b.to_vec(), txn_id), + cdrs_tokio::query_values!(item_text, txn_timestamp, pk, b.clone(), txn_id), ) .await } } - .map_err(|e| StorageError::Internal(format!("Query failed: {}", e))) + .map_err(|e| StorageError::Internal(format!("Query failed: {e}"))) } /// Execute a SELECT query by primary key (pk only, or pk + sort key). @@ -324,19 +326,17 @@ pub(crate) async fn select_by_pk( ) -> Result, StorageError> { let result = if let (Some(sk), Some(sk_col)) = (sk, sk_col) { let query = format!( - "SELECT {} FROM {}.{} WHERE pk = ? AND {} = ?", - columns, keyspace, table, sk_col + "SELECT {columns} FROM {keyspace}.{table} WHERE pk = ? AND {sk_col} = ?" ); query_with_pk_sk(session, &query, pk, sk).await } else { let query = format!( - "SELECT {} FROM {}.{} WHERE pk = ?", - columns, keyspace, table + "SELECT {columns} FROM {keyspace}.{table} WHERE pk = ?" ); session .query_with_values(&query, cdrs_tokio::query_values!(pk)) .await - .map_err(|e| StorageError::Internal(format!("Query failed: {}", e))) + .map_err(|e| StorageError::Internal(format!("Query failed: {e}"))) }?; let rows = result diff --git a/crates/storage-cassandra/src/data/put_get_item.rs b/crates/storage-cassandra/src/data/put_get_item.rs index ed6ffc09..281b3515 100644 --- a/crates/storage-cassandra/src/data/put_get_item.rs +++ b/crates/storage-cassandra/src/data/put_get_item.rs @@ -67,8 +67,7 @@ impl CassandraEngine { // Read old item including prepared_txn_id for transaction conflict detection let select_query = format!( - "SELECT item_data, prepared_txn_id FROM {}.{} WHERE pk = ? AND {} = ?", - data_keyspace, ddb_table, sk_col + "SELECT item_data, prepared_txn_id FROM {data_keyspace}.{ddb_table} WHERE pk = ? AND {sk_col} = ?" ); let old_result = @@ -76,13 +75,13 @@ impl CassandraEngine { let body = old_result .response_body() - .map_err(|e| StorageError::Internal(format!("Parse response: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Parse response: {e}")))?; let (old_item_opt, has_prepared_txn) = if let Some(rows) = body.into_rows() { if let Some(row) = rows.into_iter().next() { let item_data: String = row .get_r_by_name("item_data") - .map_err(|e| StorageError::Internal(format!("Parse item_data: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Parse item_data: {e}")))?; let prepared_txn_id: Option = row.get_by_name("prepared_txn_id").ok().flatten(); (Some(json_to_item(item_data)?), prepared_txn_id.is_some()) @@ -136,8 +135,7 @@ impl CassandraEngine { if indexes.is_empty() && stream_stmt.is_none() { // Fast path: no batch needed. let insert_query = format!( - "INSERT INTO {}.{} (pk, {}, item_data) VALUES (?, ?, ?)", - data_keyspace, ddb_table, sk_col + "INSERT INTO {data_keyspace}.{ddb_table} (pk, {sk_col}, item_data) VALUES (?, ?, ?)" ); query_with_pk_sk_item( &self.session, @@ -150,8 +148,7 @@ impl CassandraEngine { } else { // LOGGED BATCH: item insert + optional index updates + optional stream record. let insert_cql = format!( - "INSERT INTO {}.{} (pk, {}, item_data) VALUES (?, ?, ?)", - data_keyspace, ddb_table, sk_col + "INSERT INTO {data_keyspace}.{ddb_table} (pk, {sk_col}, item_data) VALUES (?, ?, ?)" ); let insert_qv = cdrs_tokio::query::QueryValues::SimpleValues(vec![ cdrs_tokio::types::value::Value::from(pk_text.as_str()), @@ -203,7 +200,7 @@ impl CassandraEngine { .map_err(|e| StorageError::Internal(e.to_string()))?, ) .await - .map_err(|e| StorageError::Internal(format!("Batch execution: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Batch execution: {e}")))?; if async_enqueued > 0 { self.gsi_queue.notify_workers(); @@ -216,8 +213,7 @@ impl CassandraEngine { // Read old item including prepared_txn_id for transaction conflict detection let select_query = format!( - "SELECT item_data, prepared_txn_id FROM {}.{} WHERE pk = ?", - data_keyspace, ddb_table + "SELECT item_data, prepared_txn_id FROM {data_keyspace}.{ddb_table} WHERE pk = ?" ); let old_result = self @@ -227,17 +223,17 @@ impl CassandraEngine { cdrs_tokio::query_values!(pk_text.as_ref() as &str), ) .await - .map_err(|e| StorageError::Internal(format!("Select for put_item: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Select for put_item: {e}")))?; let body = old_result .response_body() - .map_err(|e| StorageError::Internal(format!("Parse response: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Parse response: {e}")))?; let (old_item_opt, has_prepared_txn) = if let Some(rows) = body.into_rows() { if let Some(row) = rows.into_iter().next() { let item_data: String = row .get_r_by_name("item_data") - .map_err(|e| StorageError::Internal(format!("Parse item_data: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Parse item_data: {e}")))?; let prepared_txn_id: Option = row.get_by_name("prepared_txn_id").ok().flatten(); (Some(json_to_item(item_data)?), prepared_txn_id.is_some()) @@ -291,8 +287,7 @@ impl CassandraEngine { if indexes.is_empty() && stream_stmt.is_none() { // Fast path: no batch needed. let insert_query = format!( - "INSERT INTO {}.{} (pk, item_data) VALUES (?, ?)", - data_keyspace, ddb_table + "INSERT INTO {data_keyspace}.{ddb_table} (pk, item_data) VALUES (?, ?)" ); self.session .query_with_values( @@ -300,12 +295,11 @@ impl CassandraEngine { cdrs_tokio::query_values!(pk_text.as_ref() as &str, item_text.as_str()), ) .await - .map_err(|e| StorageError::Internal(format!("Insert item: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Insert item: {e}")))?; } else { // LOGGED BATCH: item insert + optional index updates + optional stream record. let insert_cql = format!( - "INSERT INTO {}.{} (pk, item_data) VALUES (?, ?)", - data_keyspace, ddb_table + "INSERT INTO {data_keyspace}.{ddb_table} (pk, item_data) VALUES (?, ?)" ); let insert_qv = cdrs_tokio::query::QueryValues::SimpleValues(vec![ cdrs_tokio::types::value::Value::from(pk_text.as_str()), @@ -356,7 +350,7 @@ impl CassandraEngine { .map_err(|e| StorageError::Internal(e.to_string()))?, ) .await - .map_err(|e| StorageError::Internal(format!("Batch execution: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Batch execution: {e}")))?; if async_enqueued > 0 { self.gsi_queue.notify_workers(); @@ -393,21 +387,20 @@ impl CassandraEngine { let sk_col = sk_column(sk_type); let query = format!( - "SELECT item_data FROM {}.{} WHERE pk = ? AND {} = ?", - data_keyspace, ddb_table, sk_col + "SELECT item_data FROM {data_keyspace}.{ddb_table} WHERE pk = ? AND {sk_col} = ?" ); let result = query_with_pk_sk(&self.session, &query, pk_text.as_ref(), &sk).await?; let body = result .response_body() - .map_err(|e| StorageError::Internal(format!("Parse response: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Parse response: {e}")))?; if let Some(rows) = body.into_rows() && let Some(row) = rows.into_iter().next() { let item_data: String = row .get_r_by_name("item_data") - .map_err(|e| StorageError::Internal(format!("Parse item_data: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Parse item_data: {e}")))?; return Ok(Some(json_to_item(item_data)?)); } @@ -415,25 +408,24 @@ impl CassandraEngine { } else { // PK-only table let query = format!( - "SELECT item_data FROM {}.{} WHERE pk = ?", - data_keyspace, ddb_table + "SELECT item_data FROM {data_keyspace}.{ddb_table} WHERE pk = ?" ); let result = self .session .query_with_values(&query, cdrs_tokio::query_values!(pk_text.as_ref() as &str)) .await - .map_err(|e| StorageError::Internal(format!("Get item: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Get item: {e}")))?; let body = result .response_body() - .map_err(|e| StorageError::Internal(format!("Parse response: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Parse response: {e}")))?; if let Some(rows) = body.into_rows() && let Some(row) = rows.into_iter().next() { let item_data: String = row .get_r_by_name("item_data") - .map_err(|e| StorageError::Internal(format!("Parse item_data: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Parse item_data: {e}")))?; return Ok(Some(json_to_item(item_data)?)); } diff --git a/crates/storage-cassandra/src/data/query.rs b/crates/storage-cassandra/src/data/query.rs index 701d79e1..cb16d375 100644 --- a/crates/storage-cassandra/src/data/query.rs +++ b/crates/storage-cassandra/src/data/query.rs @@ -51,7 +51,7 @@ enum PaginationBinds { /// Compute upper bound for begins_with on strings. /// Appends the maximum Unicode codepoint to create an exclusive upper bound. fn string_upper_bound(prefix: &str) -> String { - format!("{}\u{10FFFF}", prefix) + format!("{prefix}\u{10FFFF}") } /// Compute upper bound for begins_with on binary data. @@ -139,8 +139,7 @@ impl CassandraEngine { let account_keyspace = self.account_keyspace(&key_info.account_id); let mut query = format!( - "SELECT item_data FROM {}.{} WHERE pk = ?", - account_keyspace, table_name + "SELECT item_data FROM {account_keyspace}.{table_name} WHERE pk = ?" ); // Step 4: Add sort key condition if present @@ -163,7 +162,7 @@ impl CassandraEngine { CompareOp::Ge => ">=", }; - query.push_str(&format!(" AND {} {} ?", sk_col, op_str)); + query.push_str(&format!(" AND {sk_col} {op_str} ?")); // Resolve and parse SK value let sk_av = resolve_expr_to_av(value, _maps)?; @@ -171,7 +170,7 @@ impl CassandraEngine { Some((sk_col, vec![sk_val])) } SortKeyCondition::Between { low, high, .. } => { - query.push_str(&format!(" AND {} >= ? AND {} <= ?", sk_col, sk_col)); + query.push_str(&format!(" AND {sk_col} >= ? AND {sk_col} <= ?")); // Resolve and parse both bounds let low_av = resolve_expr_to_av(low, _maps)?; @@ -181,7 +180,7 @@ impl CassandraEngine { Some((sk_col, vec![low_sk, high_sk])) } SortKeyCondition::BeginsWith { prefix, .. } => { - query.push_str(&format!(" AND {} >= ? AND {} < ?", sk_col, sk_col)); + query.push_str(&format!(" AND {sk_col} >= ? AND {sk_col} < ?")); // Resolve prefix let prefix_av = resolve_expr_to_av(prefix, _maps)?; @@ -229,7 +228,7 @@ impl CassandraEngine { // Simple pagination - add to query now let sk_col = sk_column(sk_type); let cmp = if forward { ">" } else { "<" }; - query.push_str(&format!(" AND {} {} ?", sk_col, cmp)); + query.push_str(&format!(" AND {sk_col} {cmp} ?")); } // else: two-query logic will handle it below Some(sk_val) @@ -262,13 +261,13 @@ impl CassandraEngine { if let Some((_, sk_type)) = sk_info_opt { let sk_col = sk_column(sk_type); let direction = if forward { "ASC" } else { "DESC" }; - query.push_str(&format!(" ORDER BY {} {}", sk_col, direction)); + query.push_str(&format!(" ORDER BY {sk_col} {direction}")); } // Step 6: Add LIMIT (fetch limit + 1 to detect pagination) // Default limit is 1,000,000 per DynamoDB behavior let fetch_limit = limit.map_or(1_000_001, |l| l.max(0) + 1); - query.push_str(&format!(" LIMIT {}", fetch_limit)); + query.push_str(&format!(" LIMIT {fetch_limit}")); } tracing::debug!( @@ -440,8 +439,7 @@ impl CassandraEngine { let cmp = if forward { ">" } else { "<" }; let dir = if forward { "ASC" } else { "DESC" }; let query1 = format!( - "{} AND {} = ? AND base_pk {} ? ORDER BY {} {}, base_pk {} LIMIT {}", - query, sk_col, cmp, sk_col, dir, dir, fetch_limit + "{query} AND {sk_col} = ? AND base_pk {cmp} ? ORDER BY {sk_col} {dir}, base_pk {dir} LIMIT {fetch_limit}" ); let rows1 = query_with_pk_sk_pk( @@ -518,8 +516,8 @@ impl CassandraEngine { } // Query 2: next SK values (only if we haven't reached limit yet) - if all_rows.len() < fetch_limit && sk_info_opt.is_some() && start_sk.is_some() { - let start_sk = start_sk.unwrap(); // Safe: checked is_some() + if all_rows.len() < fetch_limit && sk_info_opt.is_some() { + if let Some(start_sk) = start_sk { let remaining = fetch_limit - all_rows.len(); let query2 = if let Some((_, sk_type)) = sk_info_opt { let sk_col = sk_column(sk_type); @@ -528,8 +526,7 @@ impl CassandraEngine { let base_sk_col = format!("base_{}", sk_column(*base_sk_type)); let dir = if forward { "ASC" } else { "DESC" }; format!( - " ORDER BY {} {}, base_pk {}, {} {} LIMIT {}", - sk_col, dir, dir, base_sk_col, dir, remaining + " ORDER BY {sk_col} {dir}, base_pk {dir}, {base_sk_col} {dir} LIMIT {remaining}" ) } else { format!( @@ -540,7 +537,7 @@ impl CassandraEngine { remaining ) }; - format!("{} AND {} {} ?{}", query, sk_col, cmp, order_clause) + format!("{query} AND {sk_col} {cmp} ?{order_clause}") } else { // Hash-only index let order_clause = if let Some((_, base_sk_type)) = &base_sk_info_val { @@ -559,7 +556,7 @@ impl CassandraEngine { remaining ) }; - format!("{} AND base_pk > ?{}", query, order_clause) + format!("{query} AND base_pk > ?{order_clause}") }; let rows2 = match &pagination_binds { @@ -595,6 +592,7 @@ impl CassandraEngine { _ => Vec::new(), }; all_rows.extend(rows2); + } // end if let Some(start_sk) } all_rows @@ -660,21 +658,30 @@ impl CassandraEngine { } (None, None) => { // Check if this is hash-only index with pagination - if is_hash_only_index && exclusive_start_key.is_some() { - let start_key = exclusive_start_key.unwrap(); - let base_pk_attr = &key_info.base_key_schema[0].attribute_name; - if let Some(base_pk_val) = start_key.get(base_pk_attr) { - let base_pk_text = pk_to_text(base_pk_val)?.into_owned(); + if is_hash_only_index { + if let Some(start_key) = exclusive_start_key { + let base_pk_attr = &key_info.base_key_schema[0].attribute_name; + if let Some(base_pk_val) = start_key.get(base_pk_attr) { + let base_pk_text = pk_to_text(base_pk_val)?.into_owned(); + cassandra_util::query_rows( + &self.session_arc(), + &query, + cdrs_tokio::query_values!(pk_text.as_str(), base_pk_text.as_str()), + "query", + ) + .await? + } else { + Vec::new() + } + } else { + // PK-only query without pagination (original behavior) cassandra_util::query_rows( &self.session_arc(), &query, - cdrs_tokio::query_values!(pk_text.as_str(), base_pk_text.as_str()), + cdrs_tokio::query_values!(pk_text.as_str()), "query", ) .await? - } else { - // No base_pk in start key, no more results - Vec::new() } } else { // PK-only query without pagination (original behavior) @@ -686,7 +693,7 @@ impl CassandraEngine { ) .await? } - } + } // end (None, None) arm }; // End of single-query match rows_result diff --git a/crates/storage-cassandra/src/data/query_helpers.rs b/crates/storage-cassandra/src/data/query_helpers.rs index 30ad1a15..e3af72e2 100644 --- a/crates/storage-cassandra/src/data/query_helpers.rs +++ b/crates/storage-cassandra/src/data/query_helpers.rs @@ -40,7 +40,7 @@ pub(super) async fn query_with_pk_sk( cassandra_util::query_rows( session, query, - cdrs_tokio::query_values!(pk, b.to_vec()), + cdrs_tokio::query_values!(pk, b.clone()), label, ) .await @@ -84,7 +84,7 @@ pub(super) async fn query_with_pk_sk_sk( cassandra_util::query_rows( session, query, - cdrs_tokio::query_values!(pk, b1.to_vec(), b2.to_vec()), + cdrs_tokio::query_values!(pk, b1.clone(), b2.clone()), label, ) .await @@ -130,7 +130,7 @@ pub(super) async fn query_with_pk_sk_pk_sk( cassandra_util::query_rows( session, query, - query_values!(pk, isk.as_str(), base_pk, bsk.to_vec()), + query_values!(pk, isk.as_str(), base_pk, bsk.clone()), label, ) .await @@ -162,7 +162,7 @@ pub(super) async fn query_with_pk_sk_pk_sk( cassandra_util::query_rows( session, query, - query_values!(pk, super::decimal_to_value(isk), base_pk, bsk.to_vec()), + query_values!(pk, super::decimal_to_value(isk), base_pk, bsk.clone()), label, ) .await @@ -171,7 +171,7 @@ pub(super) async fn query_with_pk_sk_pk_sk( cassandra_util::query_rows( session, query, - query_values!(pk, isk.to_vec(), base_pk, bsk.as_str()), + query_values!(pk, isk.clone(), base_pk, bsk.as_str()), label, ) .await @@ -180,7 +180,7 @@ pub(super) async fn query_with_pk_sk_pk_sk( cassandra_util::query_rows( session, query, - query_values!(pk, isk.to_vec(), base_pk, super::decimal_to_value(bsk)), + query_values!(pk, isk.clone(), base_pk, super::decimal_to_value(bsk)), label, ) .await @@ -189,7 +189,7 @@ pub(super) async fn query_with_pk_sk_pk_sk( cassandra_util::query_rows( session, query, - query_values!(pk, isk.to_vec(), base_pk, bsk.to_vec()), + query_values!(pk, isk.clone(), base_pk, bsk.clone()), label, ) .await @@ -230,7 +230,7 @@ pub(super) async fn query_with_pk_sk_pk( cassandra_util::query_rows( session, query, - cdrs_tokio::query_values!(pk, b.to_vec(), base_pk), + cdrs_tokio::query_values!(pk, b.clone(), base_pk), label, ) .await @@ -270,7 +270,7 @@ pub(super) async fn query_with_pk_pk_sk( cassandra_util::query_rows( session, query, - cdrs_tokio::query_values!(pk, base_pk, b.to_vec()), + cdrs_tokio::query_values!(pk, base_pk, b.clone()), label, ) .await @@ -316,7 +316,7 @@ pub(super) async fn query_with_pk_sk_sk_sk( cassandra_util::query_rows( session, query, - cdrs_tokio::query_values!(pk, b1.to_vec(), b2.to_vec(), b3.to_vec()), + cdrs_tokio::query_values!(pk, b1.clone(), b2.clone(), b3.clone()), label, ) .await diff --git a/crates/storage-cassandra/src/data/scan.rs b/crates/storage-cassandra/src/data/scan.rs index cd20d435..9147e553 100644 --- a/crates/storage-cassandra/src/data/scan.rs +++ b/crates/storage-cassandra/src/data/scan.rs @@ -67,12 +67,12 @@ use crate::cassandra_util::{self, CassandraSession}; /// (single segment covers the entire ring, so no token predicate is needed). fn segment_token_bounds(segment: Option, total_segments: Option) -> Option<(i64, i64)> { let (seg, total) = match (segment, total_segments) { - (Some(s), Some(t)) if t > 1 && s >= 0 && s < t => (s as i128, t as i128), + (Some(s), Some(t)) if t > 1 && s >= 0 && s < t => (i128::from(s), i128::from(t)), _ => return None, }; - let min = i64::MIN as i128; - let max = i64::MAX as i128; + let min = i128::from(i64::MIN); + let max = i128::from(i64::MAX); let ring = max - min + 1; // 2^64 let span = ring / total; @@ -83,6 +83,7 @@ fn segment_token_bounds(segment: Option, total_segments: Option) -> Op min + span * (seg + 1) - 1 }; + #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] Some((lower as i64, upper as i64)) } diff --git a/crates/storage-cassandra/src/data/transaction_ledger.rs b/crates/storage-cassandra/src/data/transaction_ledger.rs index ef4129d1..39076655 100644 --- a/crates/storage-cassandra/src/data/transaction_ledger.rs +++ b/crates/storage-cassandra/src/data/transaction_ledger.rs @@ -102,10 +102,9 @@ impl CassandraEngine { items_blob: &str, ) -> Result<(), StorageError> { let query = format!( - "INSERT INTO {}.transaction_ledger \ + "INSERT INTO {keyspace}.transaction_ledger \ (txn_id, state, started_at, client_token, request_fingerprint, items_blob) \ - VALUES (?, ?, ?, ?, ?, ?) IF NOT EXISTS", - keyspace + VALUES (?, ?, ?, ?, ?, ?) IF NOT EXISTS" ); let result = self @@ -154,8 +153,7 @@ impl CassandraEngine { new_state: TransactionState, ) -> Result<(), StorageError> { let query = format!( - "UPDATE {}.transaction_ledger SET state = ? WHERE txn_id = ?", - keyspace + "UPDATE {keyspace}.transaction_ledger SET state = ? WHERE txn_id = ?" ); self.session @@ -188,8 +186,7 @@ impl CassandraEngine { let blob = serde_json::to_string(ops) .map_err(|e| StorageError::Internal(format!("serialize ledger ops: {e}")))?; let query = format!( - "UPDATE {}.transaction_ledger SET items_blob = ? WHERE txn_id = ?", - keyspace + "UPDATE {keyspace}.transaction_ledger SET items_blob = ? WHERE txn_id = ?" ); self.session .query_with_values( @@ -212,8 +209,7 @@ impl CassandraEngine { ) -> Result, StorageError> { let query = format!( "SELECT txn_id, state, started_at, client_token, request_fingerprint, items_blob \ - FROM {}.transaction_ledger WHERE txn_id = ?", - keyspace + FROM {keyspace}.transaction_ledger WHERE txn_id = ?" ); let row = query_optional::( @@ -255,8 +251,7 @@ impl CassandraEngine { ) -> Result, StorageError> { let query = format!( "SELECT txn_id, state, started_at, client_token, request_fingerprint, items_blob \ - FROM {}.transaction_ledger WHERE started_at < ? ALLOW FILTERING", - keyspace + FROM {keyspace}.transaction_ledger WHERE started_at < ? ALLOW FILTERING" ); let rows = query_rows::( @@ -298,8 +293,7 @@ impl CassandraEngine { txn_id: Uuid, ) -> Result<(), StorageError> { let query = format!( - "DELETE FROM {}.transaction_ledger WHERE txn_id = ?", - keyspace + "DELETE FROM {keyspace}.transaction_ledger WHERE txn_id = ?" ); self.session diff --git a/crates/storage-cassandra/src/data/transactions.rs b/crates/storage-cassandra/src/data/transactions.rs index 9ac52bdb..c381c8ba 100644 --- a/crates/storage-cassandra/src/data/transactions.rs +++ b/crates/storage-cassandra/src/data/transactions.rs @@ -246,10 +246,7 @@ impl CassandraEngine { // Generate unique transaction ID let txn_id = Uuid::new_v4(); - let started_at = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as i64; + let started_at = crate::cassandra_util::now_millis(); // Build initial ledger ops (pk/sk known; item_data for UPDATE filled in after PREPARE) let mut ledger_ops = Self::initial_ledger_ops(ops)?; @@ -703,8 +700,7 @@ impl CassandraEngine { fingerprint: &str, ) -> Result<(), StorageError> { let select_query = format!( - "SELECT fingerprint FROM {}.idempotency_tokens_by_account WHERE account_id = ? AND \"token\" = ?", - keyspace + "SELECT fingerprint FROM {keyspace}.idempotency_tokens_by_account WHERE account_id = ? AND \"token\" = ?" ); let row = query_optional::( @@ -726,14 +722,10 @@ impl CassandraEngine { // Insert with LWT to handle concurrent requests racing on the same token. let insert_query = format!( - "INSERT INTO {}.idempotency_tokens_by_account (account_id, \"token\", fingerprint, created_at) \ - VALUES (?, ?, ?, ?) IF NOT EXISTS", - keyspace + "INSERT INTO {keyspace}.idempotency_tokens_by_account (account_id, \"token\", fingerprint, created_at) \ + VALUES (?, ?, ?, ?) IF NOT EXISTS" ); - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as i64; + let now = crate::cassandra_util::now_millis(); let result = self .session @@ -753,7 +745,7 @@ impl CassandraEngine { let applied: bool = result .response_body() .ok() - .and_then(|b| b.into_rows()) + .and_then(cdrs_tokio::frame::message_response::ResponseBody::into_rows) .and_then(|mut rows| rows.drain(..).next()) .and_then(|row| row.get_r_by_name("[applied]").ok()) .unwrap_or(true); @@ -825,8 +817,7 @@ impl CassandraEngine { .map_err(|e| CancellationReason::validation_error(e.to_string()))?; let query = format!( - "SELECT partition_max_delete_timestamp FROM {}.{} WHERE pk = ? LIMIT 1", - account_keyspace, table_name + "SELECT partition_max_delete_timestamp FROM {account_keyspace}.{table_name} WHERE pk = ? LIMIT 1" ); let result = self @@ -894,16 +885,14 @@ impl CassandraEngine { .map_err(|e| CancellationReason::validation_error(e.to_string()))? .to_string(); let query = format!( - "INSERT INTO {}.{} (pk, {}, item_data, prepared_txn_id, prepared_txn_timestamp, created_to_prepare) \ - VALUES (?, ?, ?, ?, ?, true) IF NOT EXISTS", - keyspace, ddb_table, sk_col + "INSERT INTO {keyspace}.{ddb_table} (pk, {sk_col}, item_data, prepared_txn_id, prepared_txn_timestamp, created_to_prepare) \ + VALUES (?, ?, ?, ?, ?, true) IF NOT EXISTS" ); query_with_pk_sk_item_txnid_ts(&self.session, &query, pk_text.as_str(), &sk, &item_text, txn_id_bytes, txn_timestamp).await } else { let query = format!( - "UPDATE {}.{} SET prepared_txn_id = ?, prepared_txn_timestamp = ? \ - WHERE pk = ? AND {} = ? IF prepared_txn_id = null", - keyspace, ddb_table, sk_col + "UPDATE {keyspace}.{ddb_table} SET prepared_txn_id = ?, prepared_txn_timestamp = ? \ + WHERE pk = ? AND {sk_col} = ? IF prepared_txn_id = null" ); query_with_txnid_ts_pk_sk(&self.session, &query, txn_id_bytes, txn_timestamp, pk_text.as_str(), &sk).await } @@ -912,9 +901,8 @@ impl CassandraEngine { .map_err(|e| CancellationReason::validation_error(e.to_string()))? .to_string(); let query = format!( - "INSERT INTO {}.{} (pk, item_data, prepared_txn_id, prepared_txn_timestamp, created_to_prepare) \ - VALUES (?, ?, ?, ?, true) IF NOT EXISTS", - keyspace, ddb_table + "INSERT INTO {keyspace}.{ddb_table} (pk, item_data, prepared_txn_id, prepared_txn_timestamp, created_to_prepare) \ + VALUES (?, ?, ?, ?, true) IF NOT EXISTS" ); self.session .query_with_values(&query, cdrs_tokio::query_values!(pk_text.as_str(), item_text, txn_id_bytes, txn_timestamp)) @@ -922,9 +910,8 @@ impl CassandraEngine { .map_err(|e| StorageError::Internal(e.to_string())) } else { let query = format!( - "UPDATE {}.{} SET prepared_txn_id = ?, prepared_txn_timestamp = ? \ - WHERE pk = ? IF prepared_txn_id = null", - keyspace, ddb_table + "UPDATE {keyspace}.{ddb_table} SET prepared_txn_id = ?, prepared_txn_timestamp = ? \ + WHERE pk = ? IF prepared_txn_id = null" ); self.session .query_with_values(&query, cdrs_tokio::query_values!(txn_id_bytes, txn_timestamp, pk_text.as_str())) @@ -983,9 +970,8 @@ impl CassandraEngine { // Step 1: Update partition_max_delete_timestamp // First, try to update if it's null (most common case - first delete in partition) let update_max_ts_null_query = format!( - "UPDATE {}.{} SET partition_max_delete_timestamp = ? WHERE pk = ? \ - IF partition_max_delete_timestamp = null", - keyspace, ddb_table + "UPDATE {keyspace}.{ddb_table} SET partition_max_delete_timestamp = ? WHERE pk = ? \ + IF partition_max_delete_timestamp = null" ); let result = self @@ -1005,9 +991,8 @@ impl CassandraEngine { // If that failed (column already has a value), try updating only if our timestamp is higher if !check_lwt_applied(&result, "partition_max update").is_ok() { let update_max_ts_query = format!( - "UPDATE {}.{} SET partition_max_delete_timestamp = ? WHERE pk = ? \ - IF partition_max_delete_timestamp < ?", - keyspace, ddb_table + "UPDATE {keyspace}.{ddb_table} SET partition_max_delete_timestamp = ? WHERE pk = ? \ + IF partition_max_delete_timestamp < ?" ); let _result2 = self @@ -1040,8 +1025,7 @@ impl CassandraEngine { let sk = parse_sk(sk_value, sk_type)?; let sk_col = sk_column(sk_type); let delete_query = format!( - "DELETE FROM {}.{} WHERE pk = ? AND {} = ? IF prepared_txn_id = ?", - keyspace, ddb_table, sk_col + "DELETE FROM {keyspace}.{ddb_table} WHERE pk = ? AND {sk_col} = ? IF prepared_txn_id = ?" ); query_with_pk_sk_txnid( &self.session, @@ -1053,8 +1037,7 @@ impl CassandraEngine { .await } else { let delete_query = format!( - "DELETE FROM {}.{} WHERE pk = ? IF prepared_txn_id = ?", - keyspace, ddb_table + "DELETE FROM {keyspace}.{ddb_table} WHERE pk = ? IF prepared_txn_id = ?" ); self.session .query_with_values( @@ -1101,16 +1084,14 @@ impl CassandraEngine { let sk_col = sk_column(sk_type); let query = format!( - "UPDATE {}.{} SET item_data = ?, prepared_txn_id = NULL, last_committed_txn_timestamp = ? \ - WHERE pk = ? AND {} = ? IF prepared_txn_id = ?", - keyspace, ddb_table, sk_col + "UPDATE {keyspace}.{ddb_table} SET item_data = ?, prepared_txn_id = NULL, last_committed_txn_timestamp = ? \ + WHERE pk = ? AND {sk_col} = ? IF prepared_txn_id = ?" ); query_with_item_ts_pk_sk_txnid(&self.session, &query, &item_text, txn_timestamp, pk_text.as_str(), &sk, txn_id_bytes).await } else { let query = format!( - "UPDATE {}.{} SET item_data = ?, prepared_txn_id = NULL, last_committed_txn_timestamp = ? \ - WHERE pk = ? IF prepared_txn_id = ?", - keyspace, ddb_table + "UPDATE {keyspace}.{ddb_table} SET item_data = ?, prepared_txn_id = NULL, last_committed_txn_timestamp = ? \ + WHERE pk = ? IF prepared_txn_id = ?" ); self.session .query_with_values(&query, cdrs_tokio::query_values!(item_text, txn_timestamp, pk_text.as_str(), txn_id_bytes)) @@ -1179,12 +1160,10 @@ impl CassandraEngine { let sk_col = sk_column(sk_type); let delete_query = format!( - "DELETE FROM {}.{} WHERE pk = ? AND {} = ? IF prepared_txn_id = ? AND created_to_prepare = true", - keyspace, ddb_table, sk_col + "DELETE FROM {keyspace}.{ddb_table} WHERE pk = ? AND {sk_col} = ? IF prepared_txn_id = ? AND created_to_prepare = true" ); let update_query = format!( - "UPDATE {}.{} SET prepared_txn_id = null WHERE pk = ? AND {} = ? IF prepared_txn_id = ?", - keyspace, ddb_table, sk_col + "UPDATE {keyspace}.{ddb_table} SET prepared_txn_id = null WHERE pk = ? AND {sk_col} = ? IF prepared_txn_id = ?" ); let dr = query_with_pk_sk_txnid(&self.session, &delete_query, pk_text.as_str(), &sk, txn_id_bytes.clone()) @@ -1196,12 +1175,10 @@ impl CassandraEngine { query_with_pk_sk_txnid(&self.session, &update_query, pk_text.as_str(), &sk, txn_id_bytes).await } else { let delete_query = format!( - "DELETE FROM {}.{} WHERE pk = ? IF prepared_txn_id = ? AND created_to_prepare = true", - keyspace, ddb_table + "DELETE FROM {keyspace}.{ddb_table} WHERE pk = ? IF prepared_txn_id = ? AND created_to_prepare = true" ); let update_query = format!( - "UPDATE {}.{} SET prepared_txn_id = null WHERE pk = ? IF prepared_txn_id = ?", - keyspace, ddb_table + "UPDATE {keyspace}.{ddb_table} SET prepared_txn_id = null WHERE pk = ? IF prepared_txn_id = ?" ); let dr = self.session diff --git a/crates/storage-cassandra/src/data/update_item.rs b/crates/storage-cassandra/src/data/update_item.rs index 06e00ca0..160ef069 100644 --- a/crates/storage-cassandra/src/data/update_item.rs +++ b/crates/storage-cassandra/src/data/update_item.rs @@ -79,15 +79,14 @@ impl CassandraEngine { let sk_col = sk_column(sk_type); let select_query = format!( - "SELECT item_data, prepared_txn_id FROM {}.{} WHERE pk = ? AND {} = ?", - data_keyspace, ddb_table, sk_col + "SELECT item_data, prepared_txn_id FROM {data_keyspace}.{ddb_table} WHERE pk = ? AND {sk_col} = ?" ); let result = query_with_pk_sk(&self.session, &select_query, &pk_text, &sk).await?; let body = result .response_body() - .map_err(|e| StorageError::Internal(format!("Parse response: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Parse response: {e}")))?; let rows = body.into_rows().unwrap_or_default(); if let Some(row) = rows.first() { @@ -113,8 +112,7 @@ impl CassandraEngine { } } else { let select_query = format!( - "SELECT item_data, prepared_txn_id FROM {}.{} WHERE pk = ?", - data_keyspace, ddb_table + "SELECT item_data, prepared_txn_id FROM {data_keyspace}.{ddb_table} WHERE pk = ?" ); let row = crate::cassandra_util::query_optional( @@ -217,8 +215,7 @@ impl CassandraEngine { let sk_col = sk_column(sk_type); let update_cql = format!( - "UPDATE {}.{} SET item_data = ? WHERE pk = ? AND {} = ?", - data_keyspace, ddb_table, sk_col + "UPDATE {data_keyspace}.{ddb_table} SET item_data = ? WHERE pk = ? AND {sk_col} = ?" ); let stream_stmt = stream.and_then(|cap| { @@ -288,7 +285,7 @@ impl CassandraEngine { .map_err(|e| StorageError::Internal(e.to_string()))?, ) .await - .map_err(|e| StorageError::Internal(format!("Batch execution: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Batch execution: {e}")))?; if async_enqueued > 0 { self.gsi_queue.notify_workers(); @@ -296,8 +293,7 @@ impl CassandraEngine { } } else { let update_cql = format!( - "UPDATE {}.{} SET item_data = ? WHERE pk = ?", - data_keyspace, ddb_table + "UPDATE {data_keyspace}.{ddb_table} SET item_data = ? WHERE pk = ?" ); let stream_stmt = stream.and_then(|cap| { @@ -371,7 +367,7 @@ impl CassandraEngine { .map_err(|e| StorageError::Internal(e.to_string()))?, ) .await - .map_err(|e| StorageError::Internal(format!("Batch execution: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Batch execution: {e}")))?; if async_enqueued > 0 { self.gsi_queue.notify_workers(); diff --git a/crates/storage-cassandra/src/delete_table.rs b/crates/storage-cassandra/src/delete_table.rs index 60539ab1..8593cce7 100644 --- a/crates/storage-cassandra/src/delete_table.rs +++ b/crates/storage-cassandra/src/delete_table.rs @@ -19,8 +19,7 @@ impl CassandraEngine { // Fetch table metadata before deletion let table_query = format!( - "SELECT * FROM {}.tables WHERE account_id = ? AND table_name = ?", - catalog_keyspace + "SELECT * FROM {catalog_keyspace}.tables WHERE account_id = ? AND table_name = ?" ); let table_result = self @@ -30,7 +29,7 @@ impl CassandraEngine { cdrs_tokio::query_values!(account_id, input.table_name.as_str()), ) .await - .map_err(|e| StorageError::Internal(format!("Query table: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Query table: {e}")))?; let table_body = table_result .response_body() @@ -52,25 +51,24 @@ impl CassandraEngine { if deletion_protection { let table_arn: String = table_row .get_r_by_name("table_arn") - .map_err(|e| StorageError::Internal(format!("Parse table_arn: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Parse table_arn: {e}")))?; return Err(StorageError::DeletionProtected(table_arn)); } let table_id: String = table_row .get_r_by_name("table_id") - .map_err(|e| StorageError::Internal(format!("Parse table_id: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Parse table_id: {e}")))?; // Fetch indexes for response let index_query = format!( - "SELECT * FROM {}.indexes WHERE table_id = ?", - catalog_keyspace + "SELECT * FROM {catalog_keyspace}.indexes WHERE table_id = ?" ); let index_result = self .session .query_with_values(&index_query, cdrs_tokio::query_values!(table_id.as_str())) .await - .map_err(|e| StorageError::Internal(format!("Query indexes: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Query indexes: {e}")))?; let index_body = index_result .response_body() @@ -93,7 +91,7 @@ impl CassandraEngine { for idx_row in &index_rows { let index_id: String = idx_row .get_r_by_name("index_id") - .map_err(|e| StorageError::Internal(format!("Parse index_id: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Parse index_id: {e}")))?; self.drop_index_data_table(&account_keyspace, &index_id) .await .map_err(|e| StorageError::Internal(format!("Drop index table: {e}")))?; @@ -101,8 +99,7 @@ impl CassandraEngine { // Delete catalog entries (indexes first due to FK) let delete_indexes_query = format!( - "DELETE FROM {}.indexes WHERE table_id = ?", - catalog_keyspace + "DELETE FROM {catalog_keyspace}.indexes WHERE table_id = ?" ); self.session .query_with_values( @@ -110,13 +107,12 @@ impl CassandraEngine { cdrs_tokio::query_values!(table_id.as_str()), ) .await - .map_err(|e| StorageError::Internal(format!("Delete indexes: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Delete indexes: {e}")))?; // Delete table-scoped continuous-backup configuration so recreating the // same table name does not inherit stale PITR state. let delete_continuous_backup_query = format!( - "DELETE FROM {}.continuous_backups WHERE account_id = ? AND table_name = ?", - catalog_keyspace + "DELETE FROM {catalog_keyspace}.continuous_backups WHERE account_id = ? AND table_name = ?" ); self.session .query_with_values( @@ -128,8 +124,7 @@ impl CassandraEngine { // Delete table catalog entry let delete_table_query = format!( - "DELETE FROM {}.tables WHERE account_id = ? AND table_name = ?", - catalog_keyspace + "DELETE FROM {catalog_keyspace}.tables WHERE account_id = ? AND table_name = ?" ); self.session .query_with_values( @@ -137,7 +132,7 @@ impl CassandraEngine { cdrs_tokio::query_values!(account_id, input.table_name.as_str()), ) .await - .map_err(|e| StorageError::Internal(format!("Delete table: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Delete table: {e}")))?; Ok(description) } diff --git a/crates/storage-cassandra/src/engine.rs b/crates/storage-cassandra/src/engine.rs index 218956ce..30e1dab1 100644 --- a/crates/storage-cassandra/src/engine.rs +++ b/crates/storage-cassandra/src/engine.rs @@ -107,7 +107,7 @@ impl CassandraEngine { // Build cluster config let cluster_config = node_builder.build().await.map_err(|e| { - StorageError::Connection(format!("Failed to build cluster config: {}", e)) + StorageError::Connection(format!("Failed to build cluster config: {e}")) })?; // Create session with round-robin load balancing @@ -131,11 +131,10 @@ impl CassandraEngine { let err_str = e.to_string(); if err_str.contains("authentication") || err_str.contains("Authentication") { StorageError::Connection(format!( - "Authentication failed: {}. Verify username/password in config.", - e + "Authentication failed: {e}. Verify username/password in config." )) } else { - StorageError::Connection(format!("Failed to build session: {}", e)) + StorageError::Connection(format!("Failed to build session: {e}")) } })?; @@ -172,19 +171,19 @@ impl CassandraEngine { self.session .query(cql) .await - .map_err(|e| StorageError::Internal(format!("Failed to create keyspace: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Failed to create keyspace: {e}")))?; Ok(()) } /// Drop a keyspace. pub async fn drop_keyspace(&self, keyspace_name: &str) -> Result<(), StorageError> { - let cql = format!("DROP KEYSPACE IF EXISTS {}", keyspace_name); + let cql = format!("DROP KEYSPACE IF EXISTS {keyspace_name}"); self.session .query(cql) .await - .map_err(|e| StorageError::Internal(format!("Failed to drop keyspace: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Failed to drop keyspace: {e}")))?; Ok(()) } @@ -197,9 +196,9 @@ impl CassandraEngine { .session .query_with_values(cql, query_values!(keyspace_name)) .await - .map_err(|e| StorageError::Internal(format!("Failed to query keyspaces: {}", e)))? + .map_err(|e| StorageError::Internal(format!("Failed to query keyspaces: {e}")))? .response_body() - .map_err(|e| StorageError::Internal(format!("Failed to get response body: {}", e)))? + .map_err(|e| StorageError::Internal(format!("Failed to get response body: {e}")))? .into_rows() .ok_or_else(|| StorageError::Internal("Failed to parse rows".to_string()))?; diff --git a/crates/storage-cassandra/src/management_store/access_keys.rs b/crates/storage-cassandra/src/management_store/access_keys.rs index 17161161..6137965f 100755 --- a/crates/storage-cassandra/src/management_store/access_keys.rs +++ b/crates/storage-cassandra/src/management_store/access_keys.rs @@ -26,8 +26,7 @@ impl CassandraCatalogStore { } else { let catalog_keyspace = self.catalog_keyspace(); let query = format!( - "SELECT value FROM {}.settings WHERE key = 'encryption_key'", - catalog_keyspace + "SELECT value FROM {catalog_keyspace}.settings WHERE key = 'encryption_key'" ); let row = crate::cassandra_util::query_optional( @@ -51,9 +50,8 @@ impl CassandraCatalogStore { let catalog_keyspace = self.catalog_keyspace(); let insert_query = format!( - "INSERT INTO {}.access_keys (access_key_id, account_id, user_name, secret_key_encrypted, is_active, created_at) \ - VALUES (?, ?, ?, ?, true, toTimestamp(now()))", - catalog_keyspace + "INSERT INTO {catalog_keyspace}.access_keys (access_key_id, account_id, user_name, secret_key_encrypted, is_active, created_at) \ + VALUES (?, ?, ?, ?, true, toTimestamp(now()))" ); let encrypted_blob = cdrs_tokio::types::blob::Blob::new(encrypted); @@ -90,9 +88,8 @@ impl CassandraCatalogStore { // Check if key exists and belongs to the correct account/user let check_query = format!( - "SELECT access_key_id, account_id, user_name FROM {}.access_keys \ - WHERE access_key_id = ?", - catalog_keyspace + "SELECT access_key_id, account_id, user_name FROM {catalog_keyspace}.access_keys \ + WHERE access_key_id = ?" ); let row = crate::cassandra_util::query_optional( @@ -120,8 +117,7 @@ impl CassandraCatalogStore { // Delete the key (by PRIMARY KEY only) let delete_query = format!( - "DELETE FROM {}.access_keys WHERE access_key_id = ?", - catalog_keyspace + "DELETE FROM {catalog_keyspace}.access_keys WHERE access_key_id = ?" ); crate::cassandra_util::execute( @@ -140,9 +136,8 @@ impl CassandraCatalogStore { ) -> OpResult> { let catalog_keyspace = self.catalog_keyspace(); let query = format!( - "SELECT access_key_id, is_active, created_at FROM {}.access_keys \ - WHERE account_id = ? AND user_name = ? ALLOW FILTERING", - catalog_keyspace + "SELECT access_key_id, is_active, created_at FROM {catalog_keyspace}.access_keys \ + WHERE account_id = ? AND user_name = ? ALLOW FILTERING" ); let rows = crate::cassandra_util::query_rows( @@ -190,8 +185,7 @@ impl CassandraCatalogStore { } else { let catalog_keyspace = self.catalog_keyspace(); let query = format!( - "SELECT value FROM {}.settings WHERE key = 'encryption_key'", - catalog_keyspace + "SELECT value FROM {catalog_keyspace}.settings WHERE key = 'encryption_key'" ); let row = crate::cassandra_util::query_optional( @@ -260,8 +254,8 @@ impl CassandraCatalogStore { self.catalog_keyspace() ); - let session_tags_json = session_tags.as_ref().map(|v| v.to_string()); - let session_policy_json = session_policy.as_ref().map(|v| v.to_string()); + let session_tags_json = session_tags.as_ref().map(std::string::ToString::to_string); + let session_policy_json = session_policy.as_ref().map(std::string::ToString::to_string); let expires_ms = expires_at.unix_timestamp() * 1000 + i64::from(expires_at.millisecond()); crate::cassandra_util::execute( diff --git a/crates/storage-cassandra/src/management_store/accounts.rs b/crates/storage-cassandra/src/management_store/accounts.rs index 50353bbe..a050a0ba 100644 --- a/crates/storage-cassandra/src/management_store/accounts.rs +++ b/crates/storage-cassandra/src/management_store/accounts.rs @@ -16,8 +16,7 @@ impl CassandraCatalogStore { ) -> OpResult<()> { let catalog_keyspace = self.catalog_keyspace(); let query = format!( - "INSERT INTO {}.accounts (account_id, account_name, created_at) VALUES (?, ?, toTimestamp(now())) IF NOT EXISTS", - catalog_keyspace + "INSERT INTO {catalog_keyspace}.accounts (account_id, account_name, created_at) VALUES (?, ?, toTimestamp(now())) IF NOT EXISTS" ); let result = self @@ -67,8 +66,7 @@ impl CassandraCatalogStore { // Check if account has tables let tables_query = format!( - "SELECT table_name FROM {}.tables WHERE account_id = ? LIMIT 1", - catalog_keyspace + "SELECT table_name FROM {catalog_keyspace}.tables WHERE account_id = ? LIMIT 1" ); let has_tables = crate::cassandra_util::query_optional( @@ -90,8 +88,7 @@ impl CassandraCatalogStore { // keyspace that stores their payload. Remove every denormalized and // authoritative catalog row before dropping that keyspace. let backup_query = format!( - "SELECT backup_arn, table_name, created_at FROM {}.backups_by_account WHERE account_id = ?", - catalog_keyspace + "SELECT backup_arn, table_name, created_at FROM {catalog_keyspace}.backups_by_account WHERE account_id = ?" ); let backup_rows = crate::cassandra_util::query_rows::( self.session(), @@ -113,8 +110,7 @@ impl CassandraCatalogStore { crate::cassandra_util::execute::( self.session(), &format!( - "DELETE FROM {}.backups_by_table WHERE account_id = ? AND table_name = ? AND created_at = ? AND backup_arn = ?", - catalog_keyspace + "DELETE FROM {catalog_keyspace}.backups_by_table WHERE account_id = ? AND table_name = ? AND created_at = ? AND backup_arn = ?" ), cdrs_tokio::query_values!(account_id, table_name, created_at, backup_arn.as_str()), "delete_account_table_backup", @@ -123,8 +119,7 @@ impl CassandraCatalogStore { crate::cassandra_util::execute::( self.session(), &format!( - "DELETE FROM {}.backups_by_arn WHERE account_id = ? AND backup_arn = ?", - catalog_keyspace + "DELETE FROM {catalog_keyspace}.backups_by_arn WHERE account_id = ? AND backup_arn = ?" ), cdrs_tokio::query_values!(account_id, backup_arn), "delete_account_backup", @@ -134,8 +129,7 @@ impl CassandraCatalogStore { crate::cassandra_util::execute::( self.session(), &format!( - "DELETE FROM {}.backups_by_account WHERE account_id = ?", - catalog_keyspace + "DELETE FROM {catalog_keyspace}.backups_by_account WHERE account_id = ?" ), cdrs_tokio::query_values!(account_id), "delete_account_backup_index", @@ -144,8 +138,7 @@ impl CassandraCatalogStore { crate::cassandra_util::execute::( self.session(), &format!( - "DELETE FROM {}.continuous_backups WHERE account_id = ?", - catalog_keyspace + "DELETE FROM {catalog_keyspace}.continuous_backups WHERE account_id = ?" ), cdrs_tokio::query_values!(account_id), "delete_account_continuous_backups", @@ -154,8 +147,7 @@ impl CassandraCatalogStore { // Delete account from catalog let delete_query = format!( - "DELETE FROM {}.accounts WHERE account_id = ?", - catalog_keyspace + "DELETE FROM {catalog_keyspace}.accounts WHERE account_id = ?" ); crate::cassandra_util::execute( @@ -175,8 +167,7 @@ impl CassandraCatalogStore { pub(crate) async fn list_all_accounts_impl(&self) -> OpResult> { let catalog_keyspace = self.catalog_keyspace(); let query = format!( - "SELECT account_id, account_name FROM {}.accounts", - catalog_keyspace + "SELECT account_id, account_name FROM {catalog_keyspace}.accounts" ); let rows = crate::cassandra_util::query_rows( @@ -208,8 +199,7 @@ impl CassandraCatalogStore { ) -> OpResult> { let catalog_keyspace = self.catalog_keyspace(); let query = format!( - "SELECT account_id, account_name, created_at FROM {}.accounts", - catalog_keyspace + "SELECT account_id, account_name, created_at FROM {catalog_keyspace}.accounts" ); let rows = crate::cassandra_util::query_rows( @@ -243,8 +233,7 @@ impl CassandraCatalogStore { ) -> OpResult> { let catalog_keyspace = self.catalog_keyspace(); let query = format!( - "SELECT account_id, account_name FROM {}.accounts WHERE account_id = ?", - catalog_keyspace + "SELECT account_id, account_name FROM {catalog_keyspace}.accounts WHERE account_id = ?" ); let rows = crate::cassandra_util::query_rows( @@ -276,8 +265,7 @@ impl CassandraCatalogStore { // Get account name let account_query = format!( - "SELECT account_name FROM {}.accounts WHERE account_id = ?", - catalog_keyspace + "SELECT account_name FROM {catalog_keyspace}.accounts WHERE account_id = ?" ); let account_row = crate::cassandra_util::query_optional( @@ -297,8 +285,7 @@ impl CassandraCatalogStore { // Get users let users_query = format!( - "SELECT user_name FROM {}.iam_users WHERE account_id = ?", - catalog_keyspace + "SELECT user_name FROM {catalog_keyspace}.iam_users WHERE account_id = ?" ); let users_rows = crate::cassandra_util::query_rows( @@ -321,8 +308,7 @@ impl CassandraCatalogStore { // Get groups let groups_query = format!( - "SELECT group_name FROM {}.iam_groups WHERE account_id = ?", - catalog_keyspace + "SELECT group_name FROM {catalog_keyspace}.iam_groups WHERE account_id = ?" ); let groups_rows = crate::cassandra_util::query_rows( @@ -345,8 +331,7 @@ impl CassandraCatalogStore { // Get roles let roles_query = format!( - "SELECT role_name FROM {}.iam_roles WHERE account_id = ?", - catalog_keyspace + "SELECT role_name FROM {catalog_keyspace}.iam_roles WHERE account_id = ?" ); let roles_rows = crate::cassandra_util::query_rows( @@ -379,7 +364,7 @@ impl CassandraCatalogStore { let catalog_keyspace = self.catalog_keyspace(); // Count accounts - let accounts_query = format!("SELECT account_id FROM {}.accounts", catalog_keyspace); + let accounts_query = format!("SELECT account_id FROM {catalog_keyspace}.accounts"); let accounts_rows = crate::cassandra_util::query_rows( self.session(), &accounts_query, @@ -387,10 +372,11 @@ impl CassandraCatalogStore { "dashboard_counts", ) .await?; + #[allow(clippy::cast_possible_wrap)] let account_count = accounts_rows.len() as i64; // Count admins - let admins_query = format!("SELECT admin_name FROM {}.admin_users", catalog_keyspace); + let admins_query = format!("SELECT admin_name FROM {catalog_keyspace}.admin_users"); let admins_rows = crate::cassandra_util::query_rows( self.session(), &admins_query, @@ -398,6 +384,7 @@ impl CassandraCatalogStore { "dashboard_counts", ) .await?; + #[allow(clippy::cast_possible_wrap)] let admin_count = admins_rows.len() as i64; Ok((account_count, admin_count)) diff --git a/crates/storage-cassandra/src/management_store/groups.rs b/crates/storage-cassandra/src/management_store/groups.rs index 381c4bd8..4a79bdd0 100644 --- a/crates/storage-cassandra/src/management_store/groups.rs +++ b/crates/storage-cassandra/src/management_store/groups.rs @@ -21,9 +21,8 @@ impl CassandraCatalogStore { let catalog_keyspace = self.catalog_keyspace(); let group_arn = format!("arn:aws:iam::{account_id}:group/{group_name}"); let insert_query = format!( - "INSERT INTO {}.iam_groups (account_id, group_name, group_arn, created_at) \ - VALUES (?, ?, ?, toTimestamp(now())) IF NOT EXISTS", - catalog_keyspace + "INSERT INTO {catalog_keyspace}.iam_groups (account_id, group_name, group_arn, created_at) \ + VALUES (?, ?, ?, toTimestamp(now())) IF NOT EXISTS" ); let applied = crate::cassandra_util::apply_lwt( @@ -54,8 +53,7 @@ impl CassandraCatalogStore { let catalog_keyspace = self.catalog_keyspace(); let delete_query = format!( - "DELETE FROM {}.iam_groups WHERE account_id = ? AND group_name = ?", - catalog_keyspace + "DELETE FROM {catalog_keyspace}.iam_groups WHERE account_id = ? AND group_name = ?" ); crate::cassandra_util::execute( @@ -74,8 +72,7 @@ impl CassandraCatalogStore { let catalog_keyspace = self.catalog_keyspace(); let query = format!( "SELECT account_id, group_name, group_arn, created_at \ - FROM {}.iam_groups WHERE account_id = ?", - catalog_keyspace + FROM {catalog_keyspace}.iam_groups WHERE account_id = ?" ); let rows = crate::cassandra_util::query_rows( @@ -117,8 +114,7 @@ impl CassandraCatalogStore { // Get members let members_query = format!( - "SELECT user_name FROM {}.iam_group_members WHERE account_id = ? AND group_name = ?", - catalog_keyspace + "SELECT user_name FROM {catalog_keyspace}.iam_group_members WHERE account_id = ? AND group_name = ?" ); let members_rows = crate::cassandra_util::query_rows( @@ -141,9 +137,8 @@ impl CassandraCatalogStore { // Get policies let policies_query = format!( - "SELECT policy_name FROM {}.iam_policies \ - WHERE account_id = ? AND principal_type = 'group' AND principal_name = ?", - catalog_keyspace + "SELECT policy_name FROM {catalog_keyspace}.iam_policies \ + WHERE account_id = ? AND principal_type = 'group' AND principal_name = ?" ); let policies_rows = crate::cassandra_util::query_rows( @@ -166,8 +161,7 @@ impl CassandraCatalogStore { // Get all users in account let all_users_query = format!( - "SELECT user_name FROM {}.iam_users WHERE account_id = ?", - catalog_keyspace + "SELECT user_name FROM {catalog_keyspace}.iam_users WHERE account_id = ?" ); let all_users_rows = crate::cassandra_util::query_rows( @@ -212,8 +206,7 @@ impl CassandraCatalogStore { let catalog_keyspace = self.catalog_keyspace(); let insert_query = format!( - "INSERT INTO {}.iam_group_members (account_id, group_name, user_name) VALUES (?, ?, ?) IF NOT EXISTS", - catalog_keyspace + "INSERT INTO {catalog_keyspace}.iam_group_members (account_id, group_name, user_name) VALUES (?, ?, ?) IF NOT EXISTS" ); let applied = crate::cassandra_util::apply_lwt( @@ -243,9 +236,8 @@ impl CassandraCatalogStore { // Check if membership exists let check_query = format!( - "SELECT user_name FROM {}.iam_group_members \ - WHERE account_id = ? AND group_name = ? AND user_name = ?", - catalog_keyspace + "SELECT user_name FROM {catalog_keyspace}.iam_group_members \ + WHERE account_id = ? AND group_name = ? AND user_name = ?" ); if crate::cassandra_util::query_optional( @@ -261,8 +253,7 @@ impl CassandraCatalogStore { } let delete_query = format!( - "DELETE FROM {}.iam_group_members WHERE account_id = ? AND group_name = ? AND user_name = ?", - catalog_keyspace + "DELETE FROM {catalog_keyspace}.iam_group_members WHERE account_id = ? AND group_name = ? AND user_name = ?" ); crate::cassandra_util::execute( @@ -278,8 +269,7 @@ impl CassandraCatalogStore { async fn group_exists(&self, account_id: &str, group_name: &str) -> Result { let catalog_keyspace = self.catalog_keyspace(); let query = format!( - "SELECT group_name FROM {}.iam_groups WHERE account_id = ? AND group_name = ?", - catalog_keyspace + "SELECT group_name FROM {catalog_keyspace}.iam_groups WHERE account_id = ? AND group_name = ?" ); let rows = crate::cassandra_util::query_rows( diff --git a/crates/storage-cassandra/src/management_store/mod.rs b/crates/storage-cassandra/src/management_store/mod.rs index 82d8651f..d8319519 100755 --- a/crates/storage-cassandra/src/management_store/mod.rs +++ b/crates/storage-cassandra/src/management_store/mod.rs @@ -78,7 +78,7 @@ impl extenddb_storage::management_store::ManagementStore for CassandraCatalogSto ) -> BoxFuture<'_, OpResult<()>> { let account_id = account_id.to_string(); let user_name = user_name.to_string(); - let password_hash = password_hash.map(|s| s.to_string()); + let password_hash = password_hash.map(std::string::ToString::to_string); Box::pin(async move { self.create_user_impl(&account_id, &user_name, password_hash.as_deref()) .await diff --git a/crates/storage-cassandra/src/management_store/policies.rs b/crates/storage-cassandra/src/management_store/policies.rs index a06e589c..5e452d2c 100644 --- a/crates/storage-cassandra/src/management_store/policies.rs +++ b/crates/storage-cassandra/src/management_store/policies.rs @@ -27,9 +27,8 @@ impl CassandraCatalogStore { // Using natural UPSERT semantics (ADR-0004) let insert_query = format!( - "INSERT INTO {}.iam_policies (account_id, principal_type, principal_name, policy_name, policy_document, created_at) \ - VALUES (?, ?, ?, ?, ?, toTimestamp(now()))", - catalog_keyspace + "INSERT INTO {catalog_keyspace}.iam_policies (account_id, principal_type, principal_name, policy_name, policy_document, created_at) \ + VALUES (?, ?, ?, ?, ?, toTimestamp(now()))" ); let doc_str = document.to_string(); @@ -60,9 +59,8 @@ impl CassandraCatalogStore { // Check if policy exists let check_query = format!( - "SELECT policy_name FROM {}.iam_policies \ - WHERE account_id = ? AND principal_type = ? AND principal_name = ? AND policy_name = ?", - catalog_keyspace + "SELECT policy_name FROM {catalog_keyspace}.iam_policies \ + WHERE account_id = ? AND principal_type = ? AND principal_name = ? AND policy_name = ?" ); if crate::cassandra_util::query_optional( @@ -78,9 +76,8 @@ impl CassandraCatalogStore { } let delete_query = format!( - "DELETE FROM {}.iam_policies \ - WHERE account_id = ? AND principal_type = ? AND principal_name = ? AND policy_name = ?", - catalog_keyspace + "DELETE FROM {catalog_keyspace}.iam_policies \ + WHERE account_id = ? AND principal_type = ? AND principal_name = ? AND policy_name = ?" ); crate::cassandra_util::execute( @@ -100,9 +97,8 @@ impl CassandraCatalogStore { ) -> OpResult> { let catalog_keyspace = self.catalog_keyspace(); let query = format!( - "SELECT policy_name, policy_document, created_at FROM {}.iam_policies \ - WHERE account_id = ? AND principal_type = ? AND principal_name = ?", - catalog_keyspace + "SELECT policy_name, policy_document, created_at FROM {catalog_keyspace}.iam_policies \ + WHERE account_id = ? AND principal_type = ? AND principal_name = ?" ); let rows = crate::cassandra_util::query_rows( @@ -153,9 +149,8 @@ impl CassandraCatalogStore { // Using natural UPSERT semantics let insert_query = format!( - "INSERT INTO {}.iam_permissions_boundaries (account_id, principal_type, principal_name, policy_document) \ - VALUES (?, ?, ?, ?)", - catalog_keyspace + "INSERT INTO {catalog_keyspace}.iam_permissions_boundaries (account_id, principal_type, principal_name, policy_document) \ + VALUES (?, ?, ?, ?)" ); let doc_str = document.to_string(); @@ -177,9 +172,8 @@ impl CassandraCatalogStore { ) -> OpResult> { let catalog_keyspace = self.catalog_keyspace(); let query = format!( - "SELECT policy_document FROM {}.iam_permissions_boundaries \ - WHERE account_id = ? AND principal_type = ? AND principal_name = ?", - catalog_keyspace + "SELECT policy_document FROM {catalog_keyspace}.iam_permissions_boundaries \ + WHERE account_id = ? AND principal_type = ? AND principal_name = ?" ); let row = crate::cassandra_util::query_optional( @@ -214,9 +208,8 @@ impl CassandraCatalogStore { // Check if boundary exists let check_query = format!( - "SELECT principal_name FROM {}.iam_permissions_boundaries \ - WHERE account_id = ? AND principal_type = ? AND principal_name = ?", - catalog_keyspace + "SELECT principal_name FROM {catalog_keyspace}.iam_permissions_boundaries \ + WHERE account_id = ? AND principal_type = ? AND principal_name = ?" ); if crate::cassandra_util::query_optional( @@ -232,9 +225,8 @@ impl CassandraCatalogStore { } let delete_query = format!( - "DELETE FROM {}.iam_permissions_boundaries \ - WHERE account_id = ? AND principal_type = ? AND principal_name = ?", - catalog_keyspace + "DELETE FROM {catalog_keyspace}.iam_permissions_boundaries \ + WHERE account_id = ? AND principal_type = ? AND principal_name = ?" ); crate::cassandra_util::execute( diff --git a/crates/storage-cassandra/src/management_store/users.rs b/crates/storage-cassandra/src/management_store/users.rs index 3a994c2b..b28c08da 100644 --- a/crates/storage-cassandra/src/management_store/users.rs +++ b/crates/storage-cassandra/src/management_store/users.rs @@ -25,7 +25,7 @@ impl CassandraCatalogStore { let account_id = account_id.to_owned(); let user_name = user_name.to_owned(); - let password_hash = password_hash.map(|s| s.to_owned()); + let password_hash = password_hash.map(std::borrow::ToOwned::to_owned); // Seed self-service policy document let self_service_policy = serde_json::json!({ @@ -44,9 +44,8 @@ impl CassandraCatalogStore { // Use LWT to atomically create the user, preventing duplicate names. let user_query = format!( - "INSERT INTO {}.iam_users (account_id, user_name, user_arn, password_hash, created_at) \ - VALUES (?, ?, ?, ?, toTimestamp(now())) IF NOT EXISTS", - catalog_keyspace + "INSERT INTO {catalog_keyspace}.iam_users (account_id, user_name, user_arn, password_hash, created_at) \ + VALUES (?, ?, ?, ?, toTimestamp(now())) IF NOT EXISTS" ); let applied = crate::cassandra_util::apply_lwt( @@ -68,9 +67,8 @@ impl CassandraCatalogStore { // User was created; now insert the self-service policy. let policy_query = format!( - "INSERT INTO {}.iam_policies (account_id, principal_type, principal_name, policy_name, policy_document, created_at) \ - VALUES (?, 'user', ?, 'SelfServicePolicy', ?, toTimestamp(now()))", - catalog_keyspace + "INSERT INTO {catalog_keyspace}.iam_policies (account_id, principal_type, principal_name, policy_name, policy_document, created_at) \ + VALUES (?, 'user', ?, 'SelfServicePolicy', ?, toTimestamp(now()))" ); session @@ -99,8 +97,7 @@ impl CassandraCatalogStore { let catalog_keyspace = self.catalog_keyspace(); let delete_query = format!( - "DELETE FROM {}.iam_users WHERE account_id = ? AND user_name = ?", - catalog_keyspace + "DELETE FROM {catalog_keyspace}.iam_users WHERE account_id = ? AND user_name = ?" ); crate::cassandra_util::execute( @@ -119,8 +116,7 @@ impl CassandraCatalogStore { let catalog_keyspace = self.catalog_keyspace(); let query = format!( "SELECT account_id, user_name, user_arn, password_hash, created_at \ - FROM {}.iam_users WHERE account_id = ?", - catalog_keyspace + FROM {catalog_keyspace}.iam_users WHERE account_id = ?" ); let rows = crate::cassandra_util::query_rows( @@ -167,9 +163,8 @@ impl CassandraCatalogStore { // Get access keys let keys_query = format!( - "SELECT access_key_id, is_active FROM {}.access_keys \ - WHERE account_id = ? AND user_name = ? ALLOW FILTERING", - catalog_keyspace + "SELECT access_key_id, is_active FROM {catalog_keyspace}.access_keys \ + WHERE account_id = ? AND user_name = ? ALLOW FILTERING" ); let keys_rows = crate::cassandra_util::query_rows( @@ -195,9 +190,8 @@ impl CassandraCatalogStore { // Get policies let policies_query = format!( - "SELECT policy_name FROM {}.iam_policies \ - WHERE account_id = ? AND principal_type = 'user' AND principal_name = ?", - catalog_keyspace + "SELECT policy_name FROM {catalog_keyspace}.iam_policies \ + WHERE account_id = ? AND principal_type = 'user' AND principal_name = ?" ); let policies_rows = crate::cassandra_util::query_rows( @@ -220,9 +214,8 @@ impl CassandraCatalogStore { // Get tags let tags_query = format!( - "SELECT tag_key, tag_value FROM {}.iam_user_tags \ - WHERE account_id = ? AND user_name = ?", - catalog_keyspace + "SELECT tag_key, tag_value FROM {catalog_keyspace}.iam_user_tags \ + WHERE account_id = ? AND user_name = ?" ); let tags_rows = crate::cassandra_util::query_rows( @@ -248,9 +241,8 @@ impl CassandraCatalogStore { // Get groups let groups_query = format!( - "SELECT group_name FROM {}.iam_group_members \ - WHERE account_id = ? AND user_name = ? ALLOW FILTERING", - catalog_keyspace + "SELECT group_name FROM {catalog_keyspace}.iam_group_members \ + WHERE account_id = ? AND user_name = ? ALLOW FILTERING" ); let groups_rows = crate::cassandra_util::query_rows( @@ -287,9 +279,8 @@ impl CassandraCatalogStore { ) -> OpResult { let catalog_keyspace = self.catalog_keyspace(); let query = format!( - "SELECT password_hash FROM {}.iam_users \ - WHERE account_id = ? AND user_name = ?", - catalog_keyspace + "SELECT password_hash FROM {catalog_keyspace}.iam_users \ + WHERE account_id = ? AND user_name = ?" ); let row = crate::cassandra_util::query_optional( @@ -331,8 +322,7 @@ impl CassandraCatalogStore { let catalog_keyspace = self.catalog_keyspace(); let update_query = format!( - "UPDATE {}.iam_users SET password_hash = ? WHERE account_id = ? AND user_name = ?", - catalog_keyspace + "UPDATE {catalog_keyspace}.iam_users SET password_hash = ? WHERE account_id = ? AND user_name = ?" ); crate::cassandra_util::execute( @@ -362,8 +352,7 @@ impl CassandraCatalogStore { // Insert tags (using natural UPSERT semantics - ADR-0004) for (key, value) in tags { let insert_query = format!( - "INSERT INTO {}.iam_user_tags (account_id, user_name, tag_key, tag_value) VALUES (?, ?, ?, ?)", - catalog_keyspace + "INSERT INTO {catalog_keyspace}.iam_user_tags (account_id, user_name, tag_key, tag_value) VALUES (?, ?, ?, ?)" ); crate::cassandra_util::execute( @@ -388,8 +377,7 @@ impl CassandraCatalogStore { for key in tag_keys { let delete_query = format!( - "DELETE FROM {}.iam_user_tags WHERE account_id = ? AND user_name = ? AND tag_key = ?", - catalog_keyspace + "DELETE FROM {catalog_keyspace}.iam_user_tags WHERE account_id = ? AND user_name = ? AND tag_key = ?" ); crate::cassandra_util::execute( @@ -411,9 +399,8 @@ impl CassandraCatalogStore { ) -> OpResult> { let catalog_keyspace = self.catalog_keyspace(); let query = format!( - "SELECT tag_key, tag_value FROM {}.iam_user_tags \ - WHERE account_id = ? AND user_name = ?", - catalog_keyspace + "SELECT tag_key, tag_value FROM {catalog_keyspace}.iam_user_tags \ + WHERE account_id = ? AND user_name = ?" ); let rows = crate::cassandra_util::query_rows( diff --git a/crates/storage-cassandra/src/metadata_engine.rs b/crates/storage-cassandra/src/metadata_engine.rs index f684d3cf..8fb5e90c 100755 --- a/crates/storage-cassandra/src/metadata_engine.rs +++ b/crates/storage-cassandra/src/metadata_engine.rs @@ -36,8 +36,7 @@ impl MetadataEngine for CassandraEngine { Box::pin(async move { for tag in &tags { let query = format!( - "INSERT INTO {}.tags (resource_arn, tag_key, tag_value) VALUES (?, ?, ?)", - catalog + "INSERT INTO {catalog}.tags (resource_arn, tag_key, tag_value) VALUES (?, ?, ?)" ); self.session_arc() .query_with_values( @@ -69,8 +68,7 @@ impl MetadataEngine for CassandraEngine { Box::pin(async move { for key in &tag_keys { let query = format!( - "DELETE FROM {}.tags WHERE resource_arn = ? AND tag_key = ?", - catalog + "DELETE FROM {catalog}.tags WHERE resource_arn = ? AND tag_key = ?" ); self.session_arc() .query_with_values( @@ -92,8 +90,7 @@ impl MetadataEngine for CassandraEngine { let catalog = self.catalog_keyspace(); Box::pin(async move { let query = format!( - "SELECT tag_key, tag_value FROM {}.tags WHERE resource_arn = ?", - catalog + "SELECT tag_key, tag_value FROM {catalog}.tags WHERE resource_arn = ?" ); let result = self .session_arc() diff --git a/crates/storage-cassandra/src/migrations.rs b/crates/storage-cassandra/src/migrations.rs index 41d3eda4..156b5e34 100644 --- a/crates/storage-cassandra/src/migrations.rs +++ b/crates/storage-cassandra/src/migrations.rs @@ -81,7 +81,7 @@ pub async fn run_catalog_migrations( // Set keyspace context for all subsequent queries session - .query(format!("USE {}", keyspace)) + .query(format!("USE {keyspace}")) .await .map_err(|e| OpError::Internal(format!("Failed to USE keyspace: {e}")))?; @@ -104,7 +104,7 @@ pub async fn run_data_migrations(session: &Arc, keyspace: &str // Set keyspace context for all subsequent queries session - .query(format!("USE {}", keyspace)) + .query(format!("USE {keyspace}")) .await .map_err(|e| OpError::Internal(format!("Failed to USE keyspace: {e}")))?; @@ -134,9 +134,8 @@ pub(crate) async fn table_exists( .await .ok() .and_then(|frame| frame.response_body().ok()) - .and_then(|body| body.into_rows()) - .map(|rows| !rows.is_empty()) - .unwrap_or(false); + .and_then(cdrs_tokio::frame::message_response::ResponseBody::into_rows) + .is_some_and(|rows| !rows.is_empty()); Ok(exists) } @@ -149,7 +148,7 @@ pub(crate) async fn pending_data_migrations( catalog_keyspace: &str, account_keyspace_fn: impl Fn(&str) -> String, ) -> OpResult> { - let query = format!("SELECT account_id FROM {}.accounts", catalog_keyspace); + let query = format!("SELECT account_id FROM {catalog_keyspace}.accounts"); let rows = crate::cassandra_util::query_rows::( &Arc::clone(session), &query, @@ -183,11 +182,10 @@ async fn is_migration_applied( .strip_prefix("V") .and_then(|s| s.split("__").next()) .and_then(|s| s.parse().ok()) - .ok_or_else(|| OpError::Internal(format!("Invalid migration filename: {}", filename)))?; + .ok_or_else(|| OpError::Internal(format!("Invalid migration filename: {filename}")))?; let check_cql = format!( - "SELECT version FROM {}.schema_history WHERE version = ?", - keyspace + "SELECT version FROM {keyspace}.schema_history WHERE version = ?" ); let applied = session @@ -195,9 +193,8 @@ async fn is_migration_applied( .await .ok() .and_then(|frame| frame.response_body().ok()) - .and_then(|body| body.into_rows()) - .map(|rows| !rows.is_empty()) - .unwrap_or(false); + .and_then(cdrs_tokio::frame::message_response::ResponseBody::into_rows) + .is_some_and(|rows| !rows.is_empty()); Ok(applied) } @@ -229,7 +226,7 @@ async fn record_migration( .strip_prefix("V") .and_then(|s| s.split("__").next()) .and_then(|s| s.parse().ok()) - .ok_or_else(|| OpError::Internal(format!("Invalid migration filename: {}", filename)))?; + .ok_or_else(|| OpError::Internal(format!("Invalid migration filename: {filename}")))?; let description = filename .split("__") @@ -238,8 +235,7 @@ async fn record_migration( .unwrap_or("unknown"); let record_cql = format!( - "INSERT INTO {}.schema_history (version, description, applied_at) VALUES (?, ?, toTimestamp(now()))", - keyspace + "INSERT INTO {keyspace}.schema_history (version, description, applied_at) VALUES (?, ?, toTimestamp(now()))" ); session diff --git a/crates/storage-cassandra/src/operations.rs b/crates/storage-cassandra/src/operations.rs index d7f68dba..17e3c25b 100644 --- a/crates/storage-cassandra/src/operations.rs +++ b/crates/storage-cassandra/src/operations.rs @@ -28,8 +28,7 @@ impl OperationsEngine for CassandraOperationsEngine { let parts: Vec<&str> = first_contact.split(':').collect(); if parts.len() != 2 { return Err(StorageError::Internal(format!( - "Invalid Cassandra contact point format: '{}'. Expected 'host:port'", - first_contact + "Invalid Cassandra contact point format: '{first_contact}'. Expected 'host:port'" ))); } @@ -42,7 +41,7 @@ impl OperationsEngine for CassandraOperationsEngine { Ok(ConnectionParts { host, port, - database: format!("{}_catalog", keyspace), + database: format!("{keyspace}_catalog"), user: String::new(), password: String::new(), }) @@ -62,13 +61,12 @@ impl OperationsEngine for CassandraOperationsEngine { // - Case-insensitive (stored lowercase unless quoted) if name.is_empty() { - return Err(StorageError::Internal(format!("{} cannot be empty", label))); + return Err(StorageError::Internal(format!("{label} cannot be empty"))); } if name.len() > 48 { return Err(StorageError::Internal(format!( - "{} '{}' exceeds maximum length of 48 characters", - label, name + "{label} '{name}' exceeds maximum length of 48 characters" ))); } @@ -76,16 +74,14 @@ impl OperationsEngine for CassandraOperationsEngine { if let Some(first_char) = name.chars().next() && first_char.is_ascii_digit() { return Err(StorageError::Internal(format!( - "{} '{}' cannot start with a digit", - label, name + "{label} '{name}' cannot start with a digit" ))); } // Check all characters (alphanumeric + underscore only) if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { return Err(StorageError::Internal(format!( - "{} '{}' contains invalid characters. Only alphanumeric and underscore allowed", - label, name + "{label} '{name}' contains invalid characters. Only alphanumeric and underscore allowed" ))); } diff --git a/crates/storage-cassandra/src/stream_engine.rs b/crates/storage-cassandra/src/stream_engine.rs index 7bd37a4c..8e452327 100755 --- a/crates/storage-cassandra/src/stream_engine.rs +++ b/crates/storage-cassandra/src/stream_engine.rs @@ -469,7 +469,7 @@ impl StreamEngine for CassandraEngine { let seq = self .hlc .lock() - .unwrap_or_else(|e| e.into_inner()) + .unwrap_or_else(std::sync::PoisonError::into_inner) .generate(); Box::pin(async move { Ok(seq) }) } @@ -543,8 +543,7 @@ impl StreamEngine for CassandraEngine { .response_body() .map_err(|e| StorageError::Internal(e.to_string()))? .into_rows() - .map(|r| !r.is_empty()) - .unwrap_or(false); + .is_some_and(|r| !r.is_empty()); if !found { return Err(StorageError::TableNotFound(format!( diff --git a/crates/storage-cassandra/src/stream_util.rs b/crates/storage-cassandra/src/stream_util.rs index be58ec18..a7c3e7c2 100644 --- a/crates/storage-cassandra/src/stream_util.rs +++ b/crates/storage-cassandra/src/stream_util.rs @@ -104,7 +104,7 @@ pub fn new_shared_hlc(instance_id: &str) -> SharedHlc { pub fn assign_shard_id(partition_key: &str, table_id: &str) -> String { let hash = crc32fast::hash(partition_key.as_bytes()); let idx = (hash as usize) % SHARDS_PER_STREAM as usize; - format!("shardId-{}-{:012}", table_id, idx) + format!("shardId-{table_id}-{idx:012}") } /// Zero sequence number used as the starting point for new shards. @@ -181,7 +181,7 @@ pub fn stream_record_statement( .unwrap_or_default(); let shard_id = assign_shard_id(&pk_str, table_id); - let sequence_number = hlc.lock().unwrap_or_else(|e| e.into_inner()).generate(); + let sequence_number = hlc.lock().unwrap_or_else(std::sync::PoisonError::into_inner).generate(); let record = StreamRecord { event_id: uuid::Uuid::new_v4().to_string(), diff --git a/crates/storage-cassandra/src/table_engine.rs b/crates/storage-cassandra/src/table_engine.rs index ca186d49..94a444e2 100644 --- a/crates/storage-cassandra/src/table_engine.rs +++ b/crates/storage-cassandra/src/table_engine.rs @@ -59,13 +59,11 @@ impl TableEngine for CassandraEngine { // Fetch limit + 1 to determine if there are more results let query = if let Some(ref _start) = input.exclusive_start_table_name { format!( - "SELECT table_name FROM {}.tables WHERE account_id = ? AND table_name > ? ORDER BY table_name LIMIT ?", - catalog_keyspace + "SELECT table_name FROM {catalog_keyspace}.tables WHERE account_id = ? AND table_name > ? ORDER BY table_name LIMIT ?" ) } else { format!( - "SELECT table_name FROM {}.tables WHERE account_id = ? ORDER BY table_name LIMIT ?", - catalog_keyspace + "SELECT table_name FROM {catalog_keyspace}.tables WHERE account_id = ? ORDER BY table_name LIMIT ?" ) }; @@ -86,7 +84,7 @@ impl TableEngine for CassandraEngine { }; let response = - result.map_err(|e| StorageError::Internal(format!("Query tables: {}", e)))?; + result.map_err(|e| StorageError::Internal(format!("Query tables: {e}")))?; let body = response .response_body() .map_err(|e| StorageError::Internal(e.to_string()))?; @@ -97,6 +95,7 @@ impl TableEngine for CassandraEngine { .filter_map(|row| row.get_r_by_name("table_name").ok()) .collect(); + #[allow(clippy::cast_sign_loss)] let last_evaluated_table_name = if names.len() > limit as usize { names.pop() } else { diff --git a/crates/storage-cassandra/src/table_helpers.rs b/crates/storage-cassandra/src/table_helpers.rs index 0a26a852..d8d0de5a 100644 --- a/crates/storage-cassandra/src/table_helpers.rs +++ b/crates/storage-cassandra/src/table_helpers.rs @@ -28,8 +28,7 @@ impl CassandraEngine { // Query table metadata let table_query = format!( - "SELECT * FROM {}.tables WHERE account_id = ? AND table_name = ?", - catalog_keyspace + "SELECT * FROM {catalog_keyspace}.tables WHERE account_id = ? AND table_name = ?" ); let table_result = self @@ -39,7 +38,7 @@ impl CassandraEngine { cdrs_tokio::query_values!(account_id, table_name), ) .await - .map_err(|e| StorageError::Internal(format!("Query tables failed: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Query tables failed: {e}")))?; let table_body = table_result .response_body() @@ -56,19 +55,18 @@ impl CassandraEngine { // Extract table_id for index query let table_id: String = table_row .get_r_by_name("table_id") - .map_err(|e| StorageError::Internal(format!("Parse table_id: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Parse table_id: {e}")))?; // Query indexes let index_query = format!( - "SELECT * FROM {}.indexes WHERE table_id = ?", - catalog_keyspace + "SELECT * FROM {catalog_keyspace}.indexes WHERE table_id = ?" ); let index_result = self .session .query_with_values(&index_query, cdrs_tokio::query_values!(table_id.as_str())) .await - .map_err(|e| StorageError::Internal(format!("Query indexes failed: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Query indexes failed: {e}")))?; let index_body = index_result .response_body() @@ -89,23 +87,23 @@ impl CassandraEngine { // Parse table fields let table_name: String = table_row .get_r_by_name("table_name") - .map_err(|e| StorageError::Internal(format!("Parse table_name: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Parse table_name: {e}")))?; let key_schema_str: String = table_row .get_r_by_name("key_schema") - .map_err(|e| StorageError::Internal(format!("Parse key_schema: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Parse key_schema: {e}")))?; let key_schema = serde_json::from_str(&key_schema_str) .map_err(|e| StorageError::Internal(e.to_string()))?; let attr_defs_str: String = table_row .get_r_by_name("attribute_definitions") - .map_err(|e| StorageError::Internal(format!("Parse attribute_definitions: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Parse attribute_definitions: {e}")))?; let attr_defs = serde_json::from_str(&attr_defs_str) .map_err(|e| StorageError::Internal(e.to_string()))?; let billing_mode_str: String = table_row .get_r_by_name("billing_mode") - .map_err(|e| StorageError::Internal(format!("Parse billing_mode: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Parse billing_mode: {e}")))?; let pt_str: Option = table_row.get_r_by_name("provisioned_throughput").ok(); let (rcu, wcu) = if let Some(ref s) = pt_str { @@ -123,7 +121,7 @@ impl CassandraEngine { let table_status_str: String = table_row .get_r_by_name("table_status") - .map_err(|e| StorageError::Internal(format!("Parse table_status: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Parse table_status: {e}")))?; let table_status = match table_status_str.as_str() { "ACTIVE" => TableStatus::Active, "CREATING" => TableStatus::Creating, @@ -131,26 +129,25 @@ impl CassandraEngine { "UPDATING" => TableStatus::Updating, other => { return Err(StorageError::Internal(format!( - "unknown table status in database: {}", - other + "unknown table status in database: {other}" ))); } }; let creation_timestamp: i64 = table_row .get_r_by_name("created_at") - .map_err(|e| StorageError::Internal(format!("Parse created_at: {}", e)))?; - let creation_epoch = creation_timestamp as f64 / 1000.0; + .map_err(|e| StorageError::Internal(format!("Parse created_at: {e}")))?; + let creation_epoch = crate::cassandra_util::millis_to_seconds_f64(creation_timestamp); let table_size_bytes: i64 = table_row.get_r_by_name("table_size_bytes").unwrap_or(0); let item_count: i64 = table_row.get_r_by_name("item_count").unwrap_or(0); let table_arn: String = table_row .get_r_by_name("table_arn") - .map_err(|e| StorageError::Internal(format!("Parse table_arn: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Parse table_arn: {e}")))?; let table_id: String = table_row .get_r_by_name("table_id") - .map_err(|e| StorageError::Internal(format!("Parse table_id: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Parse table_id: {e}")))?; let deletion_protection_enabled: bool = table_row .get_r_by_name("deletion_protection_enabled") @@ -172,26 +169,26 @@ impl CassandraEngine { for row in index_rows { let index_name: String = row .get_r_by_name("index_name") - .map_err(|e| StorageError::Internal(format!("Parse index_name: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Parse index_name: {e}")))?; let index_type: String = row .get_r_by_name("index_type") - .map_err(|e| StorageError::Internal(format!("Parse index_type: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Parse index_type: {e}")))?; let ks_str: String = row .get_r_by_name("key_schema") - .map_err(|e| StorageError::Internal(format!("Parse key_schema: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Parse key_schema: {e}")))?; let ks = serde_json::from_str(&ks_str).map_err(|e| StorageError::Internal(e.to_string()))?; let proj_str: String = row .get_r_by_name("projection") - .map_err(|e| StorageError::Internal(format!("Parse projection: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Parse projection: {e}")))?; let proj = serde_json::from_str(&proj_str) .map_err(|e| StorageError::Internal(e.to_string()))?; let index_status: String = row .get_r_by_name("index_status") - .map_err(|e| StorageError::Internal(format!("Parse index_status: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Parse index_status: {e}")))?; if index_type == "GSI" { let pt_str: Option = row.get_r_by_name("provisioned_throughput").ok(); diff --git a/crates/storage-cassandra/src/update_table.rs b/crates/storage-cassandra/src/update_table.rs index 9814c6a2..033ebbe8 100644 --- a/crates/storage-cassandra/src/update_table.rs +++ b/crates/storage-cassandra/src/update_table.rs @@ -59,18 +59,17 @@ impl CassandraEngine { if is_provisioned { let (cur_rcu, cur_wcu) = current_pt_str .and_then(|s| serde_json::from_str::(&s).ok()) - .map(|v| { + .map_or((0, 0), |v| { let rcu = v .get("ReadCapacityUnits") - .and_then(|x| x.as_i64()) + .and_then(serde_json::Value::as_i64) .unwrap_or(0); let wcu = v .get("WriteCapacityUnits") - .and_then(|x| x.as_i64()) + .and_then(serde_json::Value::as_i64) .unwrap_or(0); (rcu, wcu) - }) - .unwrap_or((0, 0)); + }); if cur_rcu == pt.read_capacity_units && cur_wcu == pt.write_capacity_units { return Err(StorageError::NoOpUpdate(format!( "The provisioned throughput for the table will not change. \ @@ -175,11 +174,10 @@ impl CassandraEngine { ) .await?; let has_label = label_row - .map(|r| { + .is_some_and(|r| { let v: Option = r.get_by_name("stream_label").ok().flatten(); v.is_some() - }) - .unwrap_or(false); + }); if !has_label { needs_label_restore = true; let label = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S").to_string(); diff --git a/crates/storage-cassandra/src/worker_store.rs b/crates/storage-cassandra/src/worker_store.rs index 23388743..d5624328 100755 --- a/crates/storage-cassandra/src/worker_store.rs +++ b/crates/storage-cassandra/src/worker_store.rs @@ -47,35 +47,33 @@ impl CassandraEngine { // CREATING → ACTIVE // Note: Cassandra requires ALLOW FILTERING for non-key columns in WHERE clause let query = format!( - "SELECT account_id, table_name, table_id FROM {}.tables \ + "SELECT account_id, table_name, table_id FROM {catalog_keyspace}.tables \ WHERE table_status = 'CREATING' AND status_transition_at <= toTimestamp(now()) \ - ALLOW FILTERING", - catalog_keyspace + ALLOW FILTERING" ); let result = self.session.query(&query).await.map_err(|e| { - StorageError::Internal(format!("Failed to query CREATING tables: {}", e)) + StorageError::Internal(format!("Failed to query CREATING tables: {e}")) })?; let body = result.response_body().map_err(|e| { - StorageError::Internal(format!("Failed to parse CREATING tables response: {}", e)) + StorageError::Internal(format!("Failed to parse CREATING tables response: {e}")) })?; let rows = body.into_rows().unwrap_or_default(); for row in rows { let account_id: String = row.get_r_by_name("account_id").map_err(|e| { - StorageError::Internal(format!("Failed to parse account_id: {}", e)) + StorageError::Internal(format!("Failed to parse account_id: {e}")) })?; let table_name: String = row.get_r_by_name("table_name").map_err(|e| { - StorageError::Internal(format!("Failed to parse table_name: {}", e)) + StorageError::Internal(format!("Failed to parse table_name: {e}")) })?; // Update to ACTIVE (PRIMARY KEY is account_id, table_name) let update = format!( - "UPDATE {}.tables SET table_status = 'ACTIVE', status_transition_at = null \ - WHERE account_id = ? AND table_name = ?", - catalog_keyspace + "UPDATE {catalog_keyspace}.tables SET table_status = 'ACTIVE', status_transition_at = null \ + WHERE account_id = ? AND table_name = ?" ); self.session .query_with_values( @@ -83,52 +81,50 @@ impl CassandraEngine { cdrs_tokio::query_values!(account_id.as_str(), table_name.as_str()), ) .await - .map_err(|e| StorageError::Internal(format!("Failed to activate table: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Failed to activate table: {e}")))?; transitions.push((table_name, "CREATING → active")); } // DELETING → remove row (with tags and data table cleanup) let query = format!( - "SELECT account_id, table_name, table_arn, table_id FROM {}.tables \ + "SELECT account_id, table_name, table_arn, table_id FROM {catalog_keyspace}.tables \ WHERE table_status = 'DELETING' AND status_transition_at <= toTimestamp(now()) \ - ALLOW FILTERING", - catalog_keyspace + ALLOW FILTERING" ); let result = self.session.query(&query).await.map_err(|e| { - StorageError::Internal(format!("Failed to query DELETING tables: {}", e)) + StorageError::Internal(format!("Failed to query DELETING tables: {e}")) })?; let body = result.response_body().map_err(|e| { - StorageError::Internal(format!("Failed to parse DELETING tables response: {}", e)) + StorageError::Internal(format!("Failed to parse DELETING tables response: {e}")) })?; let rows = body.into_rows().unwrap_or_default(); for row in rows { let account_id: String = row.get_r_by_name("account_id").map_err(|e| { - StorageError::Internal(format!("Failed to parse account_id: {}", e)) + StorageError::Internal(format!("Failed to parse account_id: {e}")) })?; let table_name: String = row.get_r_by_name("table_name").map_err(|e| { - StorageError::Internal(format!("Failed to parse table_name: {}", e)) + StorageError::Internal(format!("Failed to parse table_name: {e}")) })?; let table_arn: String = row .get_r_by_name("table_arn") - .map_err(|e| StorageError::Internal(format!("Failed to parse table_arn: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Failed to parse table_arn: {e}")))?; let table_id: String = row .get_r_by_name("table_id") - .map_err(|e| StorageError::Internal(format!("Failed to parse table_id: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Failed to parse table_id: {e}")))?; // Delete tags let tag_delete = format!( - "DELETE FROM {}.tags WHERE resource_arn = ?", - catalog_keyspace + "DELETE FROM {catalog_keyspace}.tags WHERE resource_arn = ?" ); self.session .query_with_values(&tag_delete, cdrs_tokio::query_values!(table_arn.as_str())) .await - .map_err(|e| StorageError::Internal(format!("Failed to delete tags: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Failed to delete tags: {e}")))?; // Delete indexes (catalog + data tables) let account_keyspace = self.account_keyspace(&account_id); @@ -142,8 +138,7 @@ impl CassandraEngine { .await?; let continuous_backup_delete = format!( - "DELETE FROM {}.continuous_backups WHERE account_id = ? AND table_name = ?", - catalog_keyspace + "DELETE FROM {catalog_keyspace}.continuous_backups WHERE account_id = ? AND table_name = ?" ); self.session .query_with_values( @@ -157,8 +152,7 @@ impl CassandraEngine { // Delete table row (PRIMARY KEY is account_id, table_name) let table_delete = format!( - "DELETE FROM {}.tables WHERE account_id = ? AND table_name = ?", - catalog_keyspace + "DELETE FROM {catalog_keyspace}.tables WHERE account_id = ? AND table_name = ?" ); self.session .query_with_values( @@ -166,7 +160,7 @@ impl CassandraEngine { cdrs_tokio::query_values!(account_id.as_str(), table_name.as_str()), ) .await - .map_err(|e| StorageError::Internal(format!("Failed to delete table: {}", e)))?; + .map_err(|e| StorageError::Internal(format!("Failed to delete table: {e}")))?; // Drop base table in account keyspace self.drop_data_table(&account_keyspace, &table_id).await?; diff --git a/crates/storage-cassandra/src/workers.rs b/crates/storage-cassandra/src/workers.rs index 3798e4f8..7ddb11eb 100644 --- a/crates/storage-cassandra/src/workers.rs +++ b/crates/storage-cassandra/src/workers.rs @@ -92,6 +92,7 @@ pub(crate) async fn poll_transaction_recovery( loop { tokio::time::sleep(scan_interval).await; + #[allow(clippy::cast_possible_truncation)] let cutoff = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() @@ -257,6 +258,8 @@ async fn gsi_process_batch( let keyspaces = list_account_keyspaces(engine).await?; let mut total = 0usize; + #[allow(clippy::cast_possible_truncation)] + let worker_id_i32 = worker_id as i32; for keyspace in &keyspaces { let query = format!( @@ -270,7 +273,7 @@ async fn gsi_process_batch( let rows = match crate::cassandra_util::query_rows::( &engine.session, &query, - query_values!(worker_id as i32), + query_values!(worker_id_i32), "gsi_worker", ) .await @@ -341,7 +344,7 @@ async fn gsi_process_batch( crate::cassandra_util::execute( &engine.session, &delete_cql, - query_values!(worker_id as i32, ready_at, id), + query_values!(worker_id_i32, ready_at, id), "gsi_worker_delete", ) .await?; From d6cfe3aa9a39003f6d3946fd9a03ab771c19d0d3 Mon Sep 17 00:00:00 2001 From: Joel Shepherd Date: Mon, 31 Aug 2026 17:05:40 +0000 Subject: [PATCH 08/48] chore: removing unused index function, and cascade user deletes in a logged batch --- crates/storage-cassandra/src/data/index.rs | 114 +++++------------- .../src/management_store/users.rs | 107 ++++++++++++++-- 2 files changed, 128 insertions(+), 93 deletions(-) diff --git a/crates/storage-cassandra/src/data/index.rs b/crates/storage-cassandra/src/data/index.rs index 75cf3f18..7ec8df6d 100644 --- a/crates/storage-cassandra/src/data/index.rs +++ b/crates/storage-cassandra/src/data/index.rs @@ -233,36 +233,38 @@ pub fn sync_indexes( // Delete old index row if the old item had index keys if let Some(old) = old_item - && item_has_index_keys(old, &idx.key_schema) { - delete_index_row_multi( - batch, - account_keyspace, - &idx_table, - old, - &idx.key_schema, - base_key_schema, - &idx_sks, - &base_sks, - )?; - } + && item_has_index_keys(old, &idx.key_schema) + { + delete_index_row_multi( + batch, + account_keyspace, + &idx_table, + old, + &idx.key_schema, + base_key_schema, + &idx_sks, + &base_sks, + )?; + } // Insert new index row if the new item has index keys if let Some(new) = new_item - && item_has_index_keys(new, &idx.key_schema) { - let projected = - project_item_for_index(new, &idx.key_schema, base_key_schema, &idx.projection); - insert_index_row_multi( - batch, - account_keyspace, - &idx_table, - new, - &projected, - &idx.key_schema, - base_key_schema, - &idx_sks, - &base_sks, - )?; - } + && item_has_index_keys(new, &idx.key_schema) + { + let projected = + project_item_for_index(new, &idx.key_schema, base_key_schema, &idx.projection); + insert_index_row_multi( + batch, + account_keyspace, + &idx_table, + new, + &projected, + &idx.key_schema, + base_key_schema, + &idx_sks, + &base_sks, + )?; + } } Ok(()) } @@ -551,9 +553,7 @@ pub(crate) async fn delete_indexes_for_table( engine: &crate::CassandraEngine, ) -> Result, StorageError> { // Collect index IDs - let index_query = format!( - "SELECT index_id FROM {catalog_keyspace}.indexes WHERE table_id = ?" - ); + let index_query = format!("SELECT index_id FROM {catalog_keyspace}.indexes WHERE table_id = ?"); let rows = query_rows( session, @@ -570,9 +570,7 @@ pub(crate) async fn delete_indexes_for_table( } // Delete index metadata from catalog - let delete_query = format!( - "DELETE FROM {catalog_keyspace}.indexes WHERE table_id = ?" - ); + let delete_query = format!("DELETE FROM {catalog_keyspace}.indexes WHERE table_id = ?"); crate::cassandra_util::execute( session, @@ -592,56 +590,6 @@ pub(crate) async fn delete_indexes_for_table( Ok(index_ids) } -/// Delete a specific index by name for a table. -/// -/// Called by `update_table` when a GSI is deleted via UpdateTable API. -/// -/// Returns the index_id that was deleted. -pub(crate) async fn delete_index_by_name( - session: &Arc, - catalog_keyspace: &str, - account_keyspace: &str, - table_id: &str, - index_name: &str, - engine: &crate::CassandraEngine, -) -> Result { - // Get index_id first - let query = format!( - "SELECT index_id FROM {catalog_keyspace}.indexes WHERE table_id = ? AND index_name = ?" - ); - - let row = crate::cassandra_util::query_optional( - session, - &query, - query_values!(table_id, index_name), - "delete_index_by_name", - ) - .await? - .ok_or_else(|| StorageError::IndexNotFound(index_name.to_owned()))?; - - let index_id: String = get_column(&row, "index_id", "delete_index_by_name")?; - - // Delete from catalog - let delete_query = format!( - "DELETE FROM {catalog_keyspace}.indexes WHERE table_id = ? AND index_name = ?" - ); - - crate::cassandra_util::execute( - session, - &delete_query, - query_values!(table_id, index_name), - "delete_index_by_name", - ) - .await?; - - // Drop index data table - engine - .drop_index_data_table(account_keyspace, &index_id) - .await?; - - Ok(index_id) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/storage-cassandra/src/management_store/users.rs b/crates/storage-cassandra/src/management_store/users.rs index b28c08da..a500dc6e 100644 --- a/crates/storage-cassandra/src/management_store/users.rs +++ b/crates/storage-cassandra/src/management_store/users.rs @@ -90,23 +90,110 @@ impl CassandraCatalogStore { } pub(crate) async fn delete_user_impl(&self, account_id: &str, user_name: &str) -> OpResult<()> { - // Check if user exists if !self.user_exists(account_id, user_name).await? { return Err(OpError::NotFound("IAM user not found".to_owned())); } - let catalog_keyspace = self.catalog_keyspace(); - let delete_query = format!( - "DELETE FROM {catalog_keyspace}.iam_users WHERE account_id = ? AND user_name = ?" - ); + let ks = self.catalog_keyspace(); + let session = self.session(); - crate::cassandra_util::execute( - self.session(), - &delete_query, + // Gather all keys needed for cascade deletes before building the batch. + let key_ids: Vec = crate::cassandra_util::query_rows( + session, + &format!( + "SELECT access_key_id FROM {ks}.access_keys \ + WHERE account_id = ? AND user_name = ? ALLOW FILTERING" + ), cdrs_tokio::query_values!(account_id, user_name), - "delete_user", + "delete_user access_keys", ) - .await + .await? + .into_iter() + .map(|row| crate::cassandra_util::get_column(&row, "access_key_id", "delete_user")) + .collect::>()?; + + let policy_names: Vec = crate::cassandra_util::query_rows( + session, + &format!( + "SELECT policy_name FROM {ks}.iam_policies \ + WHERE account_id = ? AND principal_type = 'user' AND principal_name = ?" + ), + cdrs_tokio::query_values!(account_id, user_name), + "delete_user policies", + ) + .await? + .into_iter() + .map(|row| crate::cassandra_util::get_column(&row, "policy_name", "delete_user")) + .collect::>()?; + + let group_names: Vec = crate::cassandra_util::query_rows( + session, + &format!( + "SELECT group_name FROM {ks}.iam_group_members \ + WHERE account_id = ? AND user_name = ? ALLOW FILTERING" + ), + cdrs_tokio::query_values!(account_id, user_name), + "delete_user groups", + ) + .await? + .into_iter() + .map(|row| crate::cassandra_util::get_column(&row, "group_name", "delete_user")) + .collect::>()?; + + // Build one logged batch with all cascade deletes. + let user_av = cdrs_tokio::query::QueryValues::SimpleValues(vec![ + cdrs_tokio::types::value::Value::from(account_id), + cdrs_tokio::types::value::Value::from(user_name), + ]); + let mut batch = cdrs_tokio::query::BatchQueryBuilder::new() + .with_consistency(cdrs_tokio::consistency::Consistency::LocalQuorum) + .add_query( + format!("DELETE FROM {ks}.iam_users WHERE account_id = ? AND user_name = ?"), + user_av.clone(), + ) + .add_query( + format!("DELETE FROM {ks}.iam_user_tags WHERE account_id = ? AND user_name = ?"), + user_av, + ); + for key_id in &key_ids { + batch = batch.add_query( + format!("DELETE FROM {ks}.access_keys WHERE access_key_id = ?"), + cdrs_tokio::query_values!(key_id.as_str()), + ); + } + for policy_name in &policy_names { + batch = batch.add_query( + format!( + "DELETE FROM {ks}.iam_policies \ + WHERE account_id = ? AND principal_type = 'user' \ + AND principal_name = ? AND policy_name = ?" + ), + cdrs_tokio::query_values!(account_id, user_name, policy_name.as_str()), + ); + } + for group_name in &group_names { + batch = batch.add_query( + format!( + "DELETE FROM {ks}.iam_group_members \ + WHERE account_id = ? AND group_name = ? AND user_name = ?" + ), + cdrs_tokio::query_values!(account_id, group_name.as_str(), user_name), + ); + } + + session + .batch( + batch + .build() + .map_err(|e| OpError::Internal(e.to_string()))?, + ) + .await + .map_err(|e| { + tracing::error!("delete_user batch: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + + Ok(()) } pub(crate) async fn list_users_impl( From 87bb1e1307d74c4df34861171bd3e346beb78ead Mon Sep 17 00:00:00 2001 From: Joel Shepherd Date: Tue, 1 Sep 2026 22:58:34 +0000 Subject: [PATCH 09/48] chore(storage-cassandra): cargo fmt --- crates/bin/src/main.rs | 7 +- crates/storage-cassandra/src/admin_store.rs | 14 +- .../src/authorization_store.rs | 29 ++-- crates/storage-cassandra/src/backup_engine.rs | 12 +- crates/storage-cassandra/src/bootstrapper.rs | 58 ++++--- crates/storage-cassandra/src/catalog_store.rs | 86 +++++----- crates/storage-cassandra/src/config.rs | 3 +- crates/storage-cassandra/src/create_table.rs | 21 ++- crates/storage-cassandra/src/data/ddl.rs | 7 +- .../storage-cassandra/src/data/delete_item.rs | 5 +- crates/storage-cassandra/src/data/mod.rs | 9 +- .../src/data/put_get_item.rs | 30 ++-- crates/storage-cassandra/src/data/query.rs | 152 +++++++++--------- crates/storage-cassandra/src/data/scan.rs | 9 +- .../src/data/transaction_ledger.rs | 30 ++-- .../src/data/transactions.rs | 37 +++-- .../storage-cassandra/src/data/update_item.rs | 5 +- crates/storage-cassandra/src/delete_table.rs | 9 +- crates/storage-cassandra/src/lib.rs | 7 +- .../src/management_store/access_keys.rs | 9 +- .../src/management_store/accounts.rs | 41 ++--- .../src/management_store/groups.rs | 5 +- .../storage-cassandra/src/metadata_engine.rs | 10 +- crates/storage-cassandra/src/migrations.rs | 4 +- crates/storage-cassandra/src/operations.rs | 11 +- crates/storage-cassandra/src/stream_engine.rs | 20 ++- crates/storage-cassandra/src/stream_util.rs | 5 +- crates/storage-cassandra/src/table_helpers.rs | 4 +- crates/storage-cassandra/src/update_table.rs | 9 +- crates/storage-cassandra/src/worker_store.rs | 42 ++--- crates/storage-cassandra/src/workers.rs | 71 ++++---- 31 files changed, 378 insertions(+), 383 deletions(-) diff --git a/crates/bin/src/main.rs b/crates/bin/src/main.rs index 1e2b27d0..7928b921 100755 --- a/crates/bin/src/main.rs +++ b/crates/bin/src/main.rs @@ -31,7 +31,12 @@ compile_error!( `--no-default-features --features mongodb)" ); -#[cfg(not(any(feature = "postgres", feature = "mongodb", feature = "sqlite", feature = "cassandra")))] +#[cfg(not(any( + feature = "postgres", + feature = "mongodb", + feature = "sqlite", + feature = "cassandra" +)))] compile_error!( "no backend selected: enable the `postgres` (default), `mongodb`, `sqlite` or `cassandra` feature" ); diff --git a/crates/storage-cassandra/src/admin_store.rs b/crates/storage-cassandra/src/admin_store.rs index 1044a9b9..d820ed84 100755 --- a/crates/storage-cassandra/src/admin_store.rs +++ b/crates/storage-cassandra/src/admin_store.rs @@ -14,9 +14,8 @@ async fn admin_exists( catalog_keyspace: &str, admin_name: &str, ) -> OpResult { - let query = format!( - "SELECT admin_name FROM {catalog_keyspace}.admin_users WHERE admin_name = ?" - ); + let query = + format!("SELECT admin_name FROM {catalog_keyspace}.admin_users WHERE admin_name = ?"); let row = crate::cassandra_util::query_optional( session, &query, @@ -76,9 +75,8 @@ impl extenddb_storage::management_store::AdminStore for CassandraCatalogStore { let session = self.session().clone(); let catalog_keyspace = self.catalog_keyspace(); Box::pin(async move { - let query = format!( - "SELECT admin_name, created_at FROM {catalog_keyspace}.admin_users" - ); + let query = + format!("SELECT admin_name, created_at FROM {catalog_keyspace}.admin_users"); let rows = crate::cassandra_util::query_rows( &session, &query, @@ -119,9 +117,7 @@ impl extenddb_storage::management_store::AdminStore for CassandraCatalogStore { return Err(OpError::NotFound("Admin user not found".to_owned())); } - let query = format!( - "DELETE FROM {catalog_keyspace}.admin_users WHERE admin_name = ?" - ); + let query = format!("DELETE FROM {catalog_keyspace}.admin_users WHERE admin_name = ?"); crate::cassandra_util::execute( &session, &query, diff --git a/crates/storage-cassandra/src/authorization_store.rs b/crates/storage-cassandra/src/authorization_store.rs index 176cc1d2..1b0da34f 100755 --- a/crates/storage-cassandra/src/authorization_store.rs +++ b/crates/storage-cassandra/src/authorization_store.rs @@ -300,24 +300,25 @@ impl AuthorizationStore for CassandraCatalogStore { let mut session_tags = Vec::new(); if let Some(tags_text) = session_tags_text - && let Ok(tags_val) = serde_json::from_str::(&tags_text) { - if let Some(arr) = tags_val.as_array() { - for tag in arr { - if let (Some(k), Some(v)) = ( - tag.get("Key").and_then(|k| k.as_str()), - tag.get("Value").and_then(|v| v.as_str()), - ) { - session_tags.push((k.to_owned(), v.to_owned())); - } + && let Ok(tags_val) = serde_json::from_str::(&tags_text) + { + if let Some(arr) = tags_val.as_array() { + for tag in arr { + if let (Some(k), Some(v)) = ( + tag.get("Key").and_then(|k| k.as_str()), + tag.get("Value").and_then(|v| v.as_str()), + ) { + session_tags.push((k.to_owned(), v.to_owned())); } - } else if let Some(obj) = tags_val.as_object() { - for (k, v) in obj { - if let Some(v_str) = v.as_str() { - session_tags.push((k.clone(), v_str.to_owned())); - } + } + } else if let Some(obj) = tags_val.as_object() { + for (k, v) in obj { + if let Some(v_str) = v.as_str() { + session_tags.push((k.clone(), v_str.to_owned())); } } } + } Ok(Some(SessionData { session_policy, diff --git a/crates/storage-cassandra/src/backup_engine.rs b/crates/storage-cassandra/src/backup_engine.rs index 20faa21f..d63c1c16 100755 --- a/crates/storage-cassandra/src/backup_engine.rs +++ b/crates/storage-cassandra/src/backup_engine.rs @@ -130,7 +130,9 @@ impl StoredBackup { backup_status: self.backup_status.clone(), backup_type: self.backup_type.clone(), backup_size_bytes: self.backup_size_bytes, - backup_creation_date_time: crate::cassandra_util::millis_to_seconds_f64(self.created_at), + backup_creation_date_time: crate::cassandra_util::millis_to_seconds_f64( + self.created_at, + ), } } @@ -143,7 +145,9 @@ impl StoredBackup { backup_status: self.backup_status.clone(), backup_type: self.backup_type.clone(), backup_size_bytes: self.backup_size_bytes, - backup_creation_date_time: crate::cassandra_util::millis_to_seconds_f64(self.created_at), + backup_creation_date_time: crate::cassandra_util::millis_to_seconds_f64( + self.created_at, + ), } } @@ -160,7 +164,9 @@ impl StoredBackup { item_count: self.item_count, table_size_bytes: self.backup_size_bytes, billing_mode: Some(self.billing_mode.clone()), - table_creation_date_time: crate::cassandra_util::millis_to_seconds_f64(self.table_created_at), + table_creation_date_time: crate::cassandra_util::millis_to_seconds_f64( + self.table_created_at, + ), }, }) } diff --git a/crates/storage-cassandra/src/bootstrapper.rs b/crates/storage-cassandra/src/bootstrapper.rs index 07774cbc..7ff1264c 100644 --- a/crates/storage-cassandra/src/bootstrapper.rs +++ b/crates/storage-cassandra/src/bootstrapper.rs @@ -108,8 +108,11 @@ impl CassandraBootstrapper { // Check for conflicts between CLI args and config values if let Some(ref cli_cp) = cassandra_contact_points { let cli_list: Vec<&str> = cli_cp.split(',').collect(); - let config_list: Vec<&str> = - config.contact_points.iter().map(std::string::String::as_str).collect(); + let config_list: Vec<&str> = config + .contact_points + .iter() + .map(std::string::String::as_str) + .collect(); if cli_list != config_list { return Err(StorageError::Internal(format!( "--cassandra-contact-points '{}' conflicts with config file contact points '{}'", @@ -134,12 +137,13 @@ impl CassandraBootstrapper { )?; if let Some(ref cli_prefix) = keyspace_prefix - && cli_prefix != &config.keyspace_prefix { - return Err(StorageError::Internal(format!( - "--keyspace-prefix '{}' conflicts with config file keyspace prefix '{}'", - cli_prefix, config.keyspace_prefix - ))); - } + && cli_prefix != &config.keyspace_prefix + { + return Err(StorageError::Internal(format!( + "--keyspace-prefix '{}' conflicts with config file keyspace prefix '{}'", + cli_prefix, config.keyspace_prefix + ))); + } if let Some(ref cli_rf) = replication_factor { let cli_rf_val = cli_rf.parse::().map_err(|_| { @@ -176,13 +180,16 @@ impl CassandraBootstrapper { }; // CLI args override config (or use config values if no CLI arg provided) - let resolved_contact_points = cassandra_contact_points - .map_or(contact_points, |cp| cp.split(',').map(std::string::ToString::to_string).collect()); + let resolved_contact_points = cassandra_contact_points.map_or(contact_points, |cp| { + cp.split(',') + .map(std::string::ToString::to_string) + .collect() + }); let resolved_admin_user = cassandra_user .unwrap_or_else(|| std::env::var("USER").unwrap_or_else(|_| "cassandra".to_owned())); let resolved_keyspace_prefix = keyspace_prefix.unwrap_or(prefix); - let resolved_replication_factor = replication_factor - .map_or(rf_from_config, |rf| rf.parse::().unwrap_or(1)); + let resolved_replication_factor = + replication_factor.map_or(rf_from_config, |rf| rf.parse::().unwrap_or(1)); let resolved_app_user = extenddb_user.unwrap_or(user); let resolved_app_password = extenddb_pass.unwrap_or(password); @@ -407,9 +414,8 @@ impl Bootstrapper for CassandraBootstrapper { println!("--- Generating AES-256-GCM encryption key..."); // Check if key already exists - let check_cql = format!( - "SELECT value FROM {keyspace}.settings WHERE key = 'encryption_key'" - ); + let check_cql = + format!("SELECT value FROM {keyspace}.settings WHERE key = 'encryption_key'"); let exists = self .engine .session() @@ -428,9 +434,7 @@ impl Bootstrapper for CassandraBootstrapper { let key_b64 = generate_encryption_key(); // Store key - let insert_cql = format!( - "INSERT INTO {keyspace}.settings (key, value) VALUES (?, ?)" - ); + let insert_cql = format!("INSERT INTO {keyspace}.settings (key, value) VALUES (?, ?)"); self.engine .session() .query_with_values( @@ -510,9 +514,8 @@ impl Bootstrapper for CassandraBootstrapper { let from_env = env_user.is_some() && env_password.is_some(); // Check if user exists - let check_cql = format!( - "SELECT admin_name FROM {keyspace}.admin_users WHERE admin_name = ?" - ); + let check_cql = + format!("SELECT admin_name FROM {keyspace}.admin_users WHERE admin_name = ?"); let exists = self .engine .session() @@ -546,9 +549,8 @@ impl Bootstrapper for CassandraBootstrapper { let password_hash = hash_password_async(password.clone()).await?; // Insert admin user - let insert_cql = format!( - "INSERT INTO {keyspace}.admin_users (admin_name, password_hash) VALUES (?, ?)" - ); + let insert_cql = + format!("INSERT INTO {keyspace}.admin_users (admin_name, password_hash) VALUES (?, ?)"); self.engine .session() @@ -577,9 +579,7 @@ impl Bootstrapper for CassandraBootstrapper { async fn list_table_names(&self) -> OpResult> { let keyspace = self.engine.catalog_keyspace(); - let cql = format!( - "SELECT table_name FROM {keyspace}.tables ORDER BY table_name" - ); + let cql = format!("SELECT table_name FROM {keyspace}.tables ORDER BY table_name"); let rows = match self.engine.session().query(cql).await { Ok(frame) => frame .response_body() @@ -656,9 +656,7 @@ impl Bootstrapper for CassandraBootstrapper { return Ok(None); } - let query = format!( - "SELECT value FROM {keyspace}.settings WHERE key = 'catalog_version'" - ); + let query = format!("SELECT value FROM {keyspace}.settings WHERE key = 'catalog_version'"); let version = self .engine .session() diff --git a/crates/storage-cassandra/src/catalog_store.rs b/crates/storage-cassandra/src/catalog_store.rs index cc0b1247..0e1057a7 100644 --- a/crates/storage-cassandra/src/catalog_store.rs +++ b/crates/storage-cassandra/src/catalog_store.rs @@ -165,9 +165,8 @@ impl CassandraCatalogStore { /// Check if an account exists. Used to emulate foreign key checks. pub(crate) async fn account_exists(&self, account_id: &str) -> Result { let catalog_keyspace = self.catalog_keyspace(); - let query = format!( - "SELECT account_id FROM {catalog_keyspace}.accounts WHERE account_id = ?" - ); + let query = + format!("SELECT account_id FROM {catalog_keyspace}.accounts WHERE account_id = ?"); let rows = crate::cassandra_util::query_rows( &self.session, @@ -211,9 +210,7 @@ impl extenddb_storage::management_store::SettingsStore for CassandraCatalogStore let session = self.session.clone(); let catalog_keyspace = self.catalog_keyspace(); Box::pin(async move { - let query = format!( - "SELECT value FROM {catalog_keyspace}.settings WHERE key = ?" - ); + let query = format!("SELECT value FROM {catalog_keyspace}.settings WHERE key = ?"); let row = crate::cassandra_util::query_optional( &session, @@ -239,9 +236,8 @@ impl extenddb_storage::management_store::SettingsStore for CassandraCatalogStore let session = self.session.clone(); let catalog_keyspace = self.catalog_keyspace(); Box::pin(async move { - let query = format!( - "INSERT INTO {catalog_keyspace}.settings (key, value) VALUES (?, ?)" - ); + let query = + format!("INSERT INTO {catalog_keyspace}.settings (key, value) VALUES (?, ?)"); crate::cassandra_util::execute( &session, @@ -286,7 +282,9 @@ impl extenddb_storage::management_store::SettingsStore for CassandraCatalogStore } fn cached_encryption_key(&self) -> Option { - self.encryption_key.as_ref().map(std::string::ToString::to_string) + self.encryption_key + .as_ref() + .map(std::string::ToString::to_string) } } @@ -307,12 +305,13 @@ impl extenddb_storage::diagnostics::DiagnosticsStore for CassandraCatalogStore { })?; if let Some(rows) = body.into_rows() - && let Some(row) = rows.first() { - let count: i64 = row.get_r_by_name("count").map_err(|e| { - extenddb_storage::diagnostics::DiagError::QueryFailed(e.to_string()) - })?; - return Ok(count); - } + && let Some(row) = rows.first() + { + let count: i64 = row.get_r_by_name("count").map_err(|e| { + extenddb_storage::diagnostics::DiagError::QueryFailed(e.to_string()) + })?; + return Ok(count); + } Ok(0) }) @@ -332,12 +331,13 @@ impl extenddb_storage::diagnostics::DiagnosticsStore for CassandraCatalogStore { })?; if let Some(rows) = body.into_rows() - && let Some(row) = rows.first() { - let count: i64 = row.get_r_by_name("count").map_err(|e| { - extenddb_storage::diagnostics::DiagError::QueryFailed(e.to_string()) - })?; - return Ok(count); - } + && let Some(row) = rows.first() + { + let count: i64 = row.get_r_by_name("count").map_err(|e| { + extenddb_storage::diagnostics::DiagError::QueryFailed(e.to_string()) + })?; + return Ok(count); + } Ok(0) }) @@ -352,9 +352,7 @@ impl extenddb_storage::diagnostics::DiagnosticsStore for CassandraCatalogStore { Box::pin(async move { // For Cassandra, we test connection to account keyspaces // Get a sample account keyspace name from accounts table - let query = format!( - "SELECT account_id FROM {catalog_keyspace}.accounts LIMIT 1" - ); + let query = format!("SELECT account_id FROM {catalog_keyspace}.accounts LIMIT 1"); let result = session.query(&query).await.map_err(|e| { extenddb_storage::diagnostics::DiagError::QueryFailed(format!( "Failed to query accounts: {e}" @@ -366,24 +364,24 @@ impl extenddb_storage::diagnostics::DiagnosticsStore for CassandraCatalogStore { })?; if let Some(rows) = body.into_rows() - && let Some(row) = rows.first() { - let account_id: String = row.get_r_by_name("account_id").map_err(|e| { - extenddb_storage::diagnostics::DiagError::QueryFailed(e.to_string()) - })?; - - // Test connection to account keyspace by querying schema_history - let account_keyspace = format!("{keyspace_prefix}_account_{account_id}"); - let test_query = - format!("SELECT COUNT(*) FROM {account_keyspace}.schema_history"); - - session.query(&test_query).await.map_err(|e| { - extenddb_storage::diagnostics::DiagError::ConnectionFailed(format!( - "Failed to query account keyspace {account_keyspace}: {e}" - )) - })?; + && let Some(row) = rows.first() + { + let account_id: String = row.get_r_by_name("account_id").map_err(|e| { + extenddb_storage::diagnostics::DiagError::QueryFailed(e.to_string()) + })?; + + // Test connection to account keyspace by querying schema_history + let account_keyspace = format!("{keyspace_prefix}_account_{account_id}"); + let test_query = format!("SELECT COUNT(*) FROM {account_keyspace}.schema_history"); + + session.query(&test_query).await.map_err(|e| { + extenddb_storage::diagnostics::DiagError::ConnectionFailed(format!( + "Failed to query account keyspace {account_keyspace}: {e}" + )) + })?; - return Ok(account_keyspace); - } + return Ok(account_keyspace); + } // No accounts exist yet - that's okay, just return a message Ok("No account keyspaces exist yet".to_string()) @@ -445,6 +443,8 @@ impl MetricsStore for CassandraCatalogStore { impl extenddb_storage::CatalogStore for CassandraCatalogStore { fn cached_encryption_key(&self) -> Option { - self.encryption_key.as_ref().map(std::string::ToString::to_string) + self.encryption_key + .as_ref() + .map(std::string::ToString::to_string) } } diff --git a/crates/storage-cassandra/src/config.rs b/crates/storage-cassandra/src/config.rs index de38e0bf..97b97854 100644 --- a/crates/storage-cassandra/src/config.rs +++ b/crates/storage-cassandra/src/config.rs @@ -117,7 +117,8 @@ impl extenddb_storage::config::StorageConfig for CassandraStorageConfig { fn connection_config(&self) -> &str { // Return JDBC-style connection string: host1,host2/keyspace_prefix // This allows factories to parse both contact points and keyspace - self.cached_connection_string.as_deref() + self.cached_connection_string + .as_deref() .unwrap_or("(no connection string)") } diff --git a/crates/storage-cassandra/src/create_table.rs b/crates/storage-cassandra/src/create_table.rs index 2311e433..9fc0fbcf 100644 --- a/crates/storage-cassandra/src/create_table.rs +++ b/crates/storage-cassandra/src/create_table.rs @@ -121,9 +121,7 @@ impl CassandraEngine { // Read control_plane_delay_seconds from settings let catalog_keyspace = self.catalog_keyspace(); - let delay_query = format!( - "SELECT value FROM {catalog_keyspace}.settings WHERE key = ?" - ); + let delay_query = format!("SELECT value FROM {catalog_keyspace}.settings WHERE key = ?"); let delay_seconds: f64 = self .session .query_with_values( @@ -211,16 +209,17 @@ impl CassandraEngine { .map_err(|e| StorageError::Internal(format!("Failed to get response body: {e}")))?; if let Some(rows) = body.into_rows() - && let Some(row) = rows.first() { - use cdrs_tokio::types::IntoRustByName; - let applied: bool = row.get_r_by_name("[applied]").map_err(|e| { - StorageError::Internal(format!("Failed to parse [applied]: {e}")) - })?; + && let Some(row) = rows.first() + { + use cdrs_tokio::types::IntoRustByName; + let applied: bool = row + .get_r_by_name("[applied]") + .map_err(|e| StorageError::Internal(format!("Failed to parse [applied]: {e}")))?; - if !applied { - return Err(StorageError::TableAlreadyExists(input.table_name.clone())); - } + if !applied { + return Err(StorageError::TableAlreadyExists(input.table_name.clone())); } + } // Insert GSI metadata let mut gsi_index_ids: Vec = Vec::new(); diff --git a/crates/storage-cassandra/src/data/ddl.rs b/crates/storage-cassandra/src/data/ddl.rs index 67bebd4a..d1e18c03 100644 --- a/crates/storage-cassandra/src/data/ddl.rs +++ b/crates/storage-cassandra/src/data/ddl.rs @@ -277,9 +277,10 @@ impl CassandraEngine { let idx_table = index_table_name(index_id); let ddl = format!("DROP TABLE IF EXISTS {account_keyspace}.{idx_table}"); - self.session.query(&ddl).await.map_err(|e| { - StorageError::Internal(format!("Failed to drop index data table: {e}")) - })?; + self.session + .query(&ddl) + .await + .map_err(|e| StorageError::Internal(format!("Failed to drop index data table: {e}")))?; Ok(()) } diff --git a/crates/storage-cassandra/src/data/delete_item.rs b/crates/storage-cassandra/src/data/delete_item.rs index 15562640..79542116 100644 --- a/crates/storage-cassandra/src/data/delete_item.rs +++ b/crates/storage-cassandra/src/data/delete_item.rs @@ -115,9 +115,8 @@ impl CassandraEngine { } // Delete the item (with index updates if needed). - let delete_cql = format!( - "DELETE FROM {data_keyspace}.{ddb_table} WHERE pk = ? AND {sk_col} = ?" - ); + let delete_cql = + format!("DELETE FROM {data_keyspace}.{ddb_table} WHERE pk = ? AND {sk_col} = ?"); // Update partition_max_delete_timestamp before the batch (must precede delete). if old_item_opt.is_some() { diff --git a/crates/storage-cassandra/src/data/mod.rs b/crates/storage-cassandra/src/data/mod.rs index 3b822b54..0bf587d8 100644 --- a/crates/storage-cassandra/src/data/mod.rs +++ b/crates/storage-cassandra/src/data/mod.rs @@ -325,14 +325,11 @@ pub(crate) async fn select_by_pk( sk_col: Option<&str>, ) -> Result, StorageError> { let result = if let (Some(sk), Some(sk_col)) = (sk, sk_col) { - let query = format!( - "SELECT {columns} FROM {keyspace}.{table} WHERE pk = ? AND {sk_col} = ?" - ); + let query = + format!("SELECT {columns} FROM {keyspace}.{table} WHERE pk = ? AND {sk_col} = ?"); query_with_pk_sk(session, &query, pk, sk).await } else { - let query = format!( - "SELECT {columns} FROM {keyspace}.{table} WHERE pk = ?" - ); + let query = format!("SELECT {columns} FROM {keyspace}.{table} WHERE pk = ?"); session .query_with_values(&query, cdrs_tokio::query_values!(pk)) .await diff --git a/crates/storage-cassandra/src/data/put_get_item.rs b/crates/storage-cassandra/src/data/put_get_item.rs index 281b3515..c3a2c771 100644 --- a/crates/storage-cassandra/src/data/put_get_item.rs +++ b/crates/storage-cassandra/src/data/put_get_item.rs @@ -397,19 +397,18 @@ impl CassandraEngine { .map_err(|e| StorageError::Internal(format!("Parse response: {e}")))?; if let Some(rows) = body.into_rows() - && let Some(row) = rows.into_iter().next() { - let item_data: String = row - .get_r_by_name("item_data") - .map_err(|e| StorageError::Internal(format!("Parse item_data: {e}")))?; - return Ok(Some(json_to_item(item_data)?)); - } + && let Some(row) = rows.into_iter().next() + { + let item_data: String = row + .get_r_by_name("item_data") + .map_err(|e| StorageError::Internal(format!("Parse item_data: {e}")))?; + return Ok(Some(json_to_item(item_data)?)); + } Ok(None) } else { // PK-only table - let query = format!( - "SELECT item_data FROM {data_keyspace}.{ddb_table} WHERE pk = ?" - ); + let query = format!("SELECT item_data FROM {data_keyspace}.{ddb_table} WHERE pk = ?"); let result = self .session @@ -422,12 +421,13 @@ impl CassandraEngine { .map_err(|e| StorageError::Internal(format!("Parse response: {e}")))?; if let Some(rows) = body.into_rows() - && let Some(row) = rows.into_iter().next() { - let item_data: String = row - .get_r_by_name("item_data") - .map_err(|e| StorageError::Internal(format!("Parse item_data: {e}")))?; - return Ok(Some(json_to_item(item_data)?)); - } + && let Some(row) = rows.into_iter().next() + { + let item_data: String = row + .get_r_by_name("item_data") + .map_err(|e| StorageError::Internal(format!("Parse item_data: {e}")))?; + return Ok(Some(json_to_item(item_data)?)); + } Ok(None) } diff --git a/crates/storage-cassandra/src/data/query.rs b/crates/storage-cassandra/src/data/query.rs index cb16d375..6087a5e8 100644 --- a/crates/storage-cassandra/src/data/query.rs +++ b/crates/storage-cassandra/src/data/query.rs @@ -138,9 +138,8 @@ impl CassandraEngine { // Step 3: Build query let account_keyspace = self.account_keyspace(&key_info.account_id); - let mut query = format!( - "SELECT item_data FROM {account_keyspace}.{table_name} WHERE pk = ?" - ); + let mut query = + format!("SELECT item_data FROM {account_keyspace}.{table_name} WHERE pk = ?"); // Step 4: Add sort key condition if present let sk_value_opt = if let (Some(sk_cond), Some((_, sk_type))) = @@ -518,80 +517,80 @@ impl CassandraEngine { // Query 2: next SK values (only if we haven't reached limit yet) if all_rows.len() < fetch_limit && sk_info_opt.is_some() { if let Some(start_sk) = start_sk { - let remaining = fetch_limit - all_rows.len(); - let query2 = if let Some((_, sk_type)) = sk_info_opt { - let sk_col = sk_column(sk_type); - let cmp = if forward { ">" } else { "<" }; - let order_clause = if let Some((_, base_sk_type)) = &base_sk_info_val { - let base_sk_col = format!("base_{}", sk_column(*base_sk_type)); - let dir = if forward { "ASC" } else { "DESC" }; - format!( - " ORDER BY {sk_col} {dir}, base_pk {dir}, {base_sk_col} {dir} LIMIT {remaining}" - ) + let remaining = fetch_limit - all_rows.len(); + let query2 = if let Some((_, sk_type)) = sk_info_opt { + let sk_col = sk_column(sk_type); + let cmp = if forward { ">" } else { "<" }; + let order_clause = if let Some((_, base_sk_type)) = &base_sk_info_val { + let base_sk_col = format!("base_{}", sk_column(*base_sk_type)); + let dir = if forward { "ASC" } else { "DESC" }; + format!( + " ORDER BY {sk_col} {dir}, base_pk {dir}, {base_sk_col} {dir} LIMIT {remaining}" + ) + } else { + format!( + " ORDER BY {} {}, base_pk {} LIMIT {}", + sk_col, + if forward { "ASC" } else { "DESC" }, + if forward { "ASC" } else { "DESC" }, + remaining + ) + }; + format!("{query} AND {sk_col} {cmp} ?{order_clause}") } else { - format!( - " ORDER BY {} {}, base_pk {} LIMIT {}", - sk_col, - if forward { "ASC" } else { "DESC" }, - if forward { "ASC" } else { "DESC" }, - remaining - ) + // Hash-only index + let order_clause = if let Some((_, base_sk_type)) = &base_sk_info_val { + let base_sk_col = format!("base_{}", sk_column(*base_sk_type)); + format!( + " ORDER BY base_pk {}, {} {} LIMIT {}", + if forward { "ASC" } else { "DESC" }, + base_sk_col, + if forward { "ASC" } else { "DESC" }, + remaining + ) + } else { + format!( + " ORDER BY base_pk {} LIMIT {}", + if forward { "ASC" } else { "DESC" }, + remaining + ) + }; + format!("{query} AND base_pk > ?{order_clause}") }; - format!("{query} AND {sk_col} {cmp} ?{order_clause}") - } else { - // Hash-only index - let order_clause = if let Some((_, base_sk_type)) = &base_sk_info_val { - let base_sk_col = format!("base_{}", sk_column(*base_sk_type)); - format!( - " ORDER BY base_pk {}, {} {} LIMIT {}", - if forward { "ASC" } else { "DESC" }, - base_sk_col, - if forward { "ASC" } else { "DESC" }, - remaining - ) - } else { - format!( - " ORDER BY base_pk {} LIMIT {}", - if forward { "ASC" } else { "DESC" }, - remaining - ) + + let rows2 = match &pagination_binds { + PaginationBinds::BaseSkOnly { .. } + | PaginationBinds::BasePkOnly { .. } + | PaginationBinds::BasePkAndSk { .. } + if sk_info_opt.is_some() => + { + query_with_pk_sk( + &self.session_arc(), + &query2, + &pk_text, + start_sk, + "next_sk", + ) + .await? + } + PaginationBinds::BasePkOnly { + pk_text: base_pk_text, + } + | PaginationBinds::BasePkAndSk { + pk_text: base_pk_text, + .. + } => { + cassandra_util::query_rows( + &self.session_arc(), + &query2, + cdrs_tokio::query_values!(pk_text.as_str(), base_pk_text.as_str()), + "next_sk", + ) + .await? + } + _ => Vec::new(), }; - format!("{query} AND base_pk > ?{order_clause}") - }; - - let rows2 = match &pagination_binds { - PaginationBinds::BaseSkOnly { .. } - | PaginationBinds::BasePkOnly { .. } - | PaginationBinds::BasePkAndSk { .. } - if sk_info_opt.is_some() => - { - query_with_pk_sk( - &self.session_arc(), - &query2, - &pk_text, - start_sk, - "next_sk", - ) - .await? - } - PaginationBinds::BasePkOnly { - pk_text: base_pk_text, - } - | PaginationBinds::BasePkAndSk { - pk_text: base_pk_text, - .. - } => { - cassandra_util::query_rows( - &self.session_arc(), - &query2, - cdrs_tokio::query_values!(pk_text.as_str(), base_pk_text.as_str()), - "next_sk", - ) - .await? - } - _ => Vec::new(), - }; - all_rows.extend(rows2); + all_rows.extend(rows2); } // end if let Some(start_sk) } @@ -666,7 +665,10 @@ impl CassandraEngine { cassandra_util::query_rows( &self.session_arc(), &query, - cdrs_tokio::query_values!(pk_text.as_str(), base_pk_text.as_str()), + cdrs_tokio::query_values!( + pk_text.as_str(), + base_pk_text.as_str() + ), "query", ) .await? diff --git a/crates/storage-cassandra/src/data/scan.rs b/crates/storage-cassandra/src/data/scan.rs index 9147e553..5e7b641f 100644 --- a/crates/storage-cassandra/src/data/scan.rs +++ b/crates/storage-cassandra/src/data/scan.rs @@ -381,10 +381,11 @@ impl crate::CassandraEngine { binds.push(Value::from(base_pk_text)); if let Some((base_sk_name, base_sk_type)) = base_sk_info_opt - && let Some(av) = start_key.get(base_sk_name) { - cols.push(format!("base_{}", sk_column(base_sk_type))); - binds.push(sk_to_value(&parse_sk(av, base_sk_type)?)); - } + && let Some(av) = start_key.get(base_sk_name) + { + cols.push(format!("base_{}", sk_column(base_sk_type))); + binds.push(sk_to_value(&parse_sk(av, base_sk_type)?)); + } Ok(Some((format_clustering_comparison(&cols), binds))) } diff --git a/crates/storage-cassandra/src/data/transaction_ledger.rs b/crates/storage-cassandra/src/data/transaction_ledger.rs index 39076655..7ae624ba 100644 --- a/crates/storage-cassandra/src/data/transaction_ledger.rs +++ b/crates/storage-cassandra/src/data/transaction_ledger.rs @@ -132,15 +132,16 @@ impl CassandraEngine { })?; if let Some(rows) = body.into_rows() - && let Some(row) = rows.first() { - let applied: bool = get_column(row, "[applied]", "write_ledger_entry")?; - if !applied { - tracing::error!("write_ledger_entry: transaction ID already exists"); - return Err(StorageError::Internal( - "Transaction ID already exists".to_owned(), - )); - } + && let Some(row) = rows.first() + { + let applied: bool = get_column(row, "[applied]", "write_ledger_entry")?; + if !applied { + tracing::error!("write_ledger_entry: transaction ID already exists"); + return Err(StorageError::Internal( + "Transaction ID already exists".to_owned(), + )); } + } Ok(()) } @@ -152,9 +153,7 @@ impl CassandraEngine { txn_id: Uuid, new_state: TransactionState, ) -> Result<(), StorageError> { - let query = format!( - "UPDATE {keyspace}.transaction_ledger SET state = ? WHERE txn_id = ?" - ); + let query = format!("UPDATE {keyspace}.transaction_ledger SET state = ? WHERE txn_id = ?"); self.session .query_with_values( @@ -185,9 +184,8 @@ impl CassandraEngine { ) -> Result<(), StorageError> { let blob = serde_json::to_string(ops) .map_err(|e| StorageError::Internal(format!("serialize ledger ops: {e}")))?; - let query = format!( - "UPDATE {keyspace}.transaction_ledger SET items_blob = ? WHERE txn_id = ?" - ); + let query = + format!("UPDATE {keyspace}.transaction_ledger SET items_blob = ? WHERE txn_id = ?"); self.session .query_with_values( &query, @@ -292,9 +290,7 @@ impl CassandraEngine { keyspace: &str, txn_id: Uuid, ) -> Result<(), StorageError> { - let query = format!( - "DELETE FROM {keyspace}.transaction_ledger WHERE txn_id = ?" - ); + let query = format!("DELETE FROM {keyspace}.transaction_ledger WHERE txn_id = ?"); self.session .query_with_values( diff --git a/crates/storage-cassandra/src/data/transactions.rs b/crates/storage-cassandra/src/data/transactions.rs index c381c8ba..b0c40a86 100644 --- a/crates/storage-cassandra/src/data/transactions.rs +++ b/crates/storage-cassandra/src/data/transactions.rs @@ -89,12 +89,13 @@ impl CassandraEngine { // Check for prepared transactions in Phase 1 data for (i, result) in phase1_results.iter().enumerate() { if let Some((_, _, prepared_txn_id)) = result - && prepared_txn_id.is_some() { - // Item is in a prepared transaction - conflict - let mut reasons = vec![CancellationReason::none(); ops.len()]; - reasons[i] = transaction_conflict_reason(); - return Err(StorageError::TransactionCanceled(reasons)); - } + && prepared_txn_id.is_some() + { + // Item is in a prepared transaction - conflict + let mut reasons = vec![CancellationReason::none(); ops.len()]; + reasons[i] = transaction_conflict_reason(); + return Err(StorageError::TransactionCanceled(reasons)); + } } // Phase 2: Verify timestamps haven't changed @@ -841,11 +842,12 @@ impl CassandraEngine { .ok() .flatten(); if let Some(max_ts) = max_ts - && txn_timestamp <= max_ts { - return Err(CancellationReason::validation_error( - "Item was deleted at a later timestamp".to_owned(), - )); - } + && txn_timestamp <= max_ts + { + return Err(CancellationReason::validation_error( + "Item was deleted at a later timestamp".to_owned(), + )); + } } Ok(()) @@ -1514,13 +1516,14 @@ fn check_lwt_applied(result: &Envelope, context: &str) -> Result<(), Cancellatio })?; if let Some(rows) = body.into_rows() - && let Some(row) = rows.first() { - let applied: bool = get_column::(row, "[applied]", context) - .map_err(|_| transaction_conflict_reason())?; - if !applied { - return Err(transaction_conflict_reason()); - } + && let Some(row) = rows.first() + { + let applied: bool = get_column::(row, "[applied]", context) + .map_err(|_| transaction_conflict_reason())?; + if !applied { + return Err(transaction_conflict_reason()); } + } Ok(()) } diff --git a/crates/storage-cassandra/src/data/update_item.rs b/crates/storage-cassandra/src/data/update_item.rs index 160ef069..d32d85c1 100644 --- a/crates/storage-cassandra/src/data/update_item.rs +++ b/crates/storage-cassandra/src/data/update_item.rs @@ -292,9 +292,8 @@ impl CassandraEngine { } } } else { - let update_cql = format!( - "UPDATE {data_keyspace}.{ddb_table} SET item_data = ? WHERE pk = ?" - ); + let update_cql = + format!("UPDATE {data_keyspace}.{ddb_table} SET item_data = ? WHERE pk = ?"); let stream_stmt = stream.and_then(|cap| { stream_record_statement( diff --git a/crates/storage-cassandra/src/delete_table.rs b/crates/storage-cassandra/src/delete_table.rs index 8593cce7..20bec025 100644 --- a/crates/storage-cassandra/src/delete_table.rs +++ b/crates/storage-cassandra/src/delete_table.rs @@ -60,9 +60,7 @@ impl CassandraEngine { .map_err(|e| StorageError::Internal(format!("Parse table_id: {e}")))?; // Fetch indexes for response - let index_query = format!( - "SELECT * FROM {catalog_keyspace}.indexes WHERE table_id = ?" - ); + let index_query = format!("SELECT * FROM {catalog_keyspace}.indexes WHERE table_id = ?"); let index_result = self .session @@ -98,9 +96,8 @@ impl CassandraEngine { } // Delete catalog entries (indexes first due to FK) - let delete_indexes_query = format!( - "DELETE FROM {catalog_keyspace}.indexes WHERE table_id = ?" - ); + let delete_indexes_query = + format!("DELETE FROM {catalog_keyspace}.indexes WHERE table_id = ?"); self.session .query_with_values( &delete_indexes_query, diff --git a/crates/storage-cassandra/src/lib.rs b/crates/storage-cassandra/src/lib.rs index a5a9284c..18069f8b 100644 --- a/crates/storage-cassandra/src/lib.rs +++ b/crates/storage-cassandra/src/lib.rs @@ -84,9 +84,10 @@ impl ServerRuntimeHooks for CassandraRuntimeHooks { .catalog_store .get_setting("gsi_propagation_delay_ms") .await - && let Ok(ms) = val.parse::() { - gsi_delay.store(ms, std::sync::atomic::Ordering::Relaxed); - } + && let Ok(ms) = val.parse::() + { + gsi_delay.store(ms, std::sync::atomic::Ordering::Relaxed); + } let catalog_store_for_gsi = ctx.catalog_store.clone(); let gsi_delay_poller = tokio::spawn( diff --git a/crates/storage-cassandra/src/management_store/access_keys.rs b/crates/storage-cassandra/src/management_store/access_keys.rs index 6137965f..4121571b 100755 --- a/crates/storage-cassandra/src/management_store/access_keys.rs +++ b/crates/storage-cassandra/src/management_store/access_keys.rs @@ -116,9 +116,8 @@ impl CassandraCatalogStore { } // Delete the key (by PRIMARY KEY only) - let delete_query = format!( - "DELETE FROM {catalog_keyspace}.access_keys WHERE access_key_id = ?" - ); + let delete_query = + format!("DELETE FROM {catalog_keyspace}.access_keys WHERE access_key_id = ?"); crate::cassandra_util::execute( self.session(), @@ -255,7 +254,9 @@ impl CassandraCatalogStore { ); let session_tags_json = session_tags.as_ref().map(std::string::ToString::to_string); - let session_policy_json = session_policy.as_ref().map(std::string::ToString::to_string); + let session_policy_json = session_policy + .as_ref() + .map(std::string::ToString::to_string); let expires_ms = expires_at.unix_timestamp() * 1000 + i64::from(expires_at.millisecond()); crate::cassandra_util::execute( diff --git a/crates/storage-cassandra/src/management_store/accounts.rs b/crates/storage-cassandra/src/management_store/accounts.rs index a050a0ba..7fd2ba38 100644 --- a/crates/storage-cassandra/src/management_store/accounts.rs +++ b/crates/storage-cassandra/src/management_store/accounts.rs @@ -128,27 +128,21 @@ impl CassandraCatalogStore { } crate::cassandra_util::execute::( self.session(), - &format!( - "DELETE FROM {catalog_keyspace}.backups_by_account WHERE account_id = ?" - ), + &format!("DELETE FROM {catalog_keyspace}.backups_by_account WHERE account_id = ?"), cdrs_tokio::query_values!(account_id), "delete_account_backup_index", ) .await?; crate::cassandra_util::execute::( self.session(), - &format!( - "DELETE FROM {catalog_keyspace}.continuous_backups WHERE account_id = ?" - ), + &format!("DELETE FROM {catalog_keyspace}.continuous_backups WHERE account_id = ?"), cdrs_tokio::query_values!(account_id), "delete_account_continuous_backups", ) .await?; // Delete account from catalog - let delete_query = format!( - "DELETE FROM {catalog_keyspace}.accounts WHERE account_id = ?" - ); + let delete_query = format!("DELETE FROM {catalog_keyspace}.accounts WHERE account_id = ?"); crate::cassandra_util::execute( self.session(), @@ -166,9 +160,7 @@ impl CassandraCatalogStore { pub(crate) async fn list_all_accounts_impl(&self) -> OpResult> { let catalog_keyspace = self.catalog_keyspace(); - let query = format!( - "SELECT account_id, account_name FROM {catalog_keyspace}.accounts" - ); + let query = format!("SELECT account_id, account_name FROM {catalog_keyspace}.accounts"); let rows = crate::cassandra_util::query_rows( self.session(), @@ -198,9 +190,8 @@ impl CassandraCatalogStore { &self, ) -> OpResult> { let catalog_keyspace = self.catalog_keyspace(); - let query = format!( - "SELECT account_id, account_name, created_at FROM {catalog_keyspace}.accounts" - ); + let query = + format!("SELECT account_id, account_name, created_at FROM {catalog_keyspace}.accounts"); let rows = crate::cassandra_util::query_rows( self.session(), @@ -264,9 +255,8 @@ impl CassandraCatalogStore { let catalog_keyspace = self.catalog_keyspace(); // Get account name - let account_query = format!( - "SELECT account_name FROM {catalog_keyspace}.accounts WHERE account_id = ?" - ); + let account_query = + format!("SELECT account_name FROM {catalog_keyspace}.accounts WHERE account_id = ?"); let account_row = crate::cassandra_util::query_optional( self.session(), @@ -284,9 +274,8 @@ impl CassandraCatalogStore { crate::cassandra_util::get_column(&row, "account_name", "get_account_detail")?; // Get users - let users_query = format!( - "SELECT user_name FROM {catalog_keyspace}.iam_users WHERE account_id = ?" - ); + let users_query = + format!("SELECT user_name FROM {catalog_keyspace}.iam_users WHERE account_id = ?"); let users_rows = crate::cassandra_util::query_rows( self.session(), @@ -307,9 +296,8 @@ impl CassandraCatalogStore { users.sort(); // Get groups - let groups_query = format!( - "SELECT group_name FROM {catalog_keyspace}.iam_groups WHERE account_id = ?" - ); + let groups_query = + format!("SELECT group_name FROM {catalog_keyspace}.iam_groups WHERE account_id = ?"); let groups_rows = crate::cassandra_util::query_rows( self.session(), @@ -330,9 +318,8 @@ impl CassandraCatalogStore { groups.sort(); // Get roles - let roles_query = format!( - "SELECT role_name FROM {catalog_keyspace}.iam_roles WHERE account_id = ?" - ); + let roles_query = + format!("SELECT role_name FROM {catalog_keyspace}.iam_roles WHERE account_id = ?"); let roles_rows = crate::cassandra_util::query_rows( self.session(), diff --git a/crates/storage-cassandra/src/management_store/groups.rs b/crates/storage-cassandra/src/management_store/groups.rs index 4a79bdd0..b888752f 100644 --- a/crates/storage-cassandra/src/management_store/groups.rs +++ b/crates/storage-cassandra/src/management_store/groups.rs @@ -160,9 +160,8 @@ impl CassandraCatalogStore { policies.sort(); // Get all users in account - let all_users_query = format!( - "SELECT user_name FROM {catalog_keyspace}.iam_users WHERE account_id = ?" - ); + let all_users_query = + format!("SELECT user_name FROM {catalog_keyspace}.iam_users WHERE account_id = ?"); let all_users_rows = crate::cassandra_util::query_rows( self.session(), diff --git a/crates/storage-cassandra/src/metadata_engine.rs b/crates/storage-cassandra/src/metadata_engine.rs index 8fb5e90c..fa6c0c09 100755 --- a/crates/storage-cassandra/src/metadata_engine.rs +++ b/crates/storage-cassandra/src/metadata_engine.rs @@ -67,9 +67,8 @@ impl MetadataEngine for CassandraEngine { let catalog = self.catalog_keyspace(); Box::pin(async move { for key in &tag_keys { - let query = format!( - "DELETE FROM {catalog}.tags WHERE resource_arn = ? AND tag_key = ?" - ); + let query = + format!("DELETE FROM {catalog}.tags WHERE resource_arn = ? AND tag_key = ?"); self.session_arc() .query_with_values( &query, @@ -89,9 +88,8 @@ impl MetadataEngine for CassandraEngine { let arn = arn.to_string(); let catalog = self.catalog_keyspace(); Box::pin(async move { - let query = format!( - "SELECT tag_key, tag_value FROM {catalog}.tags WHERE resource_arn = ?" - ); + let query = + format!("SELECT tag_key, tag_value FROM {catalog}.tags WHERE resource_arn = ?"); let result = self .session_arc() .query_with_values(&query, cdrs_tokio::query_values!(arn.as_str())) diff --git a/crates/storage-cassandra/src/migrations.rs b/crates/storage-cassandra/src/migrations.rs index 156b5e34..fee1dd19 100644 --- a/crates/storage-cassandra/src/migrations.rs +++ b/crates/storage-cassandra/src/migrations.rs @@ -184,9 +184,7 @@ async fn is_migration_applied( .and_then(|s| s.parse().ok()) .ok_or_else(|| OpError::Internal(format!("Invalid migration filename: {filename}")))?; - let check_cql = format!( - "SELECT version FROM {keyspace}.schema_history WHERE version = ?" - ); + let check_cql = format!("SELECT version FROM {keyspace}.schema_history WHERE version = ?"); let applied = session .query_with_values(check_cql, cdrs_tokio::query_values!(version)) diff --git a/crates/storage-cassandra/src/operations.rs b/crates/storage-cassandra/src/operations.rs index 17e3c25b..724a45a5 100644 --- a/crates/storage-cassandra/src/operations.rs +++ b/crates/storage-cassandra/src/operations.rs @@ -72,11 +72,12 @@ impl OperationsEngine for CassandraOperationsEngine { // Check first character (cannot be digit) if let Some(first_char) = name.chars().next() - && first_char.is_ascii_digit() { - return Err(StorageError::Internal(format!( - "{label} '{name}' cannot start with a digit" - ))); - } + && first_char.is_ascii_digit() + { + return Err(StorageError::Internal(format!( + "{label} '{name}' cannot start with a digit" + ))); + } // Check all characters (alphanumeric + underscore only) if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { diff --git a/crates/storage-cassandra/src/stream_engine.rs b/crates/storage-cassandra/src/stream_engine.rs index 8e452327..88077488 100755 --- a/crates/storage-cassandra/src/stream_engine.rs +++ b/crates/storage-cassandra/src/stream_engine.rs @@ -28,9 +28,12 @@ impl CassandraEngine { let without_prefix = shard_id.strip_prefix("shardId-").ok_or_else(|| { StorageError::Internal(format!("Invalid shard_id format: {shard_id}")) })?; - let table_id = without_prefix.rsplit_once('-').map(|x| x.0).ok_or_else(|| { - StorageError::Internal(format!("Invalid shard_id format: {shard_id}")) - })?; + let table_id = without_prefix + .rsplit_once('-') + .map(|x| x.0) + .ok_or_else(|| { + StorageError::Internal(format!("Invalid shard_id format: {shard_id}")) + })?; let catalog_keyspace = self.catalog_keyspace(); let query = format!("SELECT account_id FROM {catalog_keyspace}.tables WHERE table_id = ?"); @@ -69,7 +72,9 @@ impl CassandraEngine { let without_prefix = shard_id .strip_prefix("shardId-") .ok_or_else(|| StorageError::Validation("Invalid ShardIterator".to_owned()))?; - let table_id = without_prefix.rsplit_once('-').map(|x| x.0) + let table_id = without_prefix + .rsplit_once('-') + .map(|x| x.0) .ok_or_else(|| StorageError::Validation("Invalid ShardIterator".to_owned()))?; let catalog_keyspace = self.catalog_keyspace(); @@ -400,9 +405,10 @@ impl StreamEngine for CassandraEngine { let label: Option = row.get_by_name("stream_label").ok().flatten(); let label = label?; // skip tables without streams if let Some(ref filter_tn) = table_name - && &tn != filter_tn { - return None; - } + && &tn != filter_tn + { + return None; + } Some(StreamSummary { stream_arn: stream_arn(&self.region, &account_id, &tn, &label), stream_label: label, diff --git a/crates/storage-cassandra/src/stream_util.rs b/crates/storage-cassandra/src/stream_util.rs index a7c3e7c2..b68b6368 100644 --- a/crates/storage-cassandra/src/stream_util.rs +++ b/crates/storage-cassandra/src/stream_util.rs @@ -181,7 +181,10 @@ pub fn stream_record_statement( .unwrap_or_default(); let shard_id = assign_shard_id(&pk_str, table_id); - let sequence_number = hlc.lock().unwrap_or_else(std::sync::PoisonError::into_inner).generate(); + let sequence_number = hlc + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .generate(); let record = StreamRecord { event_id: uuid::Uuid::new_v4().to_string(), diff --git a/crates/storage-cassandra/src/table_helpers.rs b/crates/storage-cassandra/src/table_helpers.rs index d8d0de5a..2f46debf 100644 --- a/crates/storage-cassandra/src/table_helpers.rs +++ b/crates/storage-cassandra/src/table_helpers.rs @@ -58,9 +58,7 @@ impl CassandraEngine { .map_err(|e| StorageError::Internal(format!("Parse table_id: {e}")))?; // Query indexes - let index_query = format!( - "SELECT * FROM {catalog_keyspace}.indexes WHERE table_id = ?" - ); + let index_query = format!("SELECT * FROM {catalog_keyspace}.indexes WHERE table_id = ?"); let index_result = self .session diff --git a/crates/storage-cassandra/src/update_table.rs b/crates/storage-cassandra/src/update_table.rs index 033ebbe8..8399b16c 100644 --- a/crates/storage-cassandra/src/update_table.rs +++ b/crates/storage-cassandra/src/update_table.rs @@ -173,11 +173,10 @@ impl CassandraEngine { "update_table stream_label check", ) .await?; - let has_label = label_row - .is_some_and(|r| { - let v: Option = r.get_by_name("stream_label").ok().flatten(); - v.is_some() - }); + let has_label = label_row.is_some_and(|r| { + let v: Option = r.get_by_name("stream_label").ok().flatten(); + v.is_some() + }); if !has_label { needs_label_restore = true; let label = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S").to_string(); diff --git a/crates/storage-cassandra/src/worker_store.rs b/crates/storage-cassandra/src/worker_store.rs index d5624328..210ed8d8 100755 --- a/crates/storage-cassandra/src/worker_store.rs +++ b/crates/storage-cassandra/src/worker_store.rs @@ -52,9 +52,10 @@ impl CassandraEngine { ALLOW FILTERING" ); - let result = self.session.query(&query).await.map_err(|e| { - StorageError::Internal(format!("Failed to query CREATING tables: {e}")) - })?; + let result = + self.session.query(&query).await.map_err(|e| { + StorageError::Internal(format!("Failed to query CREATING tables: {e}")) + })?; let body = result.response_body().map_err(|e| { StorageError::Internal(format!("Failed to parse CREATING tables response: {e}")) @@ -63,12 +64,12 @@ impl CassandraEngine { let rows = body.into_rows().unwrap_or_default(); for row in rows { - let account_id: String = row.get_r_by_name("account_id").map_err(|e| { - StorageError::Internal(format!("Failed to parse account_id: {e}")) - })?; - let table_name: String = row.get_r_by_name("table_name").map_err(|e| { - StorageError::Internal(format!("Failed to parse table_name: {e}")) - })?; + let account_id: String = row + .get_r_by_name("account_id") + .map_err(|e| StorageError::Internal(format!("Failed to parse account_id: {e}")))?; + let table_name: String = row + .get_r_by_name("table_name") + .map_err(|e| StorageError::Internal(format!("Failed to parse table_name: {e}")))?; // Update to ACTIVE (PRIMARY KEY is account_id, table_name) let update = format!( @@ -93,9 +94,10 @@ impl CassandraEngine { ALLOW FILTERING" ); - let result = self.session.query(&query).await.map_err(|e| { - StorageError::Internal(format!("Failed to query DELETING tables: {e}")) - })?; + let result = + self.session.query(&query).await.map_err(|e| { + StorageError::Internal(format!("Failed to query DELETING tables: {e}")) + })?; let body = result.response_body().map_err(|e| { StorageError::Internal(format!("Failed to parse DELETING tables response: {e}")) @@ -104,12 +106,12 @@ impl CassandraEngine { let rows = body.into_rows().unwrap_or_default(); for row in rows { - let account_id: String = row.get_r_by_name("account_id").map_err(|e| { - StorageError::Internal(format!("Failed to parse account_id: {e}")) - })?; - let table_name: String = row.get_r_by_name("table_name").map_err(|e| { - StorageError::Internal(format!("Failed to parse table_name: {e}")) - })?; + let account_id: String = row + .get_r_by_name("account_id") + .map_err(|e| StorageError::Internal(format!("Failed to parse account_id: {e}")))?; + let table_name: String = row + .get_r_by_name("table_name") + .map_err(|e| StorageError::Internal(format!("Failed to parse table_name: {e}")))?; let table_arn: String = row .get_r_by_name("table_arn") .map_err(|e| StorageError::Internal(format!("Failed to parse table_arn: {e}")))?; @@ -118,9 +120,7 @@ impl CassandraEngine { .map_err(|e| StorageError::Internal(format!("Failed to parse table_id: {e}")))?; // Delete tags - let tag_delete = format!( - "DELETE FROM {catalog_keyspace}.tags WHERE resource_arn = ?" - ); + let tag_delete = format!("DELETE FROM {catalog_keyspace}.tags WHERE resource_arn = ?"); self.session .query_with_values(&tag_delete, cdrs_tokio::query_values!(table_arn.as_str())) .await diff --git a/crates/storage-cassandra/src/workers.rs b/crates/storage-cassandra/src/workers.rs index 7ddb11eb..4c267cfa 100644 --- a/crates/storage-cassandra/src/workers.rs +++ b/crates/storage-cassandra/src/workers.rs @@ -157,9 +157,10 @@ async fn list_account_keyspaces( for row in rows { let name: Result = row.get_r_by_name("keyspace_name"); if let Ok(name) = name - && name.starts_with(&prefix) { - keyspaces.push(name); - } + && name.starts_with(&prefix) + { + keyspaces.push(name); + } } Ok(keyspaces) } @@ -380,39 +381,41 @@ async fn gsi_apply_index( let mut batch = BatchQueryBuilder::new().with_consistency(Consistency::LocalQuorum); if let Some(old) = old_item - && item_has_index_keys(old, &idx.key_schema) { - delete_index_row_multi( - &mut batch, - account_keyspace, - &idx_table, - old, - &idx.key_schema, - &context.base_key_schema, - &idx_sks, - &base_sks, - )?; - } + && item_has_index_keys(old, &idx.key_schema) + { + delete_index_row_multi( + &mut batch, + account_keyspace, + &idx_table, + old, + &idx.key_schema, + &context.base_key_schema, + &idx_sks, + &base_sks, + )?; + } if let Some(new) = new_item - && item_has_index_keys(new, &idx.key_schema) { - let projected = project_item_for_index( - new, - &idx.key_schema, - &context.base_key_schema, - &idx.projection, - ); - insert_index_row_multi( - &mut batch, - account_keyspace, - &idx_table, - new, - &projected, - &idx.key_schema, - &context.base_key_schema, - &idx_sks, - &base_sks, - )?; - } + && item_has_index_keys(new, &idx.key_schema) + { + let projected = project_item_for_index( + new, + &idx.key_schema, + &context.base_key_schema, + &idx.projection, + ); + insert_index_row_multi( + &mut batch, + account_keyspace, + &idx_table, + new, + &projected, + &idx.key_schema, + &context.base_key_schema, + &idx_sks, + &base_sks, + )?; + } // Only execute if there's something to do. let built = batch From ce84e90b4b48024eb23e0019a49daf582bede845 Mon Sep 17 00:00:00 2001 From: Joel Shepherd Date: Tue, 1 Sep 2026 23:06:37 +0000 Subject: [PATCH 10/48] fix: SSE and table class attributes, linearization of concurrent writes on the same key SSE and table class attributes were defaulting to None: now they are plumbed through. Concurrent writes to the same key are now linearized by IF NOT EXISTS for inserts, and item-level version numbers for updates. This uses Cassandra's lightweight transactions (LWTs) which are Paxos-based and require two round-trips per write, adding roughly ~2-3ms/write without contention. With contention, short backoffs may add up to 200ms before either writing successfully, or failing the write back to the caller. --- .../catalog/V001__initial_schema.cql | 1 + crates/storage-cassandra/src/create_table.rs | 57 +- crates/storage-cassandra/src/data/ddl.rs | 3 + .../src/data/put_get_item.rs | 198 ++++++ .../src/data/transactions.rs | 11 +- .../storage-cassandra/src/data/update_item.rs | 611 ++++++++++-------- crates/storage-cassandra/src/table_helpers.rs | 26 +- 7 files changed, 621 insertions(+), 286 deletions(-) diff --git a/crates/storage-cassandra/migrations/catalog/V001__initial_schema.cql b/crates/storage-cassandra/migrations/catalog/V001__initial_schema.cql index 9c128fde..065f0d2d 100644 --- a/crates/storage-cassandra/migrations/catalog/V001__initial_schema.cql +++ b/crates/storage-cassandra/migrations/catalog/V001__initial_schema.cql @@ -36,6 +36,7 @@ CREATE TABLE IF NOT EXISTS tables ( provisioned_throughput text, on_demand_throughput text, table_class text, + sse_specification text, stream_specification text, table_size_bytes bigint, item_count bigint, diff --git a/crates/storage-cassandra/src/create_table.rs b/crates/storage-cassandra/src/create_table.rs index 9fc0fbcf..c5e136fd 100644 --- a/crates/storage-cassandra/src/create_table.rs +++ b/crates/storage-cassandra/src/create_table.rs @@ -6,7 +6,7 @@ use cdrs_tokio::types::value::Value; use extenddb_core::types::{ BillingMode, BillingModeSummary, CreateTableInput, GsiDescription, LsiDescription, - ProvisionedThroughputDescription, TableDescription, TableStatus, + ProvisionedThroughputDescription, SseDescription, SseType, TableDescription, TableStatus, }; use extenddb_storage::error::StorageError; use extenddb_storage::util::{index_arn, stream_arn, table_arn}; @@ -119,6 +119,20 @@ impl CassandraEngine { let deletion_protection = input.deletion_protection_enabled.unwrap_or(false); + let table_class_str = input.table_class.clone(); + let sse_json = input + .sse_specification + .as_ref() + .map(serde_json::to_value) + .transpose() + .map_err(|e| StorageError::Internal(e.to_string()))?; + let on_demand_json = input + .on_demand_throughput + .as_ref() + .map(serde_json::to_value) + .transpose() + .map_err(|e| StorageError::Internal(e.to_string()))?; + // Read control_plane_delay_seconds from settings let catalog_keyspace = self.catalog_keyspace(); let delay_query = format!("SELECT value FROM {catalog_keyspace}.settings WHERE key = ?"); @@ -167,8 +181,9 @@ impl CassandraEngine { let insert_table_cql = format!( "INSERT INTO {catalog_keyspace}.tables (account_id, table_name, table_id, table_arn, key_schema, \ attribute_definitions, billing_mode, provisioned_throughput, stream_specification, \ + table_class, sse_specification, on_demand_throughput, \ table_status, created_at, deletion_protection_enabled, status_transition_at) \ - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) IF NOT EXISTS" + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) IF NOT EXISTS" ); let result = self @@ -191,6 +206,18 @@ impl CassandraEngine { Some(v) => Value::from(v.to_string().as_str()), None => Value::NotSet, }, + match table_class_str.as_deref() { + Some(v) => Value::from(v), + None => Value::NotSet, + }, + match sse_json.as_ref() { + Some(v) => Value::from(v.to_string().as_str()), + None => Value::NotSet, + }, + match on_demand_json.as_ref() { + Some(v) => Value::from(v.to_string().as_str()), + None => Value::NotSet, + }, Value::from(initial_status), Value::from(creation_timestamp), Value::from(deletion_protection), @@ -498,9 +525,29 @@ impl CassandraEngine { latest_stream_arn, latest_stream_label: stream_label, deletion_protection_enabled: input.deletion_protection_enabled.unwrap_or(false), - sse_description: None, - table_class_summary: None, - on_demand_throughput: None, + sse_description: input.sse_specification.as_ref().and_then(|spec| { + let enabled = spec + .get("Enabled") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + if enabled { + Some(SseDescription { + status: "ENABLED".to_string(), + sse_type: Some(SseType::KMS), + kms_master_key_arn: Some(format!( + "arn:aws:kms:{}:{}:key/default", + self.region, account_id + )), + }) + } else { + None + } + }), + table_class_summary: input + .table_class + .as_ref() + .map(|tc| serde_json::json!({ "TableClass": tc })), + on_demand_throughput: input.on_demand_throughput, restore_summary: None, vector_indexes: None, }) diff --git a/crates/storage-cassandra/src/data/ddl.rs b/crates/storage-cassandra/src/data/ddl.rs index d1e18c03..b4ef5158 100644 --- a/crates/storage-cassandra/src/data/ddl.rs +++ b/crates/storage-cassandra/src/data/ddl.rs @@ -183,6 +183,7 @@ impl CassandraEngine { pk text PRIMARY KEY, \ partition_max_delete_timestamp bigint, \ item_data text, \ + version bigint, \ prepared_txn_id uuid, \ prepared_txn_timestamp bigint, \ last_committed_txn_timestamp bigint, \ @@ -200,6 +201,7 @@ impl CassandraEngine { sk_n decimal, \ sk_b blob, \ item_data text, \ + version bigint, \ prepared_txn_id uuid, \ prepared_txn_timestamp bigint, \ last_committed_txn_timestamp bigint, \ @@ -229,6 +231,7 @@ impl CassandraEngine { } col_defs.push("partition_max_delete_timestamp bigint STATIC".to_owned()); col_defs.push("item_data text".to_owned()); + col_defs.push("version bigint".to_owned()); col_defs.push("prepared_txn_id uuid".to_owned()); col_defs.push("prepared_txn_timestamp bigint".to_owned()); col_defs.push("last_committed_txn_timestamp bigint".to_owned()); diff --git a/crates/storage-cassandra/src/data/put_get_item.rs b/crates/storage-cassandra/src/data/put_get_item.rs index c3a2c771..fa902dcf 100644 --- a/crates/storage-cassandra/src/data/put_get_item.rs +++ b/crates/storage-cassandra/src/data/put_get_item.rs @@ -54,6 +54,32 @@ impl CassandraEngine { // Always use read-then-write path to check prepared_txn_id for transaction safety. // This ensures non-transactional writes cannot corrupt in-flight transactions. + // + // Exception: attribute_not_exists() maps directly to INSERT IF NOT EXISTS, + // which is atomic without a prior read. + + // Collect key attribute names for attribute_not_exists detection. + let key_attr_names: Vec<&str> = key_info + .key_schema + .iter() + .map(|k| k.attribute_name.as_str()) + .collect(); + + if is_attribute_not_exists_key(condition, &key_attr_names) { + return self + .put_item_if_not_exists( + key_info, + item, + stream, + &data_keyspace, + &ddb_table, + &pk_text, + &item_text, + &indexes, + sys_delay, + ) + .await; + } if let Some((sk_name, sk_type)) = sk_info(&key_info.key_schema, &key_info.attribute_definitions) @@ -361,6 +387,150 @@ impl CassandraEngine { } } + /// Atomic `put_item` for the `attribute_not_exists()` condition. + /// + /// Uses `INSERT ... IF NOT EXISTS` — no prior read needed. Returns + /// `ConditionFailed` (with no old item, since the item didn't exist) if + /// another writer got there first. + #[allow(clippy::too_many_arguments)] + async fn put_item_if_not_exists( + &self, + key_info: &TableKeyInfo, + item: Item, + stream: Option<&StreamCapture>, + data_keyspace: &str, + ddb_table: &str, + pk_text: &str, + item_text: &str, + indexes: &[super::index::IndexMeta], + sys_delay: u64, + ) -> Result, StorageError> { + use cdrs_tokio::types::IntoRustByName as _; + + let (insert_cql, insert_qv) = if let Some((sk_name, sk_type)) = + sk_info(&key_info.key_schema, &key_info.attribute_definitions) + { + let sk_value = item + .get(sk_name) + .ok_or_else(|| StorageError::Internal("missing sort key".to_owned()))?; + let sk = parse_sk(sk_value, sk_type)?; + let sk_col = sk_column(sk_type); + let cql = format!( + "INSERT INTO {data_keyspace}.{ddb_table} (pk, {sk_col}, item_data, version) \ + VALUES (?, ?, ?, 1) IF NOT EXISTS" + ); + let qv = cdrs_tokio::query::QueryValues::SimpleValues(vec![ + cdrs_tokio::types::value::Value::from(pk_text), + super::index::sk_to_value(&sk), + item_text.into(), + ]); + (cql, qv) + } else { + let cql = format!( + "INSERT INTO {data_keyspace}.{ddb_table} (pk, item_data, version) \ + VALUES (?, ?, 1) IF NOT EXISTS" + ); + let qv = cdrs_tokio::query::QueryValues::SimpleValues(vec![ + cdrs_tokio::types::value::Value::from(pk_text), + item_text.into(), + ]); + (cql, qv) + }; + + let result = self + .session + .query_with_values(&insert_cql, insert_qv) + .await + .map_err(|e| StorageError::Internal(format!("put_item_if_not_exists: {e}")))?; + + let body = result + .response_body() + .map_err(|e| StorageError::Internal(format!("put_item_if_not_exists: {e}")))?; + + let rows = body.into_rows().unwrap_or_default(); + let row = rows.into_iter().next(); + + let applied: bool = row + .as_ref() + .and_then(|r| r.get_r_by_name("[applied]").ok()) + .unwrap_or(true); + + if !applied { + // The LWT response includes the existing row — parse item_data from it. + let existing = row + .as_ref() + .and_then(|r| { + use cdrs_tokio::types::IntoRustByName as _; + r.get_r_by_name("item_data").ok() + }) + .and_then(|s: String| json_to_item(s).ok()); + return Err(StorageError::ConditionFailed(existing)); + } + + // INSERT applied — fire index/stream updates if needed. + if !indexes.is_empty() || stream.is_some() { + let stream_stmt = stream.and_then(|cap| { + stream_record_statement( + data_keyspace, + &key_info.table_id, + key_info, + None, + Some(&item), + cap, + &self.hlc, + self.stream_retention_seconds, + ) + }); + + let mut batch = BatchQueryBuilder::new().with_consistency(Consistency::LocalQuorum); + + if !indexes.is_empty() { + super::index::sync_indexes( + &mut batch, + data_keyspace, + &key_info.key_schema, + &key_info.attribute_definitions, + indexes, + None, + Some(&item), + sys_delay, + )?; + } + + let async_enqueued = if !indexes.is_empty() { + super::index::enqueue_async_indexes( + &self.session, + &mut batch, + data_keyspace, + key_info, + indexes, + None, + Some(&item), + sys_delay, + ) + .await? + } else { + 0 + }; + + if let Some(stmt) = stream_stmt { + batch = batch.add_query(stmt, cdrs_tokio::query::QueryValues::SimpleValues(vec![])); + } + + if let Ok(built) = batch.build() { + self.session.batch(built).await.map_err(|e| { + StorageError::Internal(format!("put_item_if_not_exists batch: {e}")) + })?; + } + + if async_enqueued > 0 { + self.gsi_queue.notify_workers(); + } + } + + Ok(None) // return_old is always None — item didn't exist + } + /// Implementation of `DataEngine::get_item`. pub(crate) async fn get_item_impl( &self, @@ -433,3 +603,31 @@ impl CassandraEngine { } } } + +/// Returns `true` if `condition` is exactly `attribute_not_exists()` where +/// `` resolves to one of the names in `key_attr_names` after applying +/// expression name substitutions from `maps`. +/// +/// This is the only condition we can map directly to `INSERT ... IF NOT EXISTS`, +/// which is atomic without a prior read. +pub(crate) fn is_attribute_not_exists_key( + condition: Option<&Expr>, + key_attr_names: &[&str], +) -> bool { + let Some(Expr::Function { name, args }) = condition else { + return false; + }; + if name != "attribute_not_exists" || args.len() != 1 { + return false; + } + let Expr::Path(path) = &args[0] else { + return false; + }; + if path.len() != 1 { + return false; + } + let extenddb_core::expression::PathElement::Attribute(attr) = &path[0] else { + return false; + }; + key_attr_names.contains(&attr.as_str()) +} diff --git a/crates/storage-cassandra/src/data/transactions.rs b/crates/storage-cassandra/src/data/transactions.rs index b0c40a86..0a65e60a 100644 --- a/crates/storage-cassandra/src/data/transactions.rs +++ b/crates/storage-cassandra/src/data/transactions.rs @@ -1086,13 +1086,13 @@ impl CassandraEngine { let sk_col = sk_column(sk_type); let query = format!( - "UPDATE {keyspace}.{ddb_table} SET item_data = ?, prepared_txn_id = NULL, last_committed_txn_timestamp = ? \ + "UPDATE {keyspace}.{ddb_table} SET item_data = ?, prepared_txn_id = NULL, version = version + 1, last_committed_txn_timestamp = ? \ WHERE pk = ? AND {sk_col} = ? IF prepared_txn_id = ?" ); query_with_item_ts_pk_sk_txnid(&self.session, &query, &item_text, txn_timestamp, pk_text.as_str(), &sk, txn_id_bytes).await } else { let query = format!( - "UPDATE {keyspace}.{ddb_table} SET item_data = ?, prepared_txn_id = NULL, last_committed_txn_timestamp = ? \ + "UPDATE {keyspace}.{ddb_table} SET item_data = ?, prepared_txn_id = NULL, version = version + 1, last_committed_txn_timestamp = ? \ WHERE pk = ? IF prepared_txn_id = ?" ); self.session @@ -1286,8 +1286,8 @@ impl CassandraEngine { let result = if let Some((sk_val, sk_col)) = &sk { let query = format!( "UPDATE {keyspace}.{table} SET item_data = ?, prepared_txn_id = NULL, \ - last_committed_txn_timestamp = ? WHERE pk = ? AND {sk_col} = ? \ - IF prepared_txn_id = ?", + version = version + 1, last_committed_txn_timestamp = ? \ + WHERE pk = ? AND {sk_col} = ? IF prepared_txn_id = ?", ); query_with_item_ts_pk_sk_txnid( &self.session, @@ -1302,7 +1302,8 @@ impl CassandraEngine { } else { let query = format!( "UPDATE {keyspace}.{table} SET item_data = ?, prepared_txn_id = NULL, \ - last_committed_txn_timestamp = ? WHERE pk = ? IF prepared_txn_id = ?", + version = version + 1, last_committed_txn_timestamp = ? \ + WHERE pk = ? IF prepared_txn_id = ?", ); self.session .query_with_values( diff --git a/crates/storage-cassandra/src/data/update_item.rs b/crates/storage-cassandra/src/data/update_item.rs index d32d85c1..b07fbafc 100644 --- a/crates/storage-cassandra/src/data/update_item.rs +++ b/crates/storage-cassandra/src/data/update_item.rs @@ -5,8 +5,6 @@ use cdrs_tokio::consistency::Consistency; use cdrs_tokio::query::BatchQueryBuilder; -use cdrs_tokio::query_values; -use cdrs_tokio::types::IntoRustByName; use extenddb_core::expression::{self, Expr, ExpressionMaps, UpdateAction}; use extenddb_core::types::{Item, KeyType, TableKeyInfo}; use extenddb_core::validation; @@ -15,13 +13,20 @@ use extenddb_storage::error::StorageError; use extenddb_storage::util::{parse_sk, pk_to_text, sk_column}; use super::ddl::data_table_name; -use super::{json_to_item, query_with_pk_sk, query_with_pk_sk_item}; +use super::{json_to_item, query_with_pk_sk}; use crate::CassandraEngine; use crate::stream_util::stream_record_statement; // 400 KB limit from DynamoDB specification const MAX_ITEM_SIZE_BYTES: usize = 400 * 1024; +/// Maximum OCC retry attempts before giving up. +const OCC_MAX_RETRIES: u32 = 20; +/// Base sleep for OCC backoff in milliseconds. +const OCC_BASE_DELAY_MS: u64 = 2; +/// Exponent cap for OCC backoff (max sleep = base * 2^cap = 16ms). +const OCC_EXP_CAP: u32 = 3; + impl CassandraEngine { /// Implementation of `DataEngine::update_item`. #[allow(clippy::too_many_arguments)] @@ -59,8 +64,8 @@ impl CassandraEngine { .load(std::sync::atomic::Ordering::Relaxed) }; - // Fetch existing item (including prepared_txn_id for transaction conflict detection) - let old_json = if let Some(sk_elem) = key_info + // Resolve sort key once — used in every iteration of the OCC loop. + let (sk_opt, sk_col_opt) = if let Some(sk_elem) = key_info .key_schema .iter() .find(|k| k.key_type == KeyType::Range) @@ -75,305 +80,363 @@ impl CassandraEngine { .find(|ad| ad.attribute_name == *sk_name) .ok_or_else(|| StorageError::Internal("sort key type not found".to_owned()))? .attribute_type; - let sk = parse_sk(sk_value, sk_type)?; - let sk_col = sk_column(sk_type); + (Some(parse_sk(sk_value, sk_type)?), Some(sk_column(sk_type))) + } else { + (None, None) + }; - let select_query = format!( - "SELECT item_data, prepared_txn_id FROM {data_keyspace}.{ddb_table} WHERE pk = ? AND {sk_col} = ?" - ); + for attempt in 0..=OCC_MAX_RETRIES { + // --- READ --- + let (old_json, version) = self + .occ_read( + &data_keyspace, + &ddb_table, + &pk_text, + sk_opt.as_ref(), + sk_col_opt, + ) + .await?; - let result = query_with_pk_sk(&self.session, &select_query, &pk_text, &sk).await?; + let item_existed = old_json.is_some(); + let mut item = old_json.clone().unwrap_or_else(|| key.clone()); - let body = result - .response_body() - .map_err(|e| StorageError::Internal(format!("Parse response: {e}")))?; - - let rows = body.into_rows().unwrap_or_default(); - if let Some(row) = rows.first() { - // Check for in-flight transaction - let prepared_txn_id: Option = - row.get_by_name("prepared_txn_id").ok().flatten(); - if prepared_txn_id.is_some() { - return Err(StorageError::TransactionCanceled(vec![ - extenddb_core::types::CancellationReason { - code: "TransactionConflict".to_owned(), - message: Some( - "Item is being modified by a concurrent transaction".to_owned(), - ), - item: None, - }, - ])); - } - let item_data: String = - crate::cassandra_util::get_column(row, "item_data", "update_item")?; - Some(json_to_item(item_data)?) + // Evaluate condition + let condition_item = if item_existed { + &item } else { - None + &std::collections::BTreeMap::new() + }; + match super::check_condition(condition, condition_item, maps) { + Ok(()) => {} + Err(StorageError::ConditionFailed(_)) => { + return Err(StorageError::ConditionFailed(if item_existed { + Some(item) + } else { + None + })); + } + Err(e) => return Err(e), } - } else { - let select_query = format!( - "SELECT item_data, prepared_txn_id FROM {data_keyspace}.{ddb_table} WHERE pk = ?" - ); - let row = crate::cassandra_util::query_optional( - &self.session, - &select_query, - query_values!(pk_text.as_ref() as &str), - "update_item", - ) - .await?; - - if let Some(row) = row { - // Check for in-flight transaction - let prepared_txn_id: Option = - row.get_by_name("prepared_txn_id").ok().flatten(); - if prepared_txn_id.is_some() { - return Err(StorageError::TransactionCanceled(vec![ - extenddb_core::types::CancellationReason { - code: "TransactionConflict".to_owned(), - message: Some( - "Item is being modified by a concurrent transaction".to_owned(), - ), - item: None, - }, - ])); - } - let item_data: String = - crate::cassandra_util::get_column(&row, "item_data", "update_item")?; - Some(json_to_item(item_data)?) + let old_item = if return_old { Some(item.clone()) } else { None }; + let pre_mutation_item = if (!indexes.is_empty() || stream.is_some()) && item_existed { + Some(item.clone()) } else { None - } - }; + }; - // Build working item: existing or new with key attributes only (upsert) - let item_existed = old_json.is_some(); - let mut item = if let Some(item) = old_json { - item - } else { - key.clone() - }; + // Apply update actions + expression::apply_update_validated(actions, &mut item, maps, &[], &[]) + .map_err(|e| StorageError::Validation(e.to_string()))?; + validation::validate_item_size(&item, MAX_ITEM_SIZE_BYTES) + .map_err(|e| StorageError::Validation(e.to_string()))?; - // Only capture pre-mutation item when the item already existed; for upserts - // (item_existed == false) there is no old image to record. - let pre_mutation_item = if (!indexes.is_empty() || stream.is_some()) && item_existed { - Some(item.clone()) - } else { - None - }; - let old_item = if return_old { Some(item.clone()) } else { None }; + let new_item = if return_new { Some(item.clone()) } else { None }; - // Evaluate condition against the existing item (empty if non-existent) - // DynamoDB treats a non-existent item as having no attributes - let condition_item = if item_existed { - &item - } else { - &std::collections::BTreeMap::new() - }; - match super::check_condition(condition, condition_item, maps) { - Ok(()) => {} - Err(StorageError::ConditionFailed(_)) => { - if item_existed { - return Err(StorageError::ConditionFailed(Some(item))); - } - return Err(StorageError::ConditionFailed(None)); + let item_json = + serde_json::to_value(&item).map_err(|e| StorageError::Internal(e.to_string()))?; + let item_json_str = item_json.to_string(); + + // --- WRITE (with OCC guard) --- + let applied = self + .occ_write( + &data_keyspace, + &ddb_table, + &pk_text, + sk_opt.as_ref(), + sk_col_opt, + &item_json_str, + version, + item_existed, + &indexes, + key_info, + pre_mutation_item.as_ref(), + &item, + sys_delay, + stream, + ) + .await?; + + if applied { + return Ok((old_item, new_item)); + } + + // Lost the race — back off and retry. + if attempt < OCC_MAX_RETRIES { + let window_ms = OCC_BASE_DELAY_MS * (1u64 << attempt.min(OCC_EXP_CAP)); + let sleep_ms = rand::random::() % window_ms.max(1); + tokio::time::sleep(std::time::Duration::from_millis(sleep_ms)).await; } - Err(e) => return Err(e), } - // Apply update actions - expression::apply_update_validated(actions, &mut item, maps, &[], &[]) - .map_err(|e| StorageError::Validation(e.to_string()))?; + Err(StorageError::Internal( + "update_item: too many concurrent writers on this item".to_owned(), + )) + } - // Validate item size (400 KB limit) - validation::validate_item_size(&item, MAX_ITEM_SIZE_BYTES) - .map_err(|e| StorageError::Validation(e.to_string()))?; + /// Read `item_data`, `version`, and `prepared_txn_id` for OCC. + /// + /// Returns `(existing_item, version)`. `version` is `None` for rows that + /// pre-date the OCC column (treated as version 0 — `IF version = null`). + /// + /// Returns `TransactionConflict` if `prepared_txn_id` is set. + async fn occ_read( + &self, + data_keyspace: &str, + ddb_table: &str, + pk_text: &str, + sk: Option<&extenddb_storage::util::SortKeyValue>, + sk_col: Option<&'static str>, + ) -> Result<(Option, Option), StorageError> { + use cdrs_tokio::types::IntoRustByName as _; + + let row_opt = if let (Some(sk), Some(sk_col)) = (sk, sk_col) { + let q = format!( + "SELECT item_data, version, prepared_txn_id FROM {data_keyspace}.{ddb_table} WHERE pk = ? AND {sk_col} = ?" + ); + let result = query_with_pk_sk(&self.session, &q, pk_text, sk).await?; + result + .response_body() + .map_err(|e| StorageError::Internal(format!("occ_read response_body: {e}")))? + .into_rows() + .unwrap_or_default() + .into_iter() + .next() + } else { + let q = format!( + "SELECT item_data, version, prepared_txn_id FROM {data_keyspace}.{ddb_table} WHERE pk = ?" + ); + crate::cassandra_util::query_optional( + &self.session, + &q, + cdrs_tokio::query_values!(pk_text), + "occ_read", + ) + .await? + }; - let new_item = if return_new { Some(item.clone()) } else { None }; + let Some(row) = row_opt else { + return Ok((None, None)); + }; - // Write the updated item back - let item_json = - serde_json::to_value(&item).map_err(|e| StorageError::Internal(e.to_string()))?; - let item_json_str = item_json.to_string(); + let prepared_txn_id: Option = row.get_by_name("prepared_txn_id").ok().flatten(); + if prepared_txn_id.is_some() { + return Err(StorageError::TransactionCanceled(vec![ + extenddb_core::types::CancellationReason { + code: "TransactionConflict".to_owned(), + message: Some("Item is being modified by a concurrent transaction".to_owned()), + item: None, + }, + ])); + } - if let Some(sk_elem) = key_info - .key_schema - .iter() - .find(|k| k.key_type == KeyType::Range) - { - let sk_name = &sk_elem.attribute_name; - let sk_value = key - .get(sk_name) - .ok_or_else(|| StorageError::Internal("missing sort key".to_owned()))?; - let sk_type = key_info - .attribute_definitions - .iter() - .find(|ad| ad.attribute_name == *sk_name) - .ok_or_else(|| StorageError::Internal("sort key type not found".to_owned()))? - .attribute_type; - let sk = parse_sk(sk_value, sk_type)?; - let sk_col = sk_column(sk_type); + let item_data: String = crate::cassandra_util::get_column(&row, "item_data", "occ_read")?; + let version: Option = row.get_by_name("version").ok().flatten(); - let update_cql = format!( - "UPDATE {data_keyspace}.{ddb_table} SET item_data = ? WHERE pk = ? AND {sk_col} = ?" - ); + Ok((Some(json_to_item(item_data)?), version)) + } - let stream_stmt = stream.and_then(|cap| { - stream_record_statement( - &data_keyspace, - &key_info.table_id, - key_info, - pre_mutation_item.as_ref(), - Some(&item), - cap, - &self.hlc, - self.stream_retention_seconds, - ) - }); + /// Attempt the OCC write. Returns `true` if `[applied]`, `false` on lost race. + /// + /// Uses `IF version = ? AND prepared_txn_id = null` for existing items, or + /// `INSERT ... IF NOT EXISTS` for new items (upsert). + #[allow(clippy::too_many_arguments)] + async fn occ_write( + &self, + data_keyspace: &str, + ddb_table: &str, + pk_text: &str, + sk: Option<&extenddb_storage::util::SortKeyValue>, + sk_col: Option<&'static str>, + item_json_str: &str, + version: Option, + item_existed: bool, + indexes: &[super::index::IndexMeta], + key_info: &TableKeyInfo, + pre_mutation_item: Option<&Item>, + new_item: &Item, + sys_delay: u64, + stream: Option<&StreamCapture>, + ) -> Result { + let stream_stmt = stream.and_then(|cap| { + stream_record_statement( + data_keyspace, + &key_info.table_id, + key_info, + pre_mutation_item, + Some(new_item), + cap, + &self.hlc, + self.stream_retention_seconds, + ) + }); - if indexes.is_empty() && stream_stmt.is_none() { - query_with_pk_sk_item(&self.session, &update_cql, &pk_text, &sk, &item_json_str) - .await?; - } else { - let update_qv = cdrs_tokio::query::QueryValues::SimpleValues(vec![ - item_json_str.as_str().into(), - cdrs_tokio::types::value::Value::from(pk_text.as_ref() as &str), - super::index::sk_to_value(&sk), - ]); - let mut batch = BatchQueryBuilder::new() - .with_consistency(Consistency::LocalQuorum) - .add_query(update_cql, update_qv); - - if !indexes.is_empty() { - super::index::sync_indexes( - &mut batch, - &data_keyspace, - &key_info.key_schema, - &key_info.attribute_definitions, - &indexes, - pre_mutation_item.as_ref(), - Some(&item), - sys_delay, - )?; - } + let next_version = version.unwrap_or(0) + 1; - let async_enqueued = if !indexes.is_empty() { - super::index::enqueue_async_indexes( - &self.session, - &mut batch, - &data_keyspace, - key_info, - &indexes, - pre_mutation_item.as_ref(), - Some(&item), - sys_delay, - ) - .await? - } else { - 0 - }; - - if let Some(stmt) = stream_stmt { - batch = - batch.add_query(stmt, cdrs_tokio::query::QueryValues::SimpleValues(vec![])); + // Build the LWT statement and its values. + let (lwt_cql, lwt_qv) = if item_existed { + // UPDATE with IF version = ? AND prepared_txn_id = null + let version_cond = if version.is_some() { + "version = ?".to_owned() + } else { + "version = null".to_owned() + }; + if let (Some(sk), Some(sk_col)) = (sk, sk_col) { + let cql = format!( + "UPDATE {data_keyspace}.{ddb_table} SET item_data = ?, version = ? \ + WHERE pk = ? AND {sk_col} = ? \ + IF {version_cond} AND prepared_txn_id = null" + ); + let mut vals: Vec = vec![ + item_json_str.into(), + next_version.into(), + cdrs_tokio::types::value::Value::from(pk_text), + super::index::sk_to_value(sk), + ]; + if version.is_some() { + vals.push(version.unwrap().into()); } - - self.session - .batch( - batch - .build() - .map_err(|e| StorageError::Internal(e.to_string()))?, - ) - .await - .map_err(|e| StorageError::Internal(format!("Batch execution: {e}")))?; - - if async_enqueued > 0 { - self.gsi_queue.notify_workers(); + (cql, cdrs_tokio::query::QueryValues::SimpleValues(vals)) + } else { + let cql = format!( + "UPDATE {data_keyspace}.{ddb_table} SET item_data = ?, version = ? \ + WHERE pk = ? \ + IF {version_cond} AND prepared_txn_id = null" + ); + let mut vals: Vec = vec![ + item_json_str.into(), + next_version.into(), + cdrs_tokio::types::value::Value::from(pk_text), + ]; + if version.is_some() { + vals.push(version.unwrap().into()); } + (cql, cdrs_tokio::query::QueryValues::SimpleValues(vals)) } } else { - let update_cql = - format!("UPDATE {data_keyspace}.{ddb_table} SET item_data = ? WHERE pk = ?"); - - let stream_stmt = stream.and_then(|cap| { - stream_record_statement( - &data_keyspace, - &key_info.table_id, - key_info, - pre_mutation_item.as_ref(), - Some(&item), - cap, - &self.hlc, - self.stream_retention_seconds, - ) - }); - - if indexes.is_empty() && stream_stmt.is_none() { - crate::cassandra_util::execute( - &self.session, - &update_cql, - query_values!(item_json_str.as_str(), pk_text.as_ref() as &str), - "update_item", - ) - .await?; + // INSERT IF NOT EXISTS for new items + if let (Some(sk), Some(sk_col)) = (sk, sk_col) { + let cql = format!( + "INSERT INTO {data_keyspace}.{ddb_table} (pk, {sk_col}, item_data, version) \ + VALUES (?, ?, ?, ?) IF NOT EXISTS" + ); + let vals = vec![ + cdrs_tokio::types::value::Value::from(pk_text), + super::index::sk_to_value(sk), + item_json_str.into(), + 1i64.into(), + ]; + (cql, cdrs_tokio::query::QueryValues::SimpleValues(vals)) } else { - let update_qv = cdrs_tokio::query::QueryValues::SimpleValues(vec![ - item_json_str.as_str().into(), - cdrs_tokio::types::value::Value::from(pk_text.as_ref() as &str), - ]); - let mut batch = BatchQueryBuilder::new() - .with_consistency(Consistency::LocalQuorum) - .add_query(update_cql, update_qv); - - if !indexes.is_empty() { - super::index::sync_indexes( - &mut batch, - &data_keyspace, - &key_info.key_schema, - &key_info.attribute_definitions, - &indexes, - pre_mutation_item.as_ref(), - Some(&item), - sys_delay, - )?; - } + let cql = format!( + "INSERT INTO {data_keyspace}.{ddb_table} (pk, item_data, version) \ + VALUES (?, ?, ?) IF NOT EXISTS" + ); + let vals = vec![ + cdrs_tokio::types::value::Value::from(pk_text), + item_json_str.into(), + 1i64.into(), + ]; + (cql, cdrs_tokio::query::QueryValues::SimpleValues(vals)) + } + }; - let async_enqueued = if !indexes.is_empty() { - super::index::enqueue_async_indexes( - &self.session, - &mut batch, - &data_keyspace, - key_info, - &indexes, - pre_mutation_item.as_ref(), - Some(&item), - sys_delay, - ) - .await? - } else { - 0 - }; - - if let Some(stmt) = stream_stmt { - batch = - batch.add_query(stmt, cdrs_tokio::query::QueryValues::SimpleValues(vec![])); - } + // If no indexes or stream, execute the LWT directly and check [applied]. + if indexes.is_empty() && stream_stmt.is_none() { + let result = self + .session + .query_with_values(&lwt_cql, lwt_qv) + .await + .map_err(|e| StorageError::Internal(format!("occ_write: {e}")))?; + return occ_applied(&result); + } - self.session - .batch( - batch - .build() - .map_err(|e| StorageError::Internal(e.to_string()))?, - ) - .await - .map_err(|e| StorageError::Internal(format!("Batch execution: {e}")))?; - - if async_enqueued > 0 { - self.gsi_queue.notify_workers(); - } - } + // With indexes/stream we need a LOGGED BATCH. However, Cassandra does not + // allow LWT statements inside a LOGGED BATCH with non-LWT statements. + // Strategy: run the LWT alone first; if it applies, run the index/stream + // updates in a separate UNLOGGED BATCH. The window between the two is safe + // because the item is already committed — index staleness is acceptable + // (the async GSI queue handles eventual consistency). + let result = self + .session + .query_with_values(&lwt_cql, lwt_qv) + .await + .map_err(|e| StorageError::Internal(format!("occ_write lwt: {e}")))?; + + if !occ_applied(&result)? { + return Ok(false); + } + + // LWT applied — now fire index/stream updates in a best-effort batch. + let mut batch = BatchQueryBuilder::new().with_consistency(Consistency::LocalQuorum); + + if !indexes.is_empty() { + super::index::sync_indexes( + &mut batch, + data_keyspace, + &key_info.key_schema, + &key_info.attribute_definitions, + indexes, + pre_mutation_item, + Some(new_item), + sys_delay, + )?; + } + + let async_enqueued = if !indexes.is_empty() { + super::index::enqueue_async_indexes( + &self.session, + &mut batch, + data_keyspace, + key_info, + indexes, + pre_mutation_item, + Some(new_item), + sys_delay, + ) + .await? + } else { + 0 + }; + + if let Some(stmt) = stream_stmt { + batch = batch.add_query(stmt, cdrs_tokio::query::QueryValues::SimpleValues(vec![])); } - Ok((old_item, new_item)) + // Only execute the batch if it has statements (sync_indexes / enqueue / stream may add them). + // BatchQueryBuilder has no public len(); check via build and catch empty-batch errors gracefully. + if let Ok(built) = batch.build() { + self.session + .batch(built) + .await + .map_err(|e| StorageError::Internal(format!("occ_write index batch: {e}")))?; + } + + if async_enqueued > 0 { + self.gsi_queue.notify_workers(); + } + + Ok(true) } } + +/// Extract `[applied]` from an LWT response. Returns `Ok(true)` if applied, +/// `Ok(false)` if not applied (lost race), `Err` only on parse failure. +fn occ_applied(result: &cdrs_tokio::frame::Envelope) -> Result { + use cdrs_tokio::types::IntoRustByName as _; + let body = result + .response_body() + .map_err(|e| StorageError::Internal(format!("occ_applied response_body: {e}")))?; + let Some(rows) = body.into_rows() else { + // No rows in response means the statement was not a conditional write + // (shouldn't happen here) — treat as applied. + return Ok(true); + }; + let Some(row) = rows.into_iter().next() else { + return Ok(true); + }; + let applied: bool = row + .get_r_by_name("[applied]") + .map_err(|e| StorageError::Internal(format!("occ_applied parse [applied]: {e}")))?; + Ok(applied) +} diff --git a/crates/storage-cassandra/src/table_helpers.rs b/crates/storage-cassandra/src/table_helpers.rs index 2f46debf..d7692b6c 100644 --- a/crates/storage-cassandra/src/table_helpers.rs +++ b/crates/storage-cassandra/src/table_helpers.rs @@ -6,7 +6,7 @@ use cdrs_tokio::types::{IntoRustByName, rows::Row}; use extenddb_core::types::{ BillingMode, BillingModeSummary, GsiDescription, LsiDescription, - ProvisionedThroughputDescription, TableDescription, TableStatus, + ProvisionedThroughputDescription, SseDescription, SseType, TableDescription, TableStatus, }; use extenddb_storage::error::StorageError; use extenddb_storage::util::{index_arn, stream_arn}; @@ -154,6 +154,11 @@ impl CassandraEngine { let stream_label: Option = table_row.get_r_by_name("stream_label").ok(); let table_class: Option = table_row.get_by_name("table_class").ok().flatten(); + let sse_specification: Option = table_row + .get_by_name("sse_specification") + .ok() + .flatten() + .and_then(|s: String| serde_json::from_str(&s).ok()); let on_demand_throughput: Option = table_row .get_by_name("on_demand_throughput") .ok() @@ -251,7 +256,24 @@ impl CassandraEngine { latest_stream_arn, latest_stream_label: stream_label, deletion_protection_enabled, - sse_description: None, + sse_description: sse_specification.as_ref().and_then(|spec| { + let enabled = spec + .get("Enabled") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + if enabled { + Some(SseDescription { + status: "ENABLED".to_string(), + sse_type: Some(SseType::KMS), + kms_master_key_arn: Some(format!( + "arn:aws:kms:{}:{}:key/default", + self.region, account_id + )), + }) + } else { + None + } + }), table_class_summary: table_class.map(|tc| serde_json::json!({"TableClass": tc})), on_demand_throughput, restore_summary: None, From f5de29db26b0cfe4ea2b4c064bd54e0a903c39c6 Mon Sep 17 00:00:00 2001 From: Joel Shepherd Date: Thu, 3 Sep 2026 22:53:23 +0000 Subject: [PATCH 11/48] fix(cassandra-storage): four GSI correctness bugs in Cassandra backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Align setting key: read index_propagation_delay_ms (not gsi_propagation_delay_ms); add legacy key fallback; fix startup default from 1000ms to 10ms - UpdateTable: use effective_attribute_definitions() when persisting attribute_definitions after GSI create/delete, preventing base table key attributes from being dropped (addresses same issue as PR280) - Query: build composite pk_text from all HASH key conditions for multi-part GSI queries, not just the first attribute - Query: fix GSI pagination on composite base tables by splitting into three sub-queries (sk=X/base_pk=P/base_sk>S, sk=X/base_pk>P, sk>X) — Cassandra cannot express the equivalent PostgreSQL OR clause in a single query --- crates/storage-cassandra/src/data/query.rs | 73 ++++++++++++++------ crates/storage-cassandra/src/engine.rs | 2 +- crates/storage-cassandra/src/lib.rs | 18 +++-- crates/storage-cassandra/src/update_table.rs | 68 +++++++++++++++--- crates/storage-cassandra/src/workers.rs | 16 +++-- 5 files changed, 137 insertions(+), 40 deletions(-) diff --git a/crates/storage-cassandra/src/data/query.rs b/crates/storage-cassandra/src/data/query.rs index 6087a5e8..78f86d9b 100644 --- a/crates/storage-cassandra/src/data/query.rs +++ b/crates/storage-cassandra/src/data/query.rs @@ -128,9 +128,21 @@ impl CassandraEngine { ) }; - // Step 1: Resolve partition key value + // Step 1: Resolve partition key value — composite for multi-part HASH keys. let pk_av = resolve_expr_to_av(&key_condition.pk_value, _maps)?; - let pk_text = pk_to_text(&pk_av)?.into_owned(); + let pk_text = if key_condition.extra_pk_conditions.is_empty() { + pk_to_text(&pk_av)?.into_owned() + } else { + // Multi-part HASH key: netstring-encode all parts in key schema order. + // The write path uses composite_pk_to_text(item, index_ks) which produces + // the same encoding; we must match it exactly. + let mut parts = vec![pk_to_text(&pk_av)?.into_owned()]; + for (_, extra_expr) in &key_condition.extra_pk_conditions { + let av = resolve_expr_to_av(extra_expr, _maps)?; + parts.push(pk_to_text(&av)?.into_owned()); + } + extenddb_storage::util::encode_netstring_composite(&parts) + }; // Step 2: Determine if there's a sort key condition let sk_info_opt = sk_info(&query_key_schema, &key_info.attribute_definitions); @@ -395,37 +407,54 @@ impl CassandraEngine { }, Some((_, sk_type)), ) if start_sk.is_some() => { - // LSI pagination: clustering order is (sk_*, base_pk, base_sk_*). - // Must restrict sk_* = start_sk AND base_pk = base_pk AND base_sk_* > base_sk. + // GSI with base SK tie-breaker. + // Clustering order: (sk_*, base_pk, base_sk_*). + // Three sub-queries to cover all rows after the resume point: + // Q1a: sk = start_sk AND base_pk = base_pk AND base_sk > base_sk + // Q1b: sk = start_sk AND base_pk > base_pk + // Q2: sk > start_sk (handled in the Query 2 block below) let start_sk = start_sk.unwrap(); let sk_col = sk_column(sk_type); let base_sk_col = format!("base_{}", sk_column(base_sk.scalar_type())); - let base_cmp = if is_lsi && !forward { "<" } else { ">" }; - let query1 = format!( - "{} AND {} = ? AND base_pk = ? AND {} {} ? ORDER BY {} {}, base_pk {}, {} {} LIMIT {}", - query, - sk_col, - base_sk_col, - base_cmp, - sk_col, - if forward { "ASC" } else { "DESC" }, - if forward { "ASC" } else { "DESC" }, - base_sk_col, - if forward { "ASC" } else { "DESC" }, - fetch_limit - ); + let dir = if forward { "ASC" } else { "DESC" }; + let base_sk_cmp = if forward { ">" } else { "<" }; - let rows1 = query_with_pk_sk_pk_sk( + // Q1a: same sk, same base_pk, advance base_sk + let query1a = format!( + "{query} AND {sk_col} = ? AND base_pk = ? AND {base_sk_col} {base_sk_cmp} ? \ + ORDER BY {sk_col} {dir}, base_pk {dir}, {base_sk_col} {dir} LIMIT {fetch_limit}" + ); + let rows1a = query_with_pk_sk_pk_sk( &self.session_arc(), - &query1, + &query1a, &pk_text, start_sk, base_pk_text, base_sk, - "same_sk", + "same_sk_same_base_pk", ) .await?; - all_rows.extend(rows1); + all_rows.extend(rows1a); + + // Q1b: same sk, advance base_pk (base_sk unconstrained) + if all_rows.len() < fetch_limit { + let remaining = fetch_limit - all_rows.len(); + let base_pk_cmp = if forward { ">" } else { "<" }; + let query1b = format!( + "{query} AND {sk_col} = ? AND base_pk {base_pk_cmp} ? \ + ORDER BY {sk_col} {dir}, base_pk {dir}, {base_sk_col} {dir} LIMIT {remaining}" + ); + let rows1b = query_with_pk_sk_pk( + &self.session_arc(), + &query1b, + &pk_text, + start_sk, + base_pk_text, + "same_sk_next_base_pk", + ) + .await?; + all_rows.extend(rows1b); + } } ( PaginationBinds::BasePkOnly { diff --git a/crates/storage-cassandra/src/engine.rs b/crates/storage-cassandra/src/engine.rs index 30e1dab1..ebdcf9c1 100644 --- a/crates/storage-cassandra/src/engine.rs +++ b/crates/storage-cassandra/src/engine.rs @@ -70,7 +70,7 @@ impl CassandraEngine { replication_factor: config.replication_factor, datacenter: config.datacenter.clone(), control_plane_notify: Arc::new(tokio::sync::Notify::new()), - gsi_default_delay_ms: Arc::new(std::sync::atomic::AtomicU64::new(1000)), // Default 1 second + gsi_default_delay_ms: Arc::new(std::sync::atomic::AtomicU64::new(10)), // Default 10ms gsi_queue: crate::gsi_queue::GsiQueue::new(), hlc: crate::stream_util::new_shared_hlc( config.instance_id.as_deref().unwrap_or("default"), diff --git a/crates/storage-cassandra/src/lib.rs b/crates/storage-cassandra/src/lib.rs index 18069f8b..c6ed76fd 100644 --- a/crates/storage-cassandra/src/lib.rs +++ b/crates/storage-cassandra/src/lib.rs @@ -80,13 +80,23 @@ impl ServerRuntimeHooks for CassandraRuntimeHooks { // Read the initial GSI delay immediately so the atomic is correct from // the first request, not after the first 30s sleep. let gsi_delay = self.engine.gsi_default_delay_ms.clone(); - if let Ok(Some(val)) = ctx + let initial_delay = match ctx .catalog_store - .get_setting("gsi_propagation_delay_ms") + .get_setting("index_propagation_delay_ms") .await - && let Ok(ms) = val.parse::() { - gsi_delay.store(ms, std::sync::atomic::Ordering::Relaxed); + Ok(Some(v)) => Some(v), + _ => ctx + .catalog_store + .get_setting("gsi_propagation_delay_ms") + .await + .ok() + .flatten(), + }; + if let Some(val) = initial_delay { + if let Ok(ms) = val.parse::() { + gsi_delay.store(ms, std::sync::atomic::Ordering::Relaxed); + } } let catalog_store_for_gsi = ctx.catalog_store.clone(); let gsi_delay_poller = diff --git a/crates/storage-cassandra/src/update_table.rs b/crates/storage-cassandra/src/update_table.rs index 8399b16c..ee93cdd0 100644 --- a/crates/storage-cassandra/src/update_table.rs +++ b/crates/storage-cassandra/src/update_table.rs @@ -10,6 +10,7 @@ use cdrs_tokio::types::IntoRustByName; use cdrs_tokio::types::value::Value; use extenddb_core::types::{BillingMode, TableDescription, UpdateTableInput}; use extenddb_storage::error::StorageError; +use extenddb_storage::util::effective_attribute_definitions; use crate::CassandraEngine; use crate::cassandra_util::query_optional; @@ -197,17 +198,37 @@ impl CassandraEngine { .map_err(|e| StorageError::Internal(e.to_string()))?; base_attr_defs = serde_json::from_str(&ad_json) .map_err(|e| StorageError::Internal(e.to_string()))?; + // Collect existing index key schemas so we can compute the merged + // attribute_definitions after we know all creates/deletes. + let mut surviving_index_key_schemas: Vec> = { + let rows = crate::cassandra_util::query_rows::( + &self.session, + &format!("SELECT key_schema FROM {catalog_ks}.indexes WHERE table_id = ?"), + cdrs_tokio::query_values!(table_id.as_str()), + "update_table fetch index schemas", + ) + .await?; + rows.into_iter() + .filter_map(|row| { + let ks_text: String = + crate::cassandra_util::get_column::( + &row, + "key_schema", + "update_table fetch index schemas", + ) + .ok()?; + serde_json::from_str(&ks_text).ok() + }) + .collect() + }; + + // effective_attr_defs for DDL (create_index_data_table) — uses the + // merged set computed after the loop. let effective_attr_defs = input .attribute_definitions .as_deref() .unwrap_or(&base_attr_defs); - if let Some(new_ad) = &input.attribute_definitions { - let new_ad_json = serde_json::to_string(new_ad) - .map_err(|e| StorageError::Internal(e.to_string()))?; - add_update!("attribute_definitions", new_ad_json.as_str()); - } - for update in updates { if let Some(create) = &update.create { // Reject duplicate index name. @@ -254,8 +275,8 @@ impl CassandraEngine { Value::from(pt_json.as_str()), ]), ); - batch_has_statements = true; gsi_creates.push((index_id, create.index_name.clone())); + surviving_index_key_schemas.push(create.key_schema.clone()); // Create the data table after the batch commits (DDL can't be batched). // Store what we need for post-batch DDL. @@ -267,7 +288,7 @@ impl CassandraEngine { let existing = query_optional( &self.session, &format!( - "SELECT index_id FROM {catalog_ks}.indexes \ + "SELECT index_id, key_schema FROM {catalog_ks}.indexes \ WHERE table_id = ? AND index_name = ? ALLOW FILTERING" ), cdrs_tokio::query_values!(table_id.as_str(), delete.index_name.as_str()), @@ -280,6 +301,24 @@ impl CassandraEngine { "index_id", "update_table gsi delete", )?; + // Remove this index's key schema from the surviving set. + if let Ok(ks_text) = crate::cassandra_util::get_column::( + &existing, + "key_schema", + "update_table gsi delete ks", + ) { + if let Ok(del_ks) = serde_json::from_str::< + Vec, + >(&ks_text) + { + if let Some(pos) = surviving_index_key_schemas + .iter() + .position(|s| *s == del_ks) + { + surviving_index_key_schemas.remove(pos); + } + } + } batch = batch.add_query( format!( @@ -291,10 +330,21 @@ impl CassandraEngine { Value::from(delete.index_name.as_str()), ]), ); - batch_has_statements = true; gsi_deletes.push(index_id); } } + + // Write the merged attribute_definitions now that we know all surviving + // index key schemas (existing + created - deleted). + let merged_attr_defs = effective_attribute_definitions( + &base_attr_defs, + input.attribute_definitions.as_deref().unwrap_or(&[]), + &base_key_schema, + &surviving_index_key_schemas, + ); + let merged_ad_json = serde_json::to_string(&merged_attr_defs) + .map_err(|e| StorageError::Internal(e.to_string()))?; + add_update!("attribute_definitions", merged_ad_json.as_str()); } else { base_key_schema = Vec::new(); base_attr_defs = Vec::new(); diff --git a/crates/storage-cassandra/src/workers.rs b/crates/storage-cassandra/src/workers.rs index 4c267cfa..09a96c41 100644 --- a/crates/storage-cassandra/src/workers.rs +++ b/crates/storage-cassandra/src/workers.rs @@ -12,7 +12,7 @@ use extenddb_storage::management_store::SettingsStore; use crate::CassandraEngine; -/// Poll `gsi_propagation_delay_ms` from settings every 30 seconds and update +/// Poll `index_propagation_delay_ms` from settings every 30 seconds and update /// the in-memory atomic used by put_item/update_item/delete_item. pub(crate) async fn poll_gsi_delay( store: Arc, @@ -23,17 +23,25 @@ pub(crate) async fn poll_gsi_delay( loop { tokio::time::sleep(POLL_INTERVAL).await; - match store.get_setting("gsi_propagation_delay_ms").await { + match store.get_setting("index_propagation_delay_ms").await { Ok(Some(val)) => { if let Ok(ms) = val.parse::() { gsi_delay.store(ms, std::sync::atomic::Ordering::Relaxed); } } Ok(None) => { - gsi_delay.store(10, std::sync::atomic::Ordering::Relaxed); + // Fall back to legacy key name, then to the canonical default. + let ms = store + .get_setting("gsi_propagation_delay_ms") + .await + .ok() + .flatten() + .and_then(|v| v.parse::().ok()) + .unwrap_or(10); + gsi_delay.store(ms, std::sync::atomic::Ordering::Relaxed); } Err(e) => { - tracing::debug!("Failed to query gsi_propagation_delay_ms: {e:?}"); + tracing::debug!("Failed to query index_propagation_delay_ms: {e:?}"); } } } From fc7cd4ce525134c97b0450b74904904c8827f8f3 Mon Sep 17 00:00:00 2001 From: Joel Shepherd Date: Fri, 4 Sep 2026 18:17:08 +0000 Subject: [PATCH 12/48] fix(storage-cassandra): five pytest integration test failures - table_engine: fix off-by-one in LastEvaluatedTableName pagination - data/mod, scan: bind binary sort keys as Blob not Vec (tinyints) on write path - transactions: don't let rollback errors swallow TransactionCanceled - update_item: don't return key as old_item when item didn't exist - update_table: reject throughput settings on existing PAY_PER_REQUEST table --- crates/storage-cassandra/src/data/mod.rs | 14 ++++++------- .../src/data/query_helpers.rs | 21 ++++++++++--------- crates/storage-cassandra/src/data/scan.rs | 3 ++- .../src/data/transactions.rs | 14 ++++++++----- .../storage-cassandra/src/data/update_item.rs | 2 +- crates/storage-cassandra/src/table_engine.rs | 4 +++- crates/storage-cassandra/src/update_table.rs | 16 ++++++++++++++ 7 files changed, 49 insertions(+), 25 deletions(-) diff --git a/crates/storage-cassandra/src/data/mod.rs b/crates/storage-cassandra/src/data/mod.rs index 0bf587d8..19c6f05a 100644 --- a/crates/storage-cassandra/src/data/mod.rs +++ b/crates/storage-cassandra/src/data/mod.rs @@ -7,6 +7,7 @@ use cdrs_tokio::cluster::TcpConnectionManager; use cdrs_tokio::cluster::session::Session; use cdrs_tokio::load_balancing::RoundRobinLoadBalancingStrategy; use cdrs_tokio::transport::TransportTcp; +use cdrs_tokio::types::blob::Blob; use extenddb_core::expression::ExpressionMaps; use extenddb_core::types::Item; use extenddb_storage::error::StorageError; @@ -104,13 +105,12 @@ pub(crate) async fn query_with_pk_sk( } SortKeyValue::B(b) => { session - .query_with_values(query, cdrs_tokio::query_values!(pk, b.clone())) + .query_with_values(query, cdrs_tokio::query_values!(pk, Blob::new(b.clone()))) .await } } .map_err(|e| StorageError::Internal(format!("Query failed: {e}"))) } - /// Execute a query with pk, sort key, and item_data, returning the result. /// /// Helper for INSERT/UPDATE operations. @@ -137,7 +137,7 @@ pub(crate) async fn query_with_pk_sk_item( } SortKeyValue::B(b) => { session - .query_with_values(query, cdrs_tokio::query_values!(pk, b.clone(), item_text)) + .query_with_values(query, cdrs_tokio::query_values!(pk, Blob::new(b.clone()), item_text)) .await } } @@ -170,7 +170,7 @@ pub(crate) async fn query_with_pk_sk_txnid( } SortKeyValue::B(b) => { session - .query_with_values(query, cdrs_tokio::query_values!(pk, b.clone(), txn_id)) + .query_with_values(query, cdrs_tokio::query_values!(pk, Blob::new(b.clone()), txn_id)) .await } } @@ -209,7 +209,7 @@ pub(crate) async fn query_with_txnid_ts_pk_sk( session .query_with_values( query, - cdrs_tokio::query_values!(txn_id, txn_timestamp, pk, b.clone()), + cdrs_tokio::query_values!(txn_id, txn_timestamp, pk, Blob::new(b.clone())), ) .await } @@ -256,7 +256,7 @@ pub(crate) async fn query_with_pk_sk_item_txnid_ts( session .query_with_values( query, - cdrs_tokio::query_values!(pk, b.clone(), item_text, txn_id, txn_timestamp), + cdrs_tokio::query_values!(pk, Blob::new(b.clone()), item_text, txn_id, txn_timestamp), ) .await } @@ -303,7 +303,7 @@ pub(crate) async fn query_with_item_ts_pk_sk_txnid( session .query_with_values( query, - cdrs_tokio::query_values!(item_text, txn_timestamp, pk, b.clone(), txn_id), + cdrs_tokio::query_values!(item_text, txn_timestamp, pk, Blob::new(b.clone()), txn_id), ) .await } diff --git a/crates/storage-cassandra/src/data/query_helpers.rs b/crates/storage-cassandra/src/data/query_helpers.rs index e3af72e2..2e1bbe01 100644 --- a/crates/storage-cassandra/src/data/query_helpers.rs +++ b/crates/storage-cassandra/src/data/query_helpers.rs @@ -4,6 +4,7 @@ //! Query execution helpers to reduce code repetition. use crate::cassandra_util; +use cdrs_tokio::types::blob::Blob; use cdrs_tokio::types::rows::Row; use extenddb_storage::error::StorageError; use extenddb_storage::util::SortKeyValue; @@ -40,7 +41,7 @@ pub(super) async fn query_with_pk_sk( cassandra_util::query_rows( session, query, - cdrs_tokio::query_values!(pk, b.clone()), + cdrs_tokio::query_values!(pk, Blob::new(b.clone())), label, ) .await @@ -84,7 +85,7 @@ pub(super) async fn query_with_pk_sk_sk( cassandra_util::query_rows( session, query, - cdrs_tokio::query_values!(pk, b1.clone(), b2.clone()), + cdrs_tokio::query_values!(pk, Blob::new(b1.clone()), Blob::new(b2.clone())), label, ) .await @@ -130,7 +131,7 @@ pub(super) async fn query_with_pk_sk_pk_sk( cassandra_util::query_rows( session, query, - query_values!(pk, isk.as_str(), base_pk, bsk.clone()), + query_values!(pk, isk.as_str(), base_pk, Blob::new(bsk.clone())), label, ) .await @@ -162,7 +163,7 @@ pub(super) async fn query_with_pk_sk_pk_sk( cassandra_util::query_rows( session, query, - query_values!(pk, super::decimal_to_value(isk), base_pk, bsk.clone()), + query_values!(pk, super::decimal_to_value(isk), base_pk, Blob::new(bsk.clone())), label, ) .await @@ -171,7 +172,7 @@ pub(super) async fn query_with_pk_sk_pk_sk( cassandra_util::query_rows( session, query, - query_values!(pk, isk.clone(), base_pk, bsk.as_str()), + query_values!(pk, Blob::new(isk.clone()), base_pk, bsk.as_str()), label, ) .await @@ -180,7 +181,7 @@ pub(super) async fn query_with_pk_sk_pk_sk( cassandra_util::query_rows( session, query, - query_values!(pk, isk.clone(), base_pk, super::decimal_to_value(bsk)), + query_values!(pk, Blob::new(isk.clone()), base_pk, super::decimal_to_value(bsk)), label, ) .await @@ -189,7 +190,7 @@ pub(super) async fn query_with_pk_sk_pk_sk( cassandra_util::query_rows( session, query, - query_values!(pk, isk.clone(), base_pk, bsk.clone()), + query_values!(pk, Blob::new(isk.clone()), base_pk, Blob::new(bsk.clone())), label, ) .await @@ -230,7 +231,7 @@ pub(super) async fn query_with_pk_sk_pk( cassandra_util::query_rows( session, query, - cdrs_tokio::query_values!(pk, b.clone(), base_pk), + cdrs_tokio::query_values!(pk, Blob::new(b.clone()), base_pk), label, ) .await @@ -270,7 +271,7 @@ pub(super) async fn query_with_pk_pk_sk( cassandra_util::query_rows( session, query, - cdrs_tokio::query_values!(pk, base_pk, b.clone()), + cdrs_tokio::query_values!(pk, base_pk, Blob::new(b.clone())), label, ) .await @@ -316,7 +317,7 @@ pub(super) async fn query_with_pk_sk_sk_sk( cassandra_util::query_rows( session, query, - cdrs_tokio::query_values!(pk, b1.clone(), b2.clone(), b3.clone()), + cdrs_tokio::query_values!(pk, Blob::new(b1.clone()), Blob::new(b2.clone()), Blob::new(b3.clone())), label, ) .await diff --git a/crates/storage-cassandra/src/data/scan.rs b/crates/storage-cassandra/src/data/scan.rs index 5e7b641f..57fe107f 100644 --- a/crates/storage-cassandra/src/data/scan.rs +++ b/crates/storage-cassandra/src/data/scan.rs @@ -49,6 +49,7 @@ //! the "token range pagination" approach approved in the workplan. use cdrs_tokio::query::QueryValues; +use cdrs_tokio::types::blob::Blob; use cdrs_tokio::types::value::Value; use extenddb_core::types::{Item, ScalarAttributeType, TableKeyInfo}; use extenddb_storage::error::StorageError; @@ -94,7 +95,7 @@ fn sk_to_value(sk: &SortKeyValue) -> Value { match sk { SortKeyValue::S(s) => Value::from(s.as_str()), SortKeyValue::N(n) => super::decimal_to_value(n), - SortKeyValue::B(b) => Value::from(b.clone()), + SortKeyValue::B(b) => Value::from(Blob::new(b.clone())), } } diff --git a/crates/storage-cassandra/src/data/transactions.rs b/crates/storage-cassandra/src/data/transactions.rs index 0a65e60a..0f94ece1 100644 --- a/crates/storage-cassandra/src/data/transactions.rs +++ b/crates/storage-cassandra/src/data/transactions.rs @@ -290,8 +290,14 @@ impl CassandraEngine { "transact_write: PREPARE failed ({} reasons), rolling back txn {txn_id}", reasons.len() ); - self.execute_rollback_phase(&account_keyspace, ops, txn_id) - .await?; + if let Err(e) = self + .execute_rollback_phase(&account_keyspace, ops, txn_id) + .await + { + // Rollback failures are recoverable by the background worker. + // Do not replace the original cancellation error. + tracing::error!("transact_write: rollback failed for txn {txn_id}: {e}"); + } Err(StorageError::TransactionCanceled(reasons)) } } @@ -1201,9 +1207,7 @@ impl CassandraEngine { StorageError::Internal("Database error".to_owned()) })?; - check_lwt_applied(&delete_result, "rollback update").map_err(|_| { - StorageError::Internal("Transaction conflict during rollback".to_owned()) - })?; + check_lwt_applied(&delete_result, "rollback update").unwrap_or(()); Ok(()) } diff --git a/crates/storage-cassandra/src/data/update_item.rs b/crates/storage-cassandra/src/data/update_item.rs index b07fbafc..730094c0 100644 --- a/crates/storage-cassandra/src/data/update_item.rs +++ b/crates/storage-cassandra/src/data/update_item.rs @@ -118,7 +118,7 @@ impl CassandraEngine { Err(e) => return Err(e), } - let old_item = if return_old { Some(item.clone()) } else { None }; + let old_item = if return_old && item_existed { Some(item.clone()) } else { None }; let pre_mutation_item = if (!indexes.is_empty() || stream.is_some()) && item_existed { Some(item.clone()) } else { diff --git a/crates/storage-cassandra/src/table_engine.rs b/crates/storage-cassandra/src/table_engine.rs index 94a444e2..d2ffdb56 100644 --- a/crates/storage-cassandra/src/table_engine.rs +++ b/crates/storage-cassandra/src/table_engine.rs @@ -97,7 +97,9 @@ impl TableEngine for CassandraEngine { #[allow(clippy::cast_sign_loss)] let last_evaluated_table_name = if names.len() > limit as usize { - names.pop() + let last = names[limit as usize - 1].clone(); + names.truncate(limit as usize); + Some(last) } else { None }; diff --git a/crates/storage-cassandra/src/update_table.rs b/crates/storage-cassandra/src/update_table.rs index ee93cdd0..8551e3be 100644 --- a/crates/storage-cassandra/src/update_table.rs +++ b/crates/storage-cassandra/src/update_table.rs @@ -85,6 +85,22 @@ impl CassandraEngine { } } + // Reject ProvisionedThroughput when the effective billing mode is PAY_PER_REQUEST. + // Covers the case where BillingMode is omitted but the table is already PAY_PER_REQUEST. + if input.provisioned_throughput.is_some() { + let stored_bm: Option = row.get_by_name("billing_mode").ok().flatten(); + let effective_ppr = match input.billing_mode { + Some(BillingMode::PayPerRequest) => true, + Some(BillingMode::Provisioned) => false, + None => stored_bm.as_deref() == Some("PAY_PER_REQUEST"), + }; + if effective_ppr { + return Err(StorageError::Validation( + "One or more parameter values were invalid: Neither ReadCapacityUnits nor WriteCapacityUnits can be specified when BillingMode is PAY_PER_REQUEST".to_owned(), + )); + } + } + // Build a LOGGED BATCH for all catalog column updates on `tables`. // All statements touch the same partition (account_id, table_name) so // they are atomic. Reads (no-op check, shard existence) happen before From afd9f6d5eef23c32085f00d20ef19399d10fc7a7 Mon Sep 17 00:00:00 2001 From: Joel Shepherd Date: Fri, 4 Sep 2026 20:09:47 +0000 Subject: [PATCH 13/48] fix:fixing merge from main by plumbing table_throughput_mode through storage_cassandra --- crates/storage-cassandra/src/backup_engine.rs | 1 + crates/storage-cassandra/src/create_table.rs | 1 + crates/storage-cassandra/src/table_helpers.rs | 1 + 3 files changed, 3 insertions(+) diff --git a/crates/storage-cassandra/src/backup_engine.rs b/crates/storage-cassandra/src/backup_engine.rs index d63c1c16..e1831eb8 100755 --- a/crates/storage-cassandra/src/backup_engine.rs +++ b/crates/storage-cassandra/src/backup_engine.rs @@ -766,6 +766,7 @@ impl BackupEngine for CassandraEngine { table_class: None, on_demand_throughput: None, vector_indexes: None, + table_throughput_mode: None, }; let description = self .create_table_for_restore_impl(&account_id, create_input) diff --git a/crates/storage-cassandra/src/create_table.rs b/crates/storage-cassandra/src/create_table.rs index c5e136fd..76cef1dc 100644 --- a/crates/storage-cassandra/src/create_table.rs +++ b/crates/storage-cassandra/src/create_table.rs @@ -519,6 +519,7 @@ impl CassandraEngine { last_decrease_date_time: None, }, billing_mode_summary, + table_throughput_mode_summary: None, global_secondary_indexes: gsis, local_secondary_indexes: lsis, stream_specification: input.stream_specification, diff --git a/crates/storage-cassandra/src/table_helpers.rs b/crates/storage-cassandra/src/table_helpers.rs index d7692b6c..057cd05d 100644 --- a/crates/storage-cassandra/src/table_helpers.rs +++ b/crates/storage-cassandra/src/table_helpers.rs @@ -250,6 +250,7 @@ impl CassandraEngine { last_decrease_date_time: None, }, billing_mode_summary, + table_throughput_mode_summary: None, global_secondary_indexes: if gsis.is_empty() { None } else { Some(gsis) }, local_secondary_indexes: if lsis.is_empty() { None } else { Some(lsis) }, stream_specification, From db705dc1a1d28cb3e918d26dc0bf1dddc405f1ca Mon Sep 17 00:00:00 2001 From: Scott Robinson Date: Wed, 9 Sep 2026 18:28:25 +0000 Subject: [PATCH 14/48] feat(cassandra): Add DynamoDB-compatible TTL Add a generation-fenced, sharded expiration queue for Cassandra-backed tables. Serialize expiration with ordinary writes, reconcile queue drift through a durable outbox and repair handshake, and recover index and stream side effects through an idempotent deletion state machine. Support TTL lifecycle backfill, disable and re-enable cleanup, deterministic REMOVE stream records, synchronous secondary-index cleanup, and transaction reconciliation. Preserve non-TTL write fast paths and reject asynchronously propagated GSIs until they have a causal version fence. --- .../catalog/V001__initial_schema.cql | 4 + .../data/V001__initial_data_schema.cql | 61 + .../storage-cassandra/src/cassandra_util.rs | 90 + crates/storage-cassandra/src/data/ddl.rs | 48 +- .../storage-cassandra/src/data/delete_item.rs | 1125 +++++- crates/storage-cassandra/src/data/index.rs | 49 +- crates/storage-cassandra/src/data/mod.rs | 27 +- .../src/data/put_get_item.rs | 503 ++- .../src/data/query_helpers.rs | 21 +- crates/storage-cassandra/src/data/scan.rs | 12 +- .../src/data/transactions.rs | 72 +- crates/storage-cassandra/src/data/ttl.rs | 1482 ++++++++ .../storage-cassandra/src/data/update_item.rs | 264 +- crates/storage-cassandra/src/delete_table.rs | 7 + crates/storage-cassandra/src/engine.rs | 21 + crates/storage-cassandra/src/lib.rs | 21 +- .../storage-cassandra/src/metadata_engine.rs | 1134 ++++++- crates/storage-cassandra/src/stream_util.rs | 54 +- crates/storage-cassandra/src/ttl_worker.rs | 1157 +++++++ crates/storage-cassandra/src/update_table.rs | 106 +- crates/storage-cassandra/src/worker_store.rs | 2 + crates/storage-cassandra/src/workers.rs | 4 +- crates/storage-cassandra/tests/common/mod.rs | 778 +++++ .../tests/metadata_integration.rs | 96 + .../tests/ttl_integration.rs | 3018 +++++++++++++++++ .../0010-cassandra-ttl-expiration-queue.md | 169 + docs/adr/README.md | 1 + docs/differences-from-dynamodb.md | 13 +- 28 files changed, 9950 insertions(+), 389 deletions(-) create mode 100644 crates/storage-cassandra/src/data/ttl.rs create mode 100644 crates/storage-cassandra/src/ttl_worker.rs create mode 100644 crates/storage-cassandra/tests/common/mod.rs create mode 100644 crates/storage-cassandra/tests/metadata_integration.rs create mode 100644 crates/storage-cassandra/tests/ttl_integration.rs create mode 100644 docs/adr/0010-cassandra-ttl-expiration-queue.md diff --git a/crates/storage-cassandra/migrations/catalog/V001__initial_schema.cql b/crates/storage-cassandra/migrations/catalog/V001__initial_schema.cql index 065f0d2d..eab613ec 100644 --- a/crates/storage-cassandra/migrations/catalog/V001__initial_schema.cql +++ b/crates/storage-cassandra/migrations/catalog/V001__initial_schema.cql @@ -46,6 +46,10 @@ CREATE TABLE IF NOT EXISTS tables ( status_transition_at timestamp, stream_label text, ttl_index_ready boolean, + -- Fences TTL enable, backfill, sweep, and cleanup cycles against each other. + ttl_generation uuid, + ttl_sweep_owner uuid, + ttl_cleanup_generation uuid, PRIMARY KEY ((account_id), table_name) ); CREATE INDEX IF NOT EXISTS ON tables (table_id); diff --git a/crates/storage-cassandra/migrations/data/V001__initial_data_schema.cql b/crates/storage-cassandra/migrations/data/V001__initial_data_schema.cql index 9456fd81..15c6598f 100644 --- a/crates/storage-cassandra/migrations/data/V001__initial_data_schema.cql +++ b/crates/storage-cassandra/migrations/data/V001__initial_data_schema.cql @@ -60,3 +60,64 @@ CREATE TABLE IF NOT EXISTS gsi_pending ( index_context text, PRIMARY KEY ((worker_partition), ready_at, id) ) WITH CLUSTERING ORDER BY (ready_at ASC, id ASC); + +-- Generation-fenced expiration queue. +-- `ttl_expiration_buckets` registers which (day bucket, key shard) partitions of +-- `ttl_expirations` hold work, so a sweep can find due partitions without +-- scanning. Queue rows carry the durable deletion state machine: PENDING, +-- CLAIMED, CLAIM_ABORTING, EFFECTS_APPLYING, EFFECTS_APPLIED. +CREATE TABLE IF NOT EXISTS ttl_expiration_buckets ( + table_id text, + generation uuid, + bucket bigint, + shard int, + PRIMARY KEY ((table_id), generation, bucket, shard) +) WITH CLUSTERING ORDER BY (generation ASC, bucket ASC, shard ASC); + +CREATE TABLE IF NOT EXISTS ttl_expirations ( + table_id text, + generation uuid, + bucket bigint, + shard int, + expires_at bigint, + key_hash text, + key_data text, + state text, + work_id uuid, + work_data text, + PRIMARY KEY ((table_id, generation, bucket, shard), expires_at, key_hash, key_data) +) WITH CLUSTERING ORDER BY (expires_at ASC, key_hash ASC, key_data ASC); + +-- Durable key-only reconciliation outbox. Rows are written in the same logged +-- batch as the base mutation, so an ordinary write's queue entry survives a +-- crash even if the in-batch queue mutation is lost. +CREATE TABLE IF NOT EXISTS ttl_reconcile_pending ( + worker_partition int, + id timeuuid, + table_id text, + account_id text, + table_name text, + key_data text, + PRIMARY KEY ((worker_partition), id) +) WITH CLUSTERING ORDER BY (id ASC); + +-- A repair marker is written at LOCAL_QUORUM before an active-generation queue +-- row can be conditionally destroyed. It remains here while the destroy outcome +-- is ambiguous. A definitive destroy response is handed off to +-- ttl_reconcile_pending before this marker is removed. The registry is bounded +-- to the 64 worker partitions and avoids 64 empty reads on every worker pass. +CREATE TABLE IF NOT EXISTS ttl_repair_inflight_partitions ( + worker_partition int PRIMARY KEY +); + +CREATE TABLE IF NOT EXISTS ttl_repair_inflight ( + worker_partition int, + repair_id uuid, + table_id text, + account_id text, + table_name text, + generation uuid, + key_data text, + created_at timestamp, + PRIMARY KEY ((worker_partition), repair_id) +); diff --git a/crates/storage-cassandra/src/cassandra_util.rs b/crates/storage-cassandra/src/cassandra_util.rs index 0f5aad72..93b0bedb 100644 --- a/crates/storage-cassandra/src/cassandra_util.rs +++ b/crates/storage-cassandra/src/cassandra_util.rs @@ -17,6 +17,30 @@ pub type CassandraSession = Session< RoundRobinLoadBalancingStrategy, >; +/// Execute a lightweight transaction with explicit regional quorum and serial +/// consistency. TTL claims and lifecycle metadata use this rather than the +/// driver's default consistency. +pub async fn query_lwt( + session: &CassandraSession, + query: &str, + values: QueryValues, +) -> Result { + use cdrs_tokio::consistency::Consistency; + use cdrs_tokio::statement::StatementParamsBuilder; + + let params = StatementParamsBuilder::new() + .with_consistency(Consistency::LocalQuorum) + .with_serial_consistency(Consistency::LocalSerial) + .with_values(values) + .build(); + session + .query_with_params(query, params) + .await + .map_err(|error| { + extenddb_storage::error::StorageError::Internal(format!("LWT query failed: {error}")) + }) +} + /// Trait for errors that can be constructed from database operation failures. /// Implemented by [`extenddb_storage::management_store::OpError`], /// [`extenddb_storage::error::StorageError`], and @@ -87,6 +111,42 @@ pub async fn query_rows( Ok(body.into_rows().unwrap_or_default()) } +/// Execute a query at `LOCAL_QUORUM` and return all rows. +/// +/// [`query_rows`] uses the driver's default consistency, which is `ONE`. Use +/// this instead wherever an *empty* result is treated as authoritative and acted +/// on destructively: at `ONE` a replica that has not yet received a write reads +/// as empty, and a decision made on that read cannot be undone by a timestamp +/// guard, because the write it missed is older than the guard. +pub async fn query_rows_quorum( + session: &Arc, + query: &str, + values: QueryValues, + context: &str, +) -> Result, E> { + use cdrs_tokio::consistency::Consistency; + use cdrs_tokio::statement::StatementParamsBuilder; + + let params = StatementParamsBuilder::new() + .with_consistency(Consistency::LocalQuorum) + .with_values(values) + .build(); + let result = session + .query_with_params(query, params) + .await + .map_err(|e| { + tracing::error!("{context} quorum query failed: {e}"); + E::db_error(format!("{context}: {e}")) + })?; + + let body = result.response_body().map_err(|e| { + tracing::error!("{context} response_body failed: {e}"); + E::db_error(format!("{context} response_body: {e}")) + })?; + + Ok(body.into_rows().unwrap_or_default()) +} + /// Execute a query and return the first row, if any. /// /// Similar to `sqlx::query().fetch_optional()`. @@ -124,6 +184,36 @@ pub async fn execute( Ok(()) } +/// Execute a non-query statement at `LOCAL_QUORUM`. +/// +/// Use this for writes that are prerequisites for a later quorum operation or +/// whose loss would make an absence decision unsafe. +/// +/// # Errors +/// Returns an error if Cassandra does not acknowledge the statement at quorum. +pub async fn execute_quorum( + session: &Arc, + query: &str, + values: QueryValues, + context: &str, +) -> Result<(), E> { + use cdrs_tokio::consistency::Consistency; + use cdrs_tokio::statement::StatementParamsBuilder; + + let params = StatementParamsBuilder::new() + .with_consistency(Consistency::LocalQuorum) + .with_values(values) + .build(); + session + .query_with_params(query, params) + .await + .map_err(|error| { + tracing::error!("{context} quorum execute failed: {error}"); + E::db_error(format!("{context}: {error}")) + })?; + Ok(()) +} + // ══════════════════════════════════════════════════════════════════════════════ // Type Conversion Helpers // ══════════════════════════════════════════════════════════════════════════════ diff --git a/crates/storage-cassandra/src/data/ddl.rs b/crates/storage-cassandra/src/data/ddl.rs index b4ef5158..dbb19b95 100644 --- a/crates/storage-cassandra/src/data/ddl.rs +++ b/crates/storage-cassandra/src/data/ddl.rs @@ -54,6 +54,27 @@ impl CassandraEngine { &self, account_id: &str, table_name: &str, + ) -> Result { + self.fetch_table_key_info_with_consistency(account_id, table_name, false) + .await + } + + /// Fetch table identity at `LOCAL_QUORUM` when absence or name reuse would + /// otherwise allow a durable repair obligation to be discarded. + pub(crate) async fn fetch_table_key_info_quorum( + &self, + account_id: &str, + table_name: &str, + ) -> Result { + self.fetch_table_key_info_with_consistency(account_id, table_name, true) + .await + } + + async fn fetch_table_key_info_with_consistency( + &self, + account_id: &str, + table_name: &str, + quorum: bool, ) -> Result { let catalog_keyspace = format!("{}_catalog", self.keyspace_prefix); @@ -61,20 +82,19 @@ impl CassandraEngine { "SELECT key_schema, attribute_definitions, table_status, table_id, stream_specification \ FROM {catalog_keyspace}.tables WHERE account_id = ? AND table_name = ?" ); - - let result = self - .session - .query_with_values(&query, cdrs_tokio::query_values!(account_id, table_name)) - .await - .map_err(|e| StorageError::Internal(format!("Query table: {e}")))?; - - let body = result - .response_body() - .map_err(|e| StorageError::Internal(format!("Parse response: {e}")))?; - - let rows = body - .into_rows() - .ok_or_else(|| StorageError::TableNotFound(table_name.to_owned()))?; + let values = cdrs_tokio::query_values!(account_id, table_name); + let rows = if quorum { + crate::cassandra_util::query_rows_quorum( + &self.session, + &query, + values, + "fetch_table_key_info", + ) + .await? + } else { + crate::cassandra_util::query_rows(&self.session, &query, values, "fetch_table_key_info") + .await? + }; let row = rows .into_iter() diff --git a/crates/storage-cassandra/src/data/delete_item.rs b/crates/storage-cassandra/src/data/delete_item.rs index 79542116..45e89a59 100644 --- a/crates/storage-cassandra/src/data/delete_item.rs +++ b/crates/storage-cassandra/src/data/delete_item.rs @@ -18,8 +18,69 @@ use super::{json_to_item, query_with_pk_sk}; use crate::CassandraEngine; use crate::stream_util::stream_record_statement; +/// Lifetime of the base-row claim an ordinary request holds while it commits +/// its logged batch. A request that is suspended for longer than this loses +/// its claim; `mutation_timestamp` is pinned immediately after the claim is +/// taken, so a resumed request's cells lose to anything a later owner commits +/// via Paxos (its item cells can still beat the older stored image, which is +/// why EFFECTS_APPLYING recovery fences on the owner rather than the image). +pub(crate) const TTL_REQUEST_CLAIM_SECONDS: u32 = 120; + +/// Lifetime of the base-row claim the expiration worker holds across its +/// deletion phases. This is bounded on purpose: `delete_ttl_base_exact` +/// conditions on both the work UUID *and* the exact item image, so an expired +/// claim can never let stale work delete a renewed item. An unbounded claim, +/// by contrast, blocks every write to the key forever if the queue row that +/// drives recovery is lost. +pub(crate) const TTL_WORK_CLAIM_SECONDS: u32 = 900; + +/// Bounded retries for acquiring a base-row claim. Contention is expected to +/// be short-lived (a claim is held for the duration of one logged batch), so +/// a few jittered attempts turn a client-visible conflict back into ordinary +/// last-writer-wins. +pub(crate) const TTL_CLAIM_MAX_RETRIES: u32 = 8; +const TTL_CLAIM_BASE_DELAY_MS: u64 = 4; +const TTL_CLAIM_EXP_CAP: u32 = 6; + +/// Sleep for a jittered, exponentially widening window before retrying a +/// contended claim. +pub(crate) async fn ttl_claim_backoff(attempt: u32) { + let window_ms = TTL_CLAIM_BASE_DELAY_MS * (1u64 << attempt.min(TTL_CLAIM_EXP_CAP)); + let sleep_ms = rand::random::() % window_ms.max(1); + tokio::time::sleep(std::time::Duration::from_millis(sleep_ms)).await; +} + +/// The error a single-item write reports when another owner holds the base row. +/// +/// On a TTL-enabled table `prepared_txn_id` is ambiguous: it may be a two-phase +/// commit prepare, another ordinary writer's claim, or the expiration worker's +/// claim. All three are short-lived, so the write reports the retryable +/// [`StorageError::TransactionConflict`] and the retry wrappers re-read and try +/// again. Only after those retries is the conflict surfaced, as DynamoDB's +/// `TransactionConflictException` (RFC-0003 §4.3). +/// +/// Without TTL the only possible owner is a real transaction, and the existing +/// `TransactionCanceledException` behaviour is preserved. +pub(crate) fn concurrent_owner_error(ttl_enabled: bool) -> StorageError { + if ttl_enabled { + return StorageError::TransactionConflict( + "Item is being modified by a concurrent operation".to_owned(), + ); + } + StorageError::TransactionCanceled(vec![extenddb_core::types::CancellationReason { + code: "TransactionConflict".to_owned(), + message: Some("Item is being modified by a concurrent transaction".to_owned()), + item: None, + }]) +} + impl CassandraEngine { /// Implementation of `DataEngine::delete_item`. + /// + /// On a TTL-enabled table the base-row claim can be lost to a concurrent + /// writer. That is ordinary contention, not a client error, so the whole + /// read-claim-commit sequence is retried against a freshly read image + /// before the conflict is surfaced. pub(crate) async fn delete_item_impl( &self, key_info: &TableKeyInfo, @@ -28,6 +89,102 @@ impl CassandraEngine { condition: Option<&Expr>, maps: &ExpressionMaps, stream: Option<&StreamCapture>, + ) -> Result, StorageError> { + for attempt in 0..=TTL_CLAIM_MAX_RETRIES { + match self + .delete_item_impl_inner( + key_info, key, return_old, condition, maps, stream, None, None, + ) + .await + { + Err(StorageError::TransactionConflict(message)) + if attempt == TTL_CLAIM_MAX_RETRIES => + { + return Err(StorageError::TransactionConflict(message)); + } + Err(StorageError::TransactionConflict(_)) => ttl_claim_backoff(attempt).await, + other => return other, + } + } + unreachable!("loop returns on the final attempt") + } + + /// Take the exact base-row claim that serializes an ordinary write against + /// TTL expiration of the same item. + /// + /// Returns [`StorageError::TransactionConflict`] when the claim is already + /// held or the image moved under us. That maps to DynamoDB's + /// `TransactionConflictException` (RFC-0003 §4.3) rather than + /// `TransactionCanceledException`, which single-item writes do not have. + /// Callers own the retry, because a moved image invalidates the batch they + /// have already built and they must re-read to rebuild it. + #[doc(hidden)] + pub async fn acquire_ttl_mutation_claim( + &self, + key_info: &TableKeyInfo, + key: &Item, + expected_item: Option<&Item>, + ) -> Result, StorageError> { + let claim = uuid::Uuid::new_v4(); + let applied = match expected_item { + Some(expected_item) => { + self.claim_ttl_item( + key_info, + key, + expected_item, + claim, + Some(TTL_REQUEST_CLAIM_SECONDS), + ) + .await? + } + None => { + self.claim_absent_ttl_item(key_info, key, claim, Some(TTL_REQUEST_CLAIM_SECONDS)) + .await? + } + }; + if applied { + Ok(Some(claim)) + } else { + Err(StorageError::TransactionConflict( + "Item is being modified by a concurrent operation".to_owned(), + )) + } + } + + /// Release an exact claim. Retried once, because a dropped release makes + /// the key unwritable until the claim's TTL expires. + #[doc(hidden)] + pub async fn release_ttl_mutation_claim( + &self, + key_info: &TableKeyInfo, + key: &Item, + claim: Option, + ) { + let Some(claim) = claim else { return }; + if self.release_ttl_claim(key_info, key, claim).await.is_ok() { + return; + } + if let Err(error) = self.release_ttl_claim(key_info, key, claim).await { + tracing::warn!( + table = %key_info.table_name, + "TTL claim release failed; the key is unwritable for up to {TTL_REQUEST_CLAIM_SECONDS}s: {error}" + ); + } + } + /// The transaction and TTL paths both need to drive a delete with an + /// explicitly permitted owner and an expected image, so this carries more + /// parameters than the lint allows. + #[allow(clippy::too_many_arguments)] + async fn delete_item_impl_inner( + &self, + key_info: &TableKeyInfo, + key: &Item, + return_old: bool, + condition: Option<&Expr>, + maps: &ExpressionMaps, + stream: Option<&StreamCapture>, + allowed_prepared_txn_id: Option, + expected_claimed_item: Option<&Item>, ) -> Result, StorageError> { let data_keyspace = self.account_keyspace(&key_info.account_id); let ddb_table = data_table_name(&key_info.table_id); @@ -35,8 +192,11 @@ impl CassandraEngine { let pk_text = composite_pk_to_text(key, &key_info.key_schema)?; let catalog_keyspace = self.catalog_keyspace(); - let indexes = - fetch_indexes_for_table(&key_info.table_id, &self.session, &catalog_keyspace).await?; + // Both are catalog reads with no data dependency; overlap them. + let (indexes, ttl_config) = futures::try_join!( + fetch_indexes_for_table(&key_info.table_id, &self.session, &catalog_keyspace), + self.ttl_config_for_table(&key_info.account_id, &key_info.table_name), + )?; let sys_delay = if indexes.is_empty() { 0 } else { @@ -66,31 +226,27 @@ impl CassandraEngine { .response_body() .map_err(|e| StorageError::Internal(format!("Parse response: {e}")))?; - let (old_item_opt, has_prepared_txn) = if let Some(rows) = body.into_rows() { + let (old_item_opt, prepared_txn_id_opt) = if let Some(rows) = body.into_rows() { if let Some(row) = rows.into_iter().next() { - let item_data: String = - crate::cassandra_util::get_column(&row, "item_data", "delete_item")?; + let item_data: Option = row.get_by_name("item_data").ok().flatten(); let prepared_txn_id: Option = row.get_by_name("prepared_txn_id").ok().flatten(); - (Some(json_to_item(item_data)?), prepared_txn_id.is_some()) + (item_data.map(json_to_item).transpose()?, prepared_txn_id) } else { - (None, false) + (None, None) } } else { - (None, false) + (None, None) }; - // Reject if item is part of an in-flight transaction - if has_prepared_txn { - return Err(StorageError::TransactionCanceled(vec![ - extenddb_core::types::CancellationReason { - code: "TransactionConflict".to_owned(), - message: Some( - "Item is being modified by a concurrent transaction".to_owned(), - ), - item: None, - }, - ])); + if let Some(expected_item) = expected_claimed_item { + if prepared_txn_id_opt != allowed_prepared_txn_id + || old_item_opt.as_ref() != Some(expected_item) + { + return Err(StorageError::ConditionFailed(old_item_opt)); + } + } else if prepared_txn_id_opt.is_some() { + return Err(concurrent_owner_error(ttl_config.is_some())); } // Evaluate condition against existing item (or empty if doesn't exist) @@ -114,14 +270,39 @@ impl CassandraEngine { } } + let ttl_claim = if allowed_prepared_txn_id.is_some() { + allowed_prepared_txn_id + } else if ttl_config.is_some() { + self.acquire_ttl_mutation_claim(key_info, key, old_item_opt.as_ref()) + .await? + } else { + None + }; + // Pinned here, immediately after the claim and before any further + // await, so it is strictly newer than the image this request owns + // yet strictly older than anything a later owner commits. A request + // suspended past its claim lifetime therefore loses to that later + // owner instead of overwriting it. + let mutation_timestamp = chrono::Utc::now().timestamp_micros(); + // Delete the item (with index updates if needed). let delete_cql = format!("DELETE FROM {data_keyspace}.{ddb_table} WHERE pk = ? AND {sk_col} = ?"); // Update partition_max_delete_timestamp before the batch (must precede delete). - if old_item_opt.is_some() { - self.update_partition_max_delete_timestamp(&data_keyspace, &ddb_table, &pk_text) - .await?; + if old_item_opt.is_some() + && let Err(error) = self + .update_partition_max_delete_timestamp_at( + &data_keyspace, + &ddb_table, + &pk_text, + mutation_timestamp / 1_000, + ) + .await + { + self.release_ttl_mutation_claim(key_info, key, ttl_claim) + .await; + return Err(error); } let stream_stmt = old_item_opt.as_ref().and_then(|_| { @@ -139,7 +320,11 @@ impl CassandraEngine { }) }); - if indexes.is_empty() && stream_stmt.is_none() { + if indexes.is_empty() + && stream_stmt.is_none() + && ttl_config.is_none() + && ttl_claim.is_none() + { query_with_pk_sk(&self.session, &delete_cql, pk_text.as_ref(), &sk).await?; } else { use super::index::sk_to_value; @@ -147,12 +332,14 @@ impl CassandraEngine { cdrs_tokio::types::value::Value::from(pk_text.as_str()), sk_to_value(&sk), ]); - let mut batch = BatchQueryBuilder::new() - .with_consistency(Consistency::LocalQuorum) - .add_query(delete_cql, delete_qv); + let mut batch = BatchQueryBuilder::new().with_consistency(Consistency::LocalQuorum); + if ttl_claim.is_some() { + batch = batch.with_timestamp(mutation_timestamp); + } + batch = batch.add_query(delete_cql, delete_qv); - if !indexes.is_empty() { - super::index::sync_indexes( + if !indexes.is_empty() + && let Err(error) = super::index::sync_indexes( &mut batch, &data_keyspace, &key_info.key_schema, @@ -161,11 +348,15 @@ impl CassandraEngine { old_item_opt.as_ref(), None, sys_delay, - )?; + ) + { + self.release_ttl_mutation_claim(key_info, key, ttl_claim) + .await; + return Err(error); } let async_enqueued = if !indexes.is_empty() { - super::index::enqueue_async_indexes( + match super::index::enqueue_async_indexes( &self.session, &mut batch, &data_keyspace, @@ -175,24 +366,57 @@ impl CassandraEngine { None, sys_delay, ) - .await? + .await + { + Ok(enqueued) => enqueued, + Err(error) => { + self.release_ttl_mutation_claim(key_info, key, ttl_claim) + .await; + return Err(error); + } + } } else { 0 }; + if let Some(config) = ttl_config.as_ref() + && let Err(error) = super::ttl::add_ttl_queue_mutations( + &mut batch, + &data_keyspace, + key_info, + &config.attribute, + config.generation, + old_item_opt.as_ref(), + None, + ) + { + self.release_ttl_mutation_claim(key_info, key, ttl_claim) + .await; + return Err(error); + } + if let Some(stmt) = stream_stmt { batch = batch.add_query(stmt, cdrs_tokio::query::QueryValues::SimpleValues(vec![])); } - self.session - .batch( - batch - .build() - .map_err(|e| StorageError::Internal(e.to_string()))?, - ) - .await - .map_err(|e| StorageError::Internal(format!("Batch execution: {e}")))?; + let built = match batch.build() { + Ok(built) => built, + Err(error) => { + self.release_ttl_mutation_claim(key_info, key, ttl_claim) + .await; + return Err(StorageError::Internal(error.to_string())); + } + }; + if let Err(error) = self.session.batch(built).await { + self.release_ttl_mutation_claim(key_info, key, ttl_claim) + .await; + return Err(StorageError::Internal(format!("Batch execution: {error}"))); + } + // The row tombstone carries the pinned timestamp, which + // releases the claim whenever the local clock is not behind the + // coordinator's; the detached exact release covers the residue. + self.spawn_release_ttl_claim(key_info, key, ttl_claim); if async_enqueued > 0 { self.gsi_queue.notify_workers(); @@ -216,27 +440,23 @@ impl CassandraEngine { ) .await?; - let (old_item_opt, has_prepared_txn) = if let Some(row) = row { - let item_data: String = - crate::cassandra_util::get_column(&row, "item_data", "delete_item")?; + let (old_item_opt, prepared_txn_id_opt) = if let Some(row) = row { + let item_data: Option = row.get_by_name("item_data").ok().flatten(); let prepared_txn_id: Option = row.get_by_name("prepared_txn_id").ok().flatten(); - (Some(json_to_item(item_data)?), prepared_txn_id.is_some()) + (item_data.map(json_to_item).transpose()?, prepared_txn_id) } else { - (None, false) + (None, None) }; - // Reject if item is part of an in-flight transaction - if has_prepared_txn { - return Err(StorageError::TransactionCanceled(vec![ - extenddb_core::types::CancellationReason { - code: "TransactionConflict".to_owned(), - message: Some( - "Item is being modified by a concurrent transaction".to_owned(), - ), - item: None, - }, - ])); + if let Some(expected_item) = expected_claimed_item { + if prepared_txn_id_opt != allowed_prepared_txn_id + || old_item_opt.as_ref() != Some(expected_item) + { + return Err(StorageError::ConditionFailed(old_item_opt)); + } + } else if prepared_txn_id_opt.is_some() { + return Err(concurrent_owner_error(ttl_config.is_some())); } // Evaluate condition against existing item (or empty if doesn't exist) @@ -260,13 +480,38 @@ impl CassandraEngine { } } + let ttl_claim = if allowed_prepared_txn_id.is_some() { + allowed_prepared_txn_id + } else if ttl_config.is_some() { + self.acquire_ttl_mutation_claim(key_info, key, old_item_opt.as_ref()) + .await? + } else { + None + }; + // Pinned here, immediately after the claim and before any further + // await, so it is strictly newer than the image this request owns + // yet strictly older than anything a later owner commits. A request + // suspended past its claim lifetime therefore loses to that later + // owner instead of overwriting it. + let mutation_timestamp = chrono::Utc::now().timestamp_micros(); + // Delete the item (with index updates if needed). let delete_cql = format!("DELETE FROM {data_keyspace}.{ddb_table} WHERE pk = ?"); // Update partition_max_delete_timestamp before the batch (must precede delete). - if old_item_opt.is_some() { - self.update_partition_max_delete_timestamp(&data_keyspace, &ddb_table, &pk_text) - .await?; + if old_item_opt.is_some() + && let Err(error) = self + .update_partition_max_delete_timestamp_at( + &data_keyspace, + &ddb_table, + &pk_text, + mutation_timestamp / 1_000, + ) + .await + { + self.release_ttl_mutation_claim(key_info, key, ttl_claim) + .await; + return Err(error); } let stream_stmt = old_item_opt.as_ref().and_then(|_| { @@ -284,7 +529,11 @@ impl CassandraEngine { }) }); - if indexes.is_empty() && stream_stmt.is_none() { + if indexes.is_empty() + && stream_stmt.is_none() + && ttl_config.is_none() + && ttl_claim.is_none() + { self.session .query_with_values(&delete_cql, cdrs_tokio::query_values!(pk_text.as_str())) .await @@ -293,12 +542,14 @@ impl CassandraEngine { let delete_qv = cdrs_tokio::query::QueryValues::SimpleValues(vec![ cdrs_tokio::types::value::Value::from(pk_text.as_str()), ]); - let mut batch = BatchQueryBuilder::new() - .with_consistency(Consistency::LocalQuorum) - .add_query(delete_cql, delete_qv); + let mut batch = BatchQueryBuilder::new().with_consistency(Consistency::LocalQuorum); + if ttl_claim.is_some() { + batch = batch.with_timestamp(mutation_timestamp); + } + batch = batch.add_query(delete_cql, delete_qv); - if !indexes.is_empty() { - super::index::sync_indexes( + if !indexes.is_empty() + && let Err(error) = super::index::sync_indexes( &mut batch, &data_keyspace, &key_info.key_schema, @@ -307,11 +558,15 @@ impl CassandraEngine { old_item_opt.as_ref(), None, sys_delay, - )?; + ) + { + self.release_ttl_mutation_claim(key_info, key, ttl_claim) + .await; + return Err(error); } let async_enqueued = if !indexes.is_empty() { - super::index::enqueue_async_indexes( + match super::index::enqueue_async_indexes( &self.session, &mut batch, &data_keyspace, @@ -321,24 +576,57 @@ impl CassandraEngine { None, sys_delay, ) - .await? + .await + { + Ok(enqueued) => enqueued, + Err(error) => { + self.release_ttl_mutation_claim(key_info, key, ttl_claim) + .await; + return Err(error); + } + } } else { 0 }; + if let Some(config) = ttl_config.as_ref() + && let Err(error) = super::ttl::add_ttl_queue_mutations( + &mut batch, + &data_keyspace, + key_info, + &config.attribute, + config.generation, + old_item_opt.as_ref(), + None, + ) + { + self.release_ttl_mutation_claim(key_info, key, ttl_claim) + .await; + return Err(error); + } + if let Some(stmt) = stream_stmt { batch = batch.add_query(stmt, cdrs_tokio::query::QueryValues::SimpleValues(vec![])); } - self.session - .batch( - batch - .build() - .map_err(|e| StorageError::Internal(e.to_string()))?, - ) - .await - .map_err(|e| StorageError::Internal(format!("Batch execution: {e}")))?; + let built = match batch.build() { + Ok(built) => built, + Err(error) => { + self.release_ttl_mutation_claim(key_info, key, ttl_claim) + .await; + return Err(StorageError::Internal(error.to_string())); + } + }; + if let Err(error) = self.session.batch(built).await { + self.release_ttl_mutation_claim(key_info, key, ttl_claim) + .await; + return Err(StorageError::Internal(format!("Batch execution: {error}"))); + } + // The row tombstone carries the pinned timestamp, which + // releases the claim whenever the local clock is not behind the + // coordinator's; the detached exact release covers the residue. + self.spawn_release_ttl_claim(key_info, key, ttl_claim); if async_enqueued > 0 { self.gsi_queue.notify_workers(); @@ -349,20 +637,676 @@ impl CassandraEngine { } } - /// Update `partition_max_delete_timestamp` for a partition using a two-step - /// LWT approach. This records the latest delete timestamp so that stale - /// transactions cannot re-create items that were deleted after they started. + pub(crate) async fn ensure_ttl_work_claim( + &self, + key_info: &TableKeyInfo, + key: &Item, + expected_item: &Item, + work_id: uuid::Uuid, + ) -> Result { + if self + .claim_ttl_item( + key_info, + key, + expected_item, + work_id, + Some(TTL_WORK_CLAIM_SECONDS), + ) + .await? + { + return Ok(true); + } + let keyspace = self.account_keyspace(&key_info.account_id); + let table = data_table_name(&key_info.table_id); + let pk = composite_pk_to_text(key, &key_info.key_schema)?; + let row = if let Some((sk_name, sk_type)) = + sk_info(&key_info.key_schema, &key_info.attribute_definitions) + { + let sk = parse_sk( + key.get(sk_name) + .ok_or_else(|| StorageError::Internal("missing sort key".to_owned()))?, + sk_type, + )?; + let query = format!( + "SELECT item_data, prepared_txn_id FROM {keyspace}.{table} \ + WHERE pk = ? AND {} = ?", + sk_column(sk_type) + ); + query_with_pk_sk(&self.session, &query, pk.as_str(), &sk) + .await? + .response_body() + .ok() + .and_then(|body| body.into_rows()) + .and_then(|rows| rows.into_iter().next()) + } else { + let query = + format!("SELECT item_data, prepared_txn_id FROM {keyspace}.{table} WHERE pk = ?"); + crate::cassandra_util::query_optional( + &self.session, + &query, + cdrs_tokio::query_values!(pk.as_str()), + "ensure_ttl_work_claim", + ) + .await? + }; + let Some(row) = row else { + return Ok(false); + }; + let item_data: Option = row.get_by_name("item_data").ok().flatten(); + let owner: Option = row.get_by_name("prepared_txn_id").ok().flatten(); + let expected = serde_json::to_string(expected_item) + .map_err(|error| StorageError::Internal(error.to_string()))?; + Ok(owner == Some(work_id) && item_data.as_deref() == Some(expected.as_str())) + } + + /// Promote the exact expiration worker owner and item image to a + /// non-expiring fence immediately before persisting must-complete intent. + /// Once the queue reaches `EFFECTS_APPLYING`, recovery owns releasing this + /// fence after effects and the exact base delete complete. + pub(crate) async fn seal_ttl_work_claim( + &self, + key_info: &TableKeyInfo, + key: &Item, + expected_item: &Item, + work_id: uuid::Uuid, + ) -> Result { + let keyspace = self.account_keyspace(&key_info.account_id); + let table = data_table_name(&key_info.table_id); + let pk = composite_pk_to_text(key, &key_info.key_schema)?; + let expected = serde_json::to_string(expected_item) + .map_err(|error| StorageError::Internal(error.to_string()))?; + let owner = cdrs_tokio::types::value::Bytes::new(work_id.as_bytes().to_vec()); + let claimed_at = chrono::Utc::now().timestamp_millis(); + let result = if let Some((sk_name, sk_type)) = + sk_info(&key_info.key_schema, &key_info.attribute_definitions) + { + let sk = parse_sk( + key.get(sk_name) + .ok_or_else(|| StorageError::Internal("missing sort key".to_owned()))?, + sk_type, + )?; + crate::cassandra_util::query_lwt( + &self.session, + &format!( + "UPDATE {keyspace}.{table} \ + SET prepared_txn_id = ?, prepared_txn_timestamp = ? \ + WHERE pk = ? AND {} = ? \ + IF prepared_txn_id = ? AND item_data = ?", + sk_column(sk_type) + ), + cdrs_tokio::query::QueryValues::SimpleValues(vec![ + cdrs_tokio::types::value::Value::from(owner.clone()), + cdrs_tokio::types::value::Value::from(claimed_at), + cdrs_tokio::types::value::Value::from(pk.as_str()), + super::index::sk_to_value(&sk), + cdrs_tokio::types::value::Value::from(owner), + cdrs_tokio::types::value::Value::from(expected.as_str()), + ]), + ) + .await? + } else { + crate::cassandra_util::query_lwt( + &self.session, + &format!( + "UPDATE {keyspace}.{table} \ + SET prepared_txn_id = ?, prepared_txn_timestamp = ? \ + WHERE pk = ? IF prepared_txn_id = ? AND item_data = ?" + ), + cdrs_tokio::query_values!( + owner.clone(), + claimed_at, + pk.as_str(), + owner, + expected.as_str() + ), + ) + .await? + }; + ttl_lwt_applied(&result) + } + + /// Whether the base row's exact owner is `work_id`, read at `LOCAL_QUORUM`. + /// + /// The image is deliberately not compared: `EFFECTS_APPLYING` recovery is + /// fenced on ownership alone, because a stale writer's batch can legally + /// change the image under a sealed owner. + pub(crate) async fn base_row_owned_by( + &self, + key_info: &TableKeyInfo, + key: &Item, + work_id: uuid::Uuid, + ) -> Result { + let keyspace = self.account_keyspace(&key_info.account_id); + let table = data_table_name(&key_info.table_id); + let pk = composite_pk_to_text(key, &key_info.key_schema)?; + let (query, values) = if let Some((sk_name, sk_type)) = + sk_info(&key_info.key_schema, &key_info.attribute_definitions) + { + let sk = parse_sk( + key.get(sk_name) + .ok_or_else(|| StorageError::Internal("missing sort key".to_owned()))?, + sk_type, + )?; + ( + format!( + "SELECT prepared_txn_id FROM {keyspace}.{table} \ + WHERE pk = ? AND {} = ?", + sk_column(sk_type) + ), + cdrs_tokio::query::QueryValues::SimpleValues(vec![ + cdrs_tokio::types::value::Value::from(pk.as_str()), + super::index::sk_to_value(&sk), + ]), + ) + } else { + ( + format!("SELECT prepared_txn_id FROM {keyspace}.{table} WHERE pk = ?"), + cdrs_tokio::query::QueryValues::SimpleValues(vec![ + cdrs_tokio::types::value::Value::from(pk.as_str()), + ]), + ) + }; + let rows = crate::cassandra_util::query_rows_quorum( + &self.session, + &query, + values, + "base_row_owned_by", + ) + .await?; + let owner: Option = rows + .first() + .and_then(|row| row.get_by_name("prepared_txn_id").ok().flatten()); + Ok(owner == Some(work_id)) + } + + /// Rebuild the synchronous index rows for a live item. /// - /// Step 1: Try to set the value if it's currently null (first delete in partition). - /// Step 2: If already set, update only if our timestamp is higher. - async fn update_partition_max_delete_timestamp( + /// Used when recovery replays a TTL delete's effects and then discovers the + /// base item was re-created by a writer whose cells predate the replayed + /// index tombstones: the item is live, but any index row sharing a key with + /// the old image has been deleted. Re-upserting from the current image at a + /// timestamp strictly above the recorded effects timestamp restores it. + /// Purely an index operation — no stream record, no queue mutation. + pub(crate) async fn restore_sync_indexes_for_item( + &self, + key_info: &TableKeyInfo, + item: &Item, + effects_timestamp_ms: i64, + ) -> Result<(), StorageError> { + let account_keyspace = self.account_keyspace(&key_info.account_id); + // Quorum: a stale replica omitting an index here would be read as + // "nothing to restore", and this is the last chance to restore it + // before the sealed owner is released. + let indexes = super::index::fetch_indexes_for_table_quorum( + &key_info.table_id, + &self.session, + &self.catalog_keyspace(), + ) + .await?; + if indexes.is_empty() { + return Ok(()); + } + let default_delay = self + .gsi_default_delay_ms + .load(std::sync::atomic::Ordering::Relaxed); + // Strictly above the replayed effects tombstones by construction: + // both sides derive from the same recorded value, so ordering does not + // depend on which coordinator assigns wall-clock timestamps. max() + // with now-derived micros keeps it also above any older write. + let restore_timestamp = + (effects_timestamp_ms * 1_000 + 1).max(chrono::Utc::now().timestamp_micros()); + let mut batch = BatchQueryBuilder::new() + .with_consistency(Consistency::LocalQuorum) + .with_timestamp(restore_timestamp); + // old = None: this is a pure re-upsert of the current image's rows. + super::index::sync_indexes( + &mut batch, + &account_keyspace, + &key_info.key_schema, + &key_info.attribute_definitions, + &indexes, + None, + Some(item), + default_delay, + )?; + super::index::enqueue_async_indexes( + &self.session, + &mut batch, + &account_keyspace, + key_info, + &indexes, + None, + Some(item), + default_delay, + ) + .await?; + let built = batch + .build() + .map_err(|error| StorageError::Internal(error.to_string()))?; + self.session + .batch(built) + .await + .map_err(|error| StorageError::Internal(format!("Restore sync indexes: {error}")))?; + self.gsi_queue.notify_workers(); + Ok(()) + } + + pub(crate) async fn apply_ttl_delete_effects( + &self, + key_info: &TableKeyInfo, + old_item: &Item, + work_id: uuid::Uuid, + delete_timestamp_ms: i64, + stream_plan: Option<&crate::data::ttl::TtlStreamPlan>, + ) -> Result<(), StorageError> { + let account_keyspace = self.account_keyspace(&key_info.account_id); + let table = data_table_name(&key_info.table_id); + let pk = composite_pk_to_text(old_item, &key_info.key_schema)?; + self.update_partition_max_delete_timestamp_at( + &account_keyspace, + &table, + &pk, + delete_timestamp_ms, + ) + .await?; + + let indexes = + fetch_indexes_for_table(&key_info.table_id, &self.session, &self.catalog_keyspace()) + .await?; + let default_delay = self + .gsi_default_delay_ms + .load(std::sync::atomic::Ordering::Relaxed); + // Stamped with the RECORDED delete timestamp so every replay writes + // identical cells at an identical timestamp, and so restoration (which + // stamps strictly above this) beats these tombstones by construction + // rather than by cross-coordinator clock luck. + let mut batch = BatchQueryBuilder::new() + .with_consistency(Consistency::LocalQuorum) + .with_timestamp(delete_timestamp_ms * 1_000); + let mut has_effects = !indexes.is_empty(); + if !indexes.is_empty() { + super::index::sync_indexes( + &mut batch, + &account_keyspace, + &key_info.key_schema, + &key_info.attribute_definitions, + &indexes, + Some(old_item), + None, + default_delay, + )?; + super::index::enqueue_async_indexes( + &self.session, + &mut batch, + &account_keyspace, + key_info, + &indexes, + Some(old_item), + None, + default_delay, + ) + .await?; + } + + if let Some(plan) = stream_plan { + let capture = extenddb_storage::StreamCapture { + view_type: plan.view_type, + user_identity: Some(extenddb_core::types::UserIdentity { + identity_type: "Service".to_owned(), + principal_id: "dynamodb.amazonaws.com".to_owned(), + }), + region: std::sync::Arc::from(plan.region.as_str()), + }; + let identity = crate::stream_util::StreamRecordIdentity { + event_id: plan.event_id.clone(), + sequence_number: plan.sequence_number.clone(), + created_at_ms: plan.created_at_ms, + }; + if let Some(statement) = crate::stream_util::stream_record_statement_with_identity( + &account_keyspace, + &key_info.table_id, + key_info, + Some(old_item), + None, + &capture, + &identity, + self.stream_retention_seconds, + ) { + batch = batch.add_query( + statement, + cdrs_tokio::query::QueryValues::SimpleValues(vec![]), + ); + has_effects = true; + } + } + + if has_effects { + let built = batch + .build() + .map_err(|error| StorageError::Internal(error.to_string()))?; + self.session + .batch(built) + .await + .map_err(|error| StorageError::Internal(format!("TTL side effects: {error}")))?; + if !indexes.is_empty() { + self.gsi_queue.notify_workers(); + } + } + let _ = work_id; + Ok(()) + } + + pub(crate) async fn delete_ttl_base_exact( + &self, + key_info: &TableKeyInfo, + key: &Item, + expected_item: &Item, + work_id: uuid::Uuid, + ) -> Result { + let keyspace = self.account_keyspace(&key_info.account_id); + let table = data_table_name(&key_info.table_id); + let pk = composite_pk_to_text(key, &key_info.key_schema)?; + let expected = serde_json::to_string(expected_item) + .map_err(|error| StorageError::Internal(error.to_string()))?; + let owner = cdrs_tokio::types::value::Bytes::new(work_id.as_bytes().to_vec()); + let result = if let Some((sk_name, sk_type)) = + sk_info(&key_info.key_schema, &key_info.attribute_definitions) + { + let sk = parse_sk( + key.get(sk_name) + .ok_or_else(|| StorageError::Internal("missing sort key".to_owned()))?, + sk_type, + )?; + let query = format!( + "DELETE FROM {keyspace}.{table} WHERE pk = ? AND {} = ? \ + IF prepared_txn_id = ? AND item_data = ?", + sk_column(sk_type) + ); + crate::cassandra_util::query_lwt( + &self.session, + &query, + cdrs_tokio::query::QueryValues::SimpleValues(vec![ + cdrs_tokio::types::value::Value::from(pk.as_str()), + super::index::sk_to_value(&sk), + cdrs_tokio::types::value::Value::from(owner), + cdrs_tokio::types::value::Value::from(expected.as_str()), + ]), + ) + .await? + } else { + let query = format!( + "DELETE FROM {keyspace}.{table} WHERE pk = ? \ + IF prepared_txn_id = ? AND item_data = ?" + ); + crate::cassandra_util::query_lwt( + &self.session, + &query, + cdrs_tokio::query_values!(pk.as_str(), owner, expected.as_str()), + ) + .await? + }; + ttl_lwt_applied(&result) + } + + async fn claim_absent_ttl_item( + &self, + key_info: &TableKeyInfo, + key: &Item, + claim: uuid::Uuid, + ttl_seconds: Option, + ) -> Result { + let keyspace = self.account_keyspace(&key_info.account_id); + let table = data_table_name(&key_info.table_id); + let pk = composite_pk_to_text(key, &key_info.key_schema)?; + let claim_bytes = cdrs_tokio::types::value::Bytes::new(claim.as_bytes().to_vec()); + let claimed_at = chrono::Utc::now().timestamp_millis(); + let using_ttl = ttl_seconds + .map(|seconds| format!("USING TTL {seconds} ")) + .unwrap_or_default(); + + let result = if let Some((sk_name, sk_type)) = + sk_info(&key_info.key_schema, &key_info.attribute_definitions) + { + let sk = parse_sk( + key.get(sk_name) + .ok_or_else(|| StorageError::Internal("missing sort key".to_owned()))?, + sk_type, + )?; + let query = format!( + "UPDATE {keyspace}.{table} {using_ttl}\ + SET prepared_txn_id = ?, prepared_txn_timestamp = ? \ + WHERE pk = ? AND {} = ? \ + IF prepared_txn_id = null AND item_data = null", + sk_column(sk_type) + ); + crate::cassandra_util::query_lwt( + &self.session, + &query, + cdrs_tokio::query::QueryValues::SimpleValues(vec![ + cdrs_tokio::types::value::Value::from(claim_bytes), + cdrs_tokio::types::value::Value::from(claimed_at), + cdrs_tokio::types::value::Value::from(pk.as_str()), + super::index::sk_to_value(&sk), + ]), + ) + .await + } else { + let query = format!( + "UPDATE {keyspace}.{table} {using_ttl}\ + SET prepared_txn_id = ?, prepared_txn_timestamp = ? \ + WHERE pk = ? IF prepared_txn_id = null AND item_data = null" + ); + crate::cassandra_util::query_lwt( + &self.session, + &query, + cdrs_tokio::query_values!(claim_bytes, claimed_at, pk.as_str()), + ) + .await + } + .map_err(|error| StorageError::Internal(format!("Claim absent TTL item: {error}")))?; + ttl_lwt_applied(&result) + } + + async fn claim_ttl_item( + &self, + key_info: &TableKeyInfo, + key: &Item, + expected_item: &Item, + claim: uuid::Uuid, + ttl_seconds: Option, + ) -> Result { + let keyspace = self.account_keyspace(&key_info.account_id); + let table = data_table_name(&key_info.table_id); + let pk = composite_pk_to_text(key, &key_info.key_schema)?; + let expected = serde_json::to_string(expected_item) + .map_err(|error| StorageError::Internal(error.to_string()))?; + let claim_bytes = cdrs_tokio::types::value::Bytes::new(claim.as_bytes().to_vec()); + let claimed_at = chrono::Utc::now().timestamp_millis(); + let using_ttl = ttl_seconds + .map(|seconds| format!("USING TTL {seconds} ")) + .unwrap_or_default(); + + let result = if let Some((sk_name, sk_type)) = + sk_info(&key_info.key_schema, &key_info.attribute_definitions) + { + let sk = parse_sk( + key.get(sk_name) + .ok_or_else(|| StorageError::Internal("missing sort key".to_owned()))?, + sk_type, + )?; + let query = format!( + "UPDATE {keyspace}.{table} {using_ttl}\ + SET prepared_txn_id = ?, prepared_txn_timestamp = ? \ + WHERE pk = ? AND {} = ? \ + IF prepared_txn_id = null AND item_data = ?", + sk_column(sk_type) + ); + crate::cassandra_util::query_lwt( + &self.session, + &query, + cdrs_tokio::query::QueryValues::SimpleValues(vec![ + cdrs_tokio::types::value::Value::from(claim_bytes), + cdrs_tokio::types::value::Value::from(claimed_at), + cdrs_tokio::types::value::Value::from(pk.as_str()), + super::index::sk_to_value(&sk), + cdrs_tokio::types::value::Value::from(expected.as_str()), + ]), + ) + .await + } else { + let query = format!( + "UPDATE {keyspace}.{table} {using_ttl}\ + SET prepared_txn_id = ?, prepared_txn_timestamp = ? \ + WHERE pk = ? IF prepared_txn_id = null AND item_data = ?" + ); + crate::cassandra_util::query_lwt( + &self.session, + &query, + cdrs_tokio::query_values!(claim_bytes, claimed_at, pk.as_str(), expected.as_str()), + ) + .await + } + .map_err(|error| StorageError::Internal(format!("Claim TTL item: {error}")))?; + ttl_lwt_applied(&result) + } + + /// Fire the exact conditional release off the request's latency path. + /// + /// A successful batch already wrote `prepared_txn_id = null` at its pinned + /// timestamp, which releases the claim whenever the pinned clock is not + /// behind the coordinator clock that stamped the claim cells. Clock + /// synchronization bounds that skew but does not order two clocks at + /// microsecond precision, so a trailing application clock could leave the + /// claim cells in place and the key unwritable until the claim's TTL. This + /// detached exact release closes that residue without putting a Paxos + /// round back on the request; if the in-batch nulls already won, the + /// condition simply does not match. + pub(crate) fn spawn_release_ttl_claim( + &self, + key_info: &TableKeyInfo, + key: &Item, + claim: Option, + ) { + let Some(claim) = claim else { return }; + let keyspace = self.account_keyspace(&key_info.account_id); + let table = data_table_name(&key_info.table_id); + let Ok(pk) = composite_pk_to_text(key, &key_info.key_schema) else { + return; + }; + let claim_bytes = cdrs_tokio::types::value::Bytes::new(claim.as_bytes().to_vec()); + let (query, values) = if let Some((sk_name, sk_type)) = + sk_info(&key_info.key_schema, &key_info.attribute_definitions) + { + let Some(sk_value) = key.get(sk_name) else { + return; + }; + let Ok(sk) = parse_sk(sk_value, sk_type) else { + return; + }; + ( + format!( + "UPDATE {keyspace}.{table} SET prepared_txn_id = null, \ + prepared_txn_timestamp = null WHERE pk = ? AND {} = ? \ + IF prepared_txn_id = ?", + sk_column(sk_type) + ), + cdrs_tokio::query::QueryValues::SimpleValues(vec![ + cdrs_tokio::types::value::Value::from(pk.as_str()), + super::index::sk_to_value(&sk), + cdrs_tokio::types::value::Value::from(claim_bytes), + ]), + ) + } else { + ( + format!( + "UPDATE {keyspace}.{table} SET prepared_txn_id = null, \ + prepared_txn_timestamp = null WHERE pk = ? IF prepared_txn_id = ?" + ), + cdrs_tokio::query::QueryValues::SimpleValues(vec![ + cdrs_tokio::types::value::Value::from(pk.as_str()), + cdrs_tokio::types::value::Value::from(claim_bytes), + ]), + ) + }; + // Bounded: if the fleet of in-flight releases is saturated (degraded + // LWT latency), drop this one rather than queueing without limit. The + // claim's own TTL bounds the residue, so dropping is safe. + let Ok(permit) = self.ttl_release_permits.clone().try_acquire_owned() else { + return; + }; + let session = self.session_arc(); + tokio::spawn(async move { + // Failure is benign: either the in-batch nulls already released, or + // the claim's own TTL bounds the residue. + let _ = tokio::time::timeout( + std::time::Duration::from_secs(10), + crate::cassandra_util::query_lwt(&session, &query, values), + ) + .await; + drop(permit); + }); + } + + pub(crate) async fn release_ttl_claim( + &self, + key_info: &TableKeyInfo, + key: &Item, + claim: uuid::Uuid, + ) -> Result<(), StorageError> { + let keyspace = self.account_keyspace(&key_info.account_id); + let table = data_table_name(&key_info.table_id); + let pk = composite_pk_to_text(key, &key_info.key_schema)?; + let claim_bytes = cdrs_tokio::types::value::Bytes::new(claim.as_bytes().to_vec()); + + if let Some((sk_name, sk_type)) = + sk_info(&key_info.key_schema, &key_info.attribute_definitions) + { + let sk = parse_sk( + key.get(sk_name) + .ok_or_else(|| StorageError::Internal("missing sort key".to_owned()))?, + sk_type, + )?; + let query = format!( + "UPDATE {keyspace}.{table} SET prepared_txn_id = null, prepared_txn_timestamp = null \ + WHERE pk = ? AND {} = ? IF prepared_txn_id = ?", + sk_column(sk_type) + ); + crate::cassandra_util::query_lwt( + &self.session, + &query, + cdrs_tokio::query::QueryValues::SimpleValues(vec![ + cdrs_tokio::types::value::Value::from(pk.as_str()), + super::index::sk_to_value(&sk), + cdrs_tokio::types::value::Value::from(claim_bytes), + ]), + ) + .await + } else { + let query = format!( + "UPDATE {keyspace}.{table} SET prepared_txn_id = null, prepared_txn_timestamp = null \ + WHERE pk = ? IF prepared_txn_id = ?" + ); + crate::cassandra_util::query_lwt( + &self.session, + &query, + cdrs_tokio::query_values!(pk.as_str(), claim_bytes), + ) + .await + } + .map_err(|error| StorageError::Internal(format!("Release TTL claim: {error}")))?; + Ok(()) + } + + /// Update `partition_max_delete_timestamp` for a partition using a two-step + /// LWT approach. This records the supplied stable delete timestamp so that + /// stale transactions cannot re-create items deleted after they started. + async fn update_partition_max_delete_timestamp_at( &self, keyspace: &str, table: &str, pk: &str, + now_ms: i64, ) -> Result<(), StorageError> { - let now_ms = crate::cassandra_util::now_millis(); - // Step 1: Try to set if null let query_null = format!( "UPDATE {keyspace}.{table} SET partition_max_delete_timestamp = ? WHERE pk = ? \ @@ -414,3 +1358,18 @@ impl CassandraEngine { Ok(()) } } + +fn ttl_lwt_applied(result: &cdrs_tokio::frame::Envelope) -> Result { + let rows = result + .response_body() + .map_err(|error| StorageError::Internal(format!("Parse TTL claim: {error}")))? + .into_rows() + .unwrap_or_default(); + let Some(row) = rows.first() else { + return Err(StorageError::Internal( + "TTL claim returned no LWT result".to_owned(), + )); + }; + row.get_r_by_name("[applied]") + .map_err(|error| StorageError::Internal(format!("Parse TTL claim result: {error}"))) +} diff --git a/crates/storage-cassandra/src/data/index.rs b/crates/storage-cassandra/src/data/index.rs index 7ec8df6d..782a6a13 100644 --- a/crates/storage-cassandra/src/data/index.rs +++ b/crates/storage-cassandra/src/data/index.rs @@ -40,19 +40,52 @@ pub async fn fetch_indexes_for_table( table_id: &str, session: &Arc, catalog_keyspace: &str, +) -> Result, StorageError> { + fetch_indexes_for_table_at(table_id, session, catalog_keyspace, false).await +} + +/// [`fetch_indexes_for_table`] at `LOCAL_QUORUM`. +/// +/// For paths where a stale-empty answer is acted on destructively or treated +/// as "nothing to restore": at the default consistency a lagging replica can +/// omit an index, and a must-complete restoration that trusts that omission +/// leaves a live item permanently missing an index row. +pub async fn fetch_indexes_for_table_quorum( + table_id: &str, + session: &Arc, + catalog_keyspace: &str, +) -> Result, StorageError> { + fetch_indexes_for_table_at(table_id, session, catalog_keyspace, true).await +} + +async fn fetch_indexes_for_table_at( + table_id: &str, + session: &Arc, + catalog_keyspace: &str, + quorum: bool, ) -> Result, StorageError> { let query = format!( "SELECT index_name, index_id, index_type, key_schema, projection, propagation_delay_ms \ FROM {catalog_keyspace}.indexes WHERE table_id = ?" ); - let rows = query_rows( - session, - &query, - query_values!(table_id), - "fetch_indexes_for_table", - ) - .await?; + let rows = if quorum { + crate::cassandra_util::query_rows_quorum( + session, + &query, + query_values!(table_id), + "fetch_indexes_for_table", + ) + .await? + } else { + query_rows( + session, + &query, + query_values!(table_id), + "fetch_indexes_for_table", + ) + .await? + }; rows.into_iter() .map(|row| { @@ -195,7 +228,7 @@ pub(crate) fn item_has_index_keys(item: &Item, index_ks: &[KeySchemaElement]) -> /// Compute the effective propagation delay for an index. /// /// Per-GSI setting overrides the system default. `Some(0)` = sync, `None` = use default. -pub(super) fn effective_delay(idx: &IndexMeta, system_default: u64) -> u64 { +pub(crate) fn effective_delay(idx: &IndexMeta, system_default: u64) -> u64 { match idx.propagation_delay_ms { Some(0) => 0, #[allow(clippy::cast_sign_loss)] diff --git a/crates/storage-cassandra/src/data/mod.rs b/crates/storage-cassandra/src/data/mod.rs index 19c6f05a..94f3bf81 100644 --- a/crates/storage-cassandra/src/data/mod.rs +++ b/crates/storage-cassandra/src/data/mod.rs @@ -24,6 +24,7 @@ mod query_helpers; mod scan; pub mod transaction_ledger; mod transactions; +pub(crate) mod ttl; mod update_item; use condition::check_condition; @@ -137,7 +138,10 @@ pub(crate) async fn query_with_pk_sk_item( } SortKeyValue::B(b) => { session - .query_with_values(query, cdrs_tokio::query_values!(pk, Blob::new(b.clone()), item_text)) + .query_with_values( + query, + cdrs_tokio::query_values!(pk, Blob::new(b.clone()), item_text), + ) .await } } @@ -170,7 +174,10 @@ pub(crate) async fn query_with_pk_sk_txnid( } SortKeyValue::B(b) => { session - .query_with_values(query, cdrs_tokio::query_values!(pk, Blob::new(b.clone()), txn_id)) + .query_with_values( + query, + cdrs_tokio::query_values!(pk, Blob::new(b.clone()), txn_id), + ) .await } } @@ -256,7 +263,13 @@ pub(crate) async fn query_with_pk_sk_item_txnid_ts( session .query_with_values( query, - cdrs_tokio::query_values!(pk, Blob::new(b.clone()), item_text, txn_id, txn_timestamp), + cdrs_tokio::query_values!( + pk, + Blob::new(b.clone()), + item_text, + txn_id, + txn_timestamp + ), ) .await } @@ -303,7 +316,13 @@ pub(crate) async fn query_with_item_ts_pk_sk_txnid( session .query_with_values( query, - cdrs_tokio::query_values!(item_text, txn_timestamp, pk, Blob::new(b.clone()), txn_id), + cdrs_tokio::query_values!( + item_text, + txn_timestamp, + pk, + Blob::new(b.clone()), + txn_id + ), ) .await } diff --git a/crates/storage-cassandra/src/data/put_get_item.rs b/crates/storage-cassandra/src/data/put_get_item.rs index fa902dcf..7616b2b1 100644 --- a/crates/storage-cassandra/src/data/put_get_item.rs +++ b/crates/storage-cassandra/src/data/put_get_item.rs @@ -19,6 +19,11 @@ use crate::stream_util::stream_record_statement; impl CassandraEngine { /// Implementation of `DataEngine::put_item`. + /// + /// On a TTL-enabled table the base-row claim can be lost to a concurrent + /// writer. That is ordinary contention, not a client error, so the whole + /// read-claim-commit sequence is retried against a freshly read image + /// before the conflict is surfaced. pub(crate) async fn put_item_impl( &self, key_info: &TableKeyInfo, @@ -27,6 +32,34 @@ impl CassandraEngine { condition: Option<&Expr>, maps: &ExpressionMaps, stream: Option<&StreamCapture>, + ) -> Result, StorageError> { + use super::delete_item::{TTL_CLAIM_MAX_RETRIES, ttl_claim_backoff}; + + for attempt in 0..=TTL_CLAIM_MAX_RETRIES { + match self + .put_item_impl_inner(key_info, item.clone(), return_old, condition, maps, stream) + .await + { + Err(StorageError::TransactionConflict(message)) + if attempt == TTL_CLAIM_MAX_RETRIES => + { + return Err(StorageError::TransactionConflict(message)); + } + Err(StorageError::TransactionConflict(_)) => ttl_claim_backoff(attempt).await, + other => return other, + } + } + unreachable!("loop returns on the final attempt") + } + + async fn put_item_impl_inner( + &self, + key_info: &TableKeyInfo, + item: Item, + return_old: bool, + condition: Option<&Expr>, + maps: &ExpressionMaps, + stream: Option<&StreamCapture>, ) -> Result, StorageError> { let data_keyspace = self.account_keyspace(&key_info.account_id); let catalog_keyspace = self.catalog_keyspace(); @@ -39,12 +72,15 @@ impl CassandraEngine { let item_text = item_json.to_string(); // Fetch indexes for GSI/LSI updates - let indexes = super::index::fetch_indexes_for_table( - &key_info.table_id, - &self.session, - &catalog_keyspace, - ) - .await?; + // Both are catalog reads with no data dependency; overlap them. + let (indexes, ttl_config) = futures::try_join!( + super::index::fetch_indexes_for_table( + &key_info.table_id, + &self.session, + &catalog_keyspace, + ), + self.ttl_config_for_table(&key_info.account_id, &key_info.table_name), + )?; let sys_delay = if indexes.is_empty() { 0 } else { @@ -55,7 +91,7 @@ impl CassandraEngine { // Always use read-then-write path to check prepared_txn_id for transaction safety. // This ensures non-transactional writes cannot corrupt in-flight transactions. // - // Exception: attribute_not_exists() maps directly to INSERT IF NOT EXISTS, + // Exception: attribute_not_exists() maps directly to a null-aware LWT, // which is atomic without a prior read. // Collect key attribute names for attribute_not_exists detection. @@ -65,7 +101,8 @@ impl CassandraEngine { .map(|k| k.attribute_name.as_str()) .collect(); - if is_attribute_not_exists_key(condition, &key_attr_names) { + let key_not_exists_condition = is_attribute_not_exists_key(condition, &key_attr_names); + if key_not_exists_condition && ttl_config.is_none() { return self .put_item_if_not_exists( key_info, @@ -105,12 +142,13 @@ impl CassandraEngine { let (old_item_opt, has_prepared_txn) = if let Some(rows) = body.into_rows() { if let Some(row) = rows.into_iter().next() { - let item_data: String = row - .get_r_by_name("item_data") - .map_err(|e| StorageError::Internal(format!("Parse item_data: {e}")))?; + let item_data: Option = row.get_by_name("item_data").ok().flatten(); let prepared_txn_id: Option = row.get_by_name("prepared_txn_id").ok().flatten(); - (Some(json_to_item(item_data)?), prepared_txn_id.is_some()) + ( + item_data.map(json_to_item).transpose()?, + prepared_txn_id.is_some(), + ) } else { (None, false) } @@ -120,15 +158,9 @@ impl CassandraEngine { // Reject if item is part of an in-flight transaction if has_prepared_txn { - return Err(StorageError::TransactionCanceled(vec![ - extenddb_core::types::CancellationReason { - code: "TransactionConflict".to_owned(), - message: Some( - "Item is being modified by a concurrent transaction".to_owned(), - ), - item: None, - }, - ])); + return Err(super::delete_item::concurrent_owner_error( + ttl_config.is_some(), + )); } // Evaluate condition against existing item (or empty if doesn't exist) @@ -158,10 +190,13 @@ impl CassandraEngine { ) }); - if indexes.is_empty() && stream_stmt.is_none() { + if indexes.is_empty() && stream_stmt.is_none() && ttl_config.is_none() { // Fast path: no batch needed. let insert_query = format!( - "INSERT INTO {data_keyspace}.{ddb_table} (pk, {sk_col}, item_data) VALUES (?, ?, ?)" + "INSERT INTO {}.{} \ + (pk, {}, item_data) \ + VALUES (?, ?, ?)", + data_keyspace, ddb_table, sk_col ); query_with_pk_sk_item( &self.session, @@ -173,9 +208,25 @@ impl CassandraEngine { .await?; } else { // LOGGED BATCH: item insert + optional index updates + optional stream record. - let insert_cql = format!( - "INSERT INTO {data_keyspace}.{ddb_table} (pk, {sk_col}, item_data) VALUES (?, ?, ?)" - ); + // On a claimed row the base write also clears the claim columns. + // The batch timestamp is pinned after claim acquisition, so these + // nulls lose to any newer owner's Paxos cells exactly as the item + // cells do — no separate release round trip is needed on success. + let insert_cql = if ttl_config.is_some() { + format!( + "INSERT INTO {}.{} \ + (pk, {}, item_data, prepared_txn_id, prepared_txn_timestamp) \ + VALUES (?, ?, ?, null, null)", + data_keyspace, ddb_table, sk_col + ) + } else { + format!( + "INSERT INTO {}.{} \ + (pk, {}, item_data) \ + VALUES (?, ?, ?)", + data_keyspace, ddb_table, sk_col + ) + }; let insert_qv = cdrs_tokio::query::QueryValues::SimpleValues(vec![ cdrs_tokio::types::value::Value::from(pk_text.as_str()), super::index::sk_to_value(&sk), @@ -214,19 +265,71 @@ impl CassandraEngine { 0 }; + if let Some(config) = ttl_config.as_ref() { + super::ttl::add_ttl_reconciliation_mutation( + &mut batch, + &data_keyspace, + key_info, + &config.attribute, + &item, + )?; + super::ttl::add_ttl_queue_mutations( + &mut batch, + &data_keyspace, + key_info, + &config.attribute, + config.generation, + old_item_opt.as_ref(), + Some(&item), + )?; + } + if let Some(stmt) = stream_stmt { batch = batch.add_query(stmt, cdrs_tokio::query::QueryValues::SimpleValues(vec![])); } - self.session - .batch( - batch - .build() - .map_err(|e| StorageError::Internal(e.to_string()))?, - ) - .await - .map_err(|e| StorageError::Internal(format!("Batch execution: {e}")))?; + // Acquire only after every fallible side-effect statement has been + // prepared, then release this exact claim on every remaining path. + let ttl_claim = if ttl_config.is_some() { + match self + .acquire_ttl_mutation_claim(key_info, &item, old_item_opt.as_ref()) + .await + { + Ok(claim) => claim, + // An absent-row claim that cannot be taken means the row + // now exists, which is exactly what this condition + // forbids. Report the condition failure rather than + // retrying a write that can never apply. + Err(StorageError::TransactionConflict(_)) if key_not_exists_condition => { + return Err(StorageError::ConditionFailed(old_item_opt)); + } + Err(error) => return Err(error), + } + } else { + None + }; + if let Some(timestamp) = ttl_claim.map(|_| chrono::Utc::now().timestamp_micros()) { + batch = batch.with_timestamp(timestamp); + } + + let built = match batch.build() { + Ok(built) => built, + Err(error) => { + self.release_ttl_mutation_claim(key_info, &item, ttl_claim) + .await; + return Err(StorageError::Internal(error.to_string())); + } + }; + if let Err(error) = self.session.batch(built).await { + self.release_ttl_mutation_claim(key_info, &item, ttl_claim) + .await; + return Err(StorageError::Internal(format!("Batch execution: {error}"))); + } + // The batch itself released the claim at its pinned timestamp; + // the detached exact release covers a trailing local clock + // without costing the request a Paxos round. + self.spawn_release_ttl_claim(key_info, &item, ttl_claim); if async_enqueued > 0 { self.gsi_queue.notify_workers(); @@ -257,12 +360,13 @@ impl CassandraEngine { let (old_item_opt, has_prepared_txn) = if let Some(rows) = body.into_rows() { if let Some(row) = rows.into_iter().next() { - let item_data: String = row - .get_r_by_name("item_data") - .map_err(|e| StorageError::Internal(format!("Parse item_data: {e}")))?; + let item_data: Option = row.get_by_name("item_data").ok().flatten(); let prepared_txn_id: Option = row.get_by_name("prepared_txn_id").ok().flatten(); - (Some(json_to_item(item_data)?), prepared_txn_id.is_some()) + ( + item_data.map(json_to_item).transpose()?, + prepared_txn_id.is_some(), + ) } else { (None, false) } @@ -272,15 +376,9 @@ impl CassandraEngine { // Reject if item is part of an in-flight transaction if has_prepared_txn { - return Err(StorageError::TransactionCanceled(vec![ - extenddb_core::types::CancellationReason { - code: "TransactionConflict".to_owned(), - message: Some( - "Item is being modified by a concurrent transaction".to_owned(), - ), - item: None, - }, - ])); + return Err(super::delete_item::concurrent_owner_error( + ttl_config.is_some(), + )); } // Evaluate condition @@ -310,10 +408,13 @@ impl CassandraEngine { ) }); - if indexes.is_empty() && stream_stmt.is_none() { + if indexes.is_empty() && stream_stmt.is_none() && ttl_config.is_none() { // Fast path: no batch needed. let insert_query = format!( - "INSERT INTO {data_keyspace}.{ddb_table} (pk, item_data) VALUES (?, ?)" + "INSERT INTO {}.{} \ + (pk, item_data) \ + VALUES (?, ?)", + data_keyspace, ddb_table ); self.session .query_with_values( @@ -324,9 +425,23 @@ impl CassandraEngine { .map_err(|e| StorageError::Internal(format!("Insert item: {e}")))?; } else { // LOGGED BATCH: item insert + optional index updates + optional stream record. - let insert_cql = format!( - "INSERT INTO {data_keyspace}.{ddb_table} (pk, item_data) VALUES (?, ?)" - ); + // On a claimed row the base write also clears the claim columns + // (see the sort-key path for the timestamp reasoning). + let insert_cql = if ttl_config.is_some() { + format!( + "INSERT INTO {}.{} \ + (pk, item_data, prepared_txn_id, prepared_txn_timestamp) \ + VALUES (?, ?, null, null)", + data_keyspace, ddb_table + ) + } else { + format!( + "INSERT INTO {}.{} \ + (pk, item_data) \ + VALUES (?, ?)", + data_keyspace, ddb_table + ) + }; let insert_qv = cdrs_tokio::query::QueryValues::SimpleValues(vec![ cdrs_tokio::types::value::Value::from(pk_text.as_str()), item_text.as_str().into(), @@ -364,19 +479,71 @@ impl CassandraEngine { 0 }; + if let Some(config) = ttl_config.as_ref() { + super::ttl::add_ttl_reconciliation_mutation( + &mut batch, + &data_keyspace, + key_info, + &config.attribute, + &item, + )?; + super::ttl::add_ttl_queue_mutations( + &mut batch, + &data_keyspace, + key_info, + &config.attribute, + config.generation, + old_item_opt.as_ref(), + Some(&item), + )?; + } + if let Some(stmt) = stream_stmt { batch = batch.add_query(stmt, cdrs_tokio::query::QueryValues::SimpleValues(vec![])); } - self.session - .batch( - batch - .build() - .map_err(|e| StorageError::Internal(e.to_string()))?, - ) - .await - .map_err(|e| StorageError::Internal(format!("Batch execution: {e}")))?; + // Acquire only after every fallible side-effect statement has been + // prepared, then release this exact claim on every remaining path. + let ttl_claim = if ttl_config.is_some() { + match self + .acquire_ttl_mutation_claim(key_info, &item, old_item_opt.as_ref()) + .await + { + Ok(claim) => claim, + // An absent-row claim that cannot be taken means the row + // now exists, which is exactly what this condition + // forbids. Report the condition failure rather than + // retrying a write that can never apply. + Err(StorageError::TransactionConflict(_)) if key_not_exists_condition => { + return Err(StorageError::ConditionFailed(old_item_opt)); + } + Err(error) => return Err(error), + } + } else { + None + }; + if let Some(timestamp) = ttl_claim.map(|_| chrono::Utc::now().timestamp_micros()) { + batch = batch.with_timestamp(timestamp); + } + + let built = match batch.build() { + Ok(built) => built, + Err(error) => { + self.release_ttl_mutation_claim(key_info, &item, ttl_claim) + .await; + return Err(StorageError::Internal(error.to_string())); + } + }; + if let Err(error) = self.session.batch(built).await { + self.release_ttl_mutation_claim(key_info, &item, ttl_claim) + .await; + return Err(StorageError::Internal(format!("Batch execution: {error}"))); + } + // The batch itself released the claim at its pinned timestamp; + // the detached exact release covers a trailing local clock + // without costing the request a Paxos round. + self.spawn_release_ttl_claim(key_info, &item, ttl_claim); if async_enqueued > 0 { self.gsi_queue.notify_workers(); @@ -389,10 +556,16 @@ impl CassandraEngine { /// Atomic `put_item` for the `attribute_not_exists()` condition. /// - /// Uses `INSERT ... IF NOT EXISTS` — no prior read needed. Returns - /// `ConditionFailed` (with no old item, since the item didn't exist) if - /// another writer got there first. + /// Uses a null-aware LWT so both a physically absent row and a metadata-only + /// row are treated as logically absent. Returns `ConditionFailed` if another + /// writer creates the item first. #[allow(clippy::too_many_arguments)] + /// Insert an item only if its key does not exist. + /// + /// Reached only when TTL is disabled: a TTL-enabled table cannot use this + /// fast path, because absence has to be established by the exact base-row + /// claim instead of by `IF NOT EXISTS`. There is therefore no TTL queue or + /// reconciliation work to do here. async fn put_item_if_not_exists( &self, key_info: &TableKeyInfo, @@ -416,23 +589,24 @@ impl CassandraEngine { let sk = parse_sk(sk_value, sk_type)?; let sk_col = sk_column(sk_type); let cql = format!( - "INSERT INTO {data_keyspace}.{ddb_table} (pk, {sk_col}, item_data, version) \ - VALUES (?, ?, ?, 1) IF NOT EXISTS" + "UPDATE {data_keyspace}.{ddb_table} SET item_data = ?, version = 1 \ + WHERE pk = ? AND {sk_col} = ? \ + IF item_data = null AND prepared_txn_id = null" ); let qv = cdrs_tokio::query::QueryValues::SimpleValues(vec![ + item_text.into(), cdrs_tokio::types::value::Value::from(pk_text), super::index::sk_to_value(&sk), - item_text.into(), ]); (cql, qv) } else { let cql = format!( - "INSERT INTO {data_keyspace}.{ddb_table} (pk, item_data, version) \ - VALUES (?, ?, 1) IF NOT EXISTS" + "UPDATE {data_keyspace}.{ddb_table} SET item_data = ?, version = 1 \ + WHERE pk = ? IF item_data = null AND prepared_txn_id = null" ); let qv = cdrs_tokio::query::QueryValues::SimpleValues(vec![ - cdrs_tokio::types::value::Value::from(pk_text), item_text.into(), + cdrs_tokio::types::value::Value::from(pk_text), ]); (cql, qv) }; @@ -467,71 +641,134 @@ impl CassandraEngine { return Err(StorageError::ConditionFailed(existing)); } - // INSERT applied — fire index/stream updates if needed. - if !indexes.is_empty() || stream.is_some() { - let stream_stmt = stream.and_then(|cap| { - stream_record_statement( - data_keyspace, - &key_info.table_id, - key_info, - None, - Some(&item), - cap, - &self.hlc, - self.stream_retention_seconds, - ) - }); - - let mut batch = BatchQueryBuilder::new().with_consistency(Consistency::LocalQuorum); - - if !indexes.is_empty() { - super::index::sync_indexes( - &mut batch, - data_keyspace, - &key_info.key_schema, - &key_info.attribute_definitions, - indexes, - None, - Some(&item), - sys_delay, - )?; - } + // The LWT linearizes the insert. Persist every secondary effect and a + // durable TTL reconciliation outbox entry in the following LOGGED BATCH. + let stream_stmt = stream.and_then(|cap| { + stream_record_statement( + data_keyspace, + &key_info.table_id, + key_info, + None, + Some(&item), + cap, + &self.hlc, + self.stream_retention_seconds, + ) + }); + if indexes.is_empty() && stream_stmt.is_none() { + return Ok(None); + } + let mut batch = BatchQueryBuilder::new().with_consistency(Consistency::LocalQuorum); + + if !indexes.is_empty() { + super::index::sync_indexes( + &mut batch, + data_keyspace, + &key_info.key_schema, + &key_info.attribute_definitions, + indexes, + None, + Some(&item), + sys_delay, + )?; + } - let async_enqueued = if !indexes.is_empty() { - super::index::enqueue_async_indexes( - &self.session, - &mut batch, - data_keyspace, - key_info, - indexes, - None, - Some(&item), - sys_delay, - ) - .await? - } else { - 0 - }; + let async_enqueued = if !indexes.is_empty() { + super::index::enqueue_async_indexes( + &self.session, + &mut batch, + data_keyspace, + key_info, + indexes, + None, + Some(&item), + sys_delay, + ) + .await? + } else { + 0 + }; - if let Some(stmt) = stream_stmt { - batch = batch.add_query(stmt, cdrs_tokio::query::QueryValues::SimpleValues(vec![])); - } + if let Some(stmt) = stream_stmt { + batch = batch.add_query(stmt, cdrs_tokio::query::QueryValues::SimpleValues(vec![])); + } - if let Ok(built) = batch.build() { - self.session.batch(built).await.map_err(|e| { - StorageError::Internal(format!("put_item_if_not_exists batch: {e}")) - })?; - } + let built = batch + .build() + .map_err(|error| StorageError::Internal(error.to_string()))?; + self.session.batch(built).await.map_err(|error| { + StorageError::Internal(format!("put_item_if_not_exists batch: {error}")) + })?; - if async_enqueued > 0 { - self.gsi_queue.notify_workers(); - } + if async_enqueued > 0 { + self.gsi_queue.notify_workers(); } - Ok(None) // return_old is always None — item didn't exist } /// Implementation of `DataEngine::get_item`. + /// Read an item at `LOCAL_QUORUM`. + /// + /// [`Self::get_item_impl`] reads at the driver's default consistency, which is + /// correct for an eventually-consistent `GetItem` but not for a TTL decision: + /// the expiration worker and the reconciliation outbox both treat `None` as + /// authoritative and act on it destructively — retiring queue work, or + /// dropping an outbox row — and a lagging replica reads as `None` for an item + /// that exists. Acting on that would leave the item with no expiration entry + /// and therefore never expiring. + /// + /// All TTL writes commit at `LOCAL_QUORUM`, so a `LOCAL_QUORUM` read + /// intersects them and cannot miss a committed item. + pub(crate) async fn get_item_quorum( + &self, + key_info: &TableKeyInfo, + key: &Item, + ) -> Result, StorageError> { + let data_keyspace = self.account_keyspace(&key_info.account_id); + let ddb_table = data_table_name(&key_info.table_id); + let pk_text = composite_pk_to_text(key, &key_info.key_schema)?; + + let (query, values) = if let Some((sk_name, sk_type)) = + sk_info(&key_info.key_schema, &key_info.attribute_definitions) + { + let sk_value = key + .get(sk_name) + .ok_or_else(|| StorageError::Internal("missing sort key".to_owned()))?; + let sk = parse_sk(sk_value, sk_type)?; + let sk_col = sk_column(sk_type); + ( + format!( + "SELECT item_data FROM {data_keyspace}.{ddb_table} \ + WHERE pk = ? AND {sk_col} = ?" + ), + cdrs_tokio::query::QueryValues::SimpleValues(vec![ + cdrs_tokio::types::value::Value::from(pk_text.as_str()), + super::index::sk_to_value(&sk), + ]), + ) + } else { + ( + format!("SELECT item_data FROM {data_keyspace}.{ddb_table} WHERE pk = ?"), + cdrs_tokio::query::QueryValues::SimpleValues(vec![ + cdrs_tokio::types::value::Value::from(pk_text.as_str()), + ]), + ) + }; + + let rows = crate::cassandra_util::query_rows_quorum( + &self.session, + &query, + values, + "get_item_quorum", + ) + .await?; + let Some(row) = rows.into_iter().next() else { + return Ok(None); + }; + let item_data: Option = row.get_by_name("item_data").ok().flatten(); + item_data.map(json_to_item).transpose() + } + pub(crate) async fn get_item_impl( &self, key_info: &TableKeyInfo, @@ -569,10 +806,8 @@ impl CassandraEngine { if let Some(rows) = body.into_rows() && let Some(row) = rows.into_iter().next() { - let item_data: String = row - .get_r_by_name("item_data") - .map_err(|e| StorageError::Internal(format!("Parse item_data: {e}")))?; - return Ok(Some(json_to_item(item_data)?)); + let item_data: Option = row.get_by_name("item_data").ok().flatten(); + return item_data.map(json_to_item).transpose(); } Ok(None) @@ -593,10 +828,8 @@ impl CassandraEngine { if let Some(rows) = body.into_rows() && let Some(row) = rows.into_iter().next() { - let item_data: String = row - .get_r_by_name("item_data") - .map_err(|e| StorageError::Internal(format!("Parse item_data: {e}")))?; - return Ok(Some(json_to_item(item_data)?)); + let item_data: Option = row.get_by_name("item_data").ok().flatten(); + return item_data.map(json_to_item).transpose(); } Ok(None) @@ -608,8 +841,8 @@ impl CassandraEngine { /// `` resolves to one of the names in `key_attr_names` after applying /// expression name substitutions from `maps`. /// -/// This is the only condition we can map directly to `INSERT ... IF NOT EXISTS`, -/// which is atomic without a prior read. +/// This is the only condition we map directly to a null-aware LWT without a +/// prior read. pub(crate) fn is_attribute_not_exists_key( condition: Option<&Expr>, key_attr_names: &[&str], diff --git a/crates/storage-cassandra/src/data/query_helpers.rs b/crates/storage-cassandra/src/data/query_helpers.rs index 2e1bbe01..243058b3 100644 --- a/crates/storage-cassandra/src/data/query_helpers.rs +++ b/crates/storage-cassandra/src/data/query_helpers.rs @@ -163,7 +163,12 @@ pub(super) async fn query_with_pk_sk_pk_sk( cassandra_util::query_rows( session, query, - query_values!(pk, super::decimal_to_value(isk), base_pk, Blob::new(bsk.clone())), + query_values!( + pk, + super::decimal_to_value(isk), + base_pk, + Blob::new(bsk.clone()) + ), label, ) .await @@ -181,7 +186,12 @@ pub(super) async fn query_with_pk_sk_pk_sk( cassandra_util::query_rows( session, query, - query_values!(pk, Blob::new(isk.clone()), base_pk, super::decimal_to_value(bsk)), + query_values!( + pk, + Blob::new(isk.clone()), + base_pk, + super::decimal_to_value(bsk) + ), label, ) .await @@ -317,7 +327,12 @@ pub(super) async fn query_with_pk_sk_sk_sk( cassandra_util::query_rows( session, query, - cdrs_tokio::query_values!(pk, Blob::new(b1.clone()), Blob::new(b2.clone()), Blob::new(b3.clone())), + cdrs_tokio::query_values!( + pk, + Blob::new(b1.clone()), + Blob::new(b2.clone()), + Blob::new(b3.clone()) + ), label, ) .await diff --git a/crates/storage-cassandra/src/data/scan.rs b/crates/storage-cassandra/src/data/scan.rs index 57fe107f..3deffde6 100644 --- a/crates/storage-cassandra/src/data/scan.rs +++ b/crates/storage-cassandra/src/data/scan.rs @@ -49,6 +49,7 @@ //! the "token range pagination" approach approved in the workplan. use cdrs_tokio::query::QueryValues; +use cdrs_tokio::types::IntoRustByName; use cdrs_tokio::types::blob::Blob; use cdrs_tokio::types::value::Value; use extenddb_core::types::{Item, ScalarAttributeType, TableKeyInfo}; @@ -223,10 +224,15 @@ impl crate::CassandraEngine { let items: Vec = rows .into_iter() .map(|row| { - let json_str: String = cassandra_util::get_column(&row, "item_data", "scan parse")?; - json_to_item(json_str) + let item_data: Option = row.get_by_name("item_data").map_err(|error| { + StorageError::Internal(format!("scan parse item_data failed: {error}")) + })?; + item_data.map(json_to_item).transpose() }) - .collect::, _>>()?; + .collect::, StorageError>>()? + .into_iter() + .flatten() + .collect(); // Enforce the limit and derive the LastEvaluatedKey from the last item. let has_more = items.len() > actual_limit; diff --git a/crates/storage-cassandra/src/data/transactions.rs b/crates/storage-cassandra/src/data/transactions.rs index 0f94ece1..e097c085 100644 --- a/crates/storage-cassandra/src/data/transactions.rs +++ b/crates/storage-cassandra/src/data/transactions.rs @@ -158,7 +158,10 @@ impl CassandraEngine { return Ok(None); }; - let item_data: String = get_column(&row, "item_data", "read_item_with_metadata")?; + let item_data: Option = row.get_by_name("item_data").ok().flatten(); + let Some(item_data) = item_data else { + return Ok(None); + }; let item: Item = serde_json::from_str(&item_data).map_err(|e| StorageError::Internal(e.to_string()))?; let last_committed: Option = row @@ -182,7 +185,7 @@ impl CassandraEngine { &self.session, &keyspace, &table, - "last_committed_txn_timestamp, prepared_txn_id", + "item_data, last_committed_txn_timestamp, prepared_txn_id", pk_text.as_str(), sk.as_ref(), sk_col.as_deref(), @@ -197,6 +200,10 @@ impl CassandraEngine { .ok() .flatten(); let prepared_txn_id: Option = row.get_by_name("prepared_txn_id").ok().flatten(); + let item_data: Option = row.get_by_name("item_data").ok().flatten(); + if item_data.is_none() && prepared_txn_id.is_none() { + return Ok(None); + } Ok(Some((last_committed.unwrap_or(0), prepared_txn_id))) } @@ -552,6 +559,27 @@ impl CassandraEngine { self.update_ledger_state(account_keyspace, txn_id, TransactionState::Committing) .await?; + // Capture the pre-commit image of every item on a TTL-enabled table. + // Reconciliation needs it to retire the expiration entry the item had + // *before* this transaction: unlike an ordinary write, a transactional + // write cannot carry the queue delete in the same batch as the base + // mutation, so without this a changed or removed TTL leaves its old + // entry behind until that entry's original due time. + let mut pre_commit_images: Vec> = Vec::with_capacity(ops.len()); + for op in ops { + let image = match op { + TransactWriteOp::Put { key_info, item, .. } => { + self.pre_commit_ttl_image(key_info, item).await? + } + TransactWriteOp::Update { key_info, key, .. } + | TransactWriteOp::Delete { key_info, key, .. } => { + self.pre_commit_ttl_image(key_info, key).await? + } + TransactWriteOp::ConditionCheck { .. } => None, + }; + pre_commit_images.push(image); + } + // TODO: Execute COMMIT operations for each item in parallel // For now, sequential execution for op in ops { @@ -559,6 +587,25 @@ impl CassandraEngine { .await?; } + for (op, old_image) in ops.iter().zip(&pre_commit_images) { + match op { + TransactWriteOp::Put { key_info, item, .. } => { + self.reconcile_ttl_transition(key_info, old_image.as_ref(), Some(item)) + .await?; + } + TransactWriteOp::Update { key_info, key, .. } => { + let new_image = self.get_item_quorum(key_info, key).await?; + self.reconcile_ttl_transition(key_info, old_image.as_ref(), new_image.as_ref()) + .await?; + } + TransactWriteOp::Delete { key_info, .. } => { + self.reconcile_ttl_transition(key_info, old_image.as_ref(), None) + .await?; + } + TransactWriteOp::ConditionCheck { .. } => {} + } + } + // Delete transaction from ledger self.delete_ledger_entry(account_keyspace, txn_id).await?; @@ -804,7 +851,10 @@ impl CassandraEngine { return Ok(None); }; - let item_data: String = get_column(&row, "item_data", "fetch_item_for_transaction")?; + let item_data: Option = row.get_by_name("item_data").ok().flatten(); + let Some(item_data) = item_data else { + return Ok(None); + }; Ok(Some( serde_json::from_str(&item_data).map_err(|e| StorageError::Internal(e.to_string()))?, )) @@ -1243,6 +1293,22 @@ impl CassandraEngine { return Err(e); } } + for op in &ops { + if matches!(op.op.as_str(), "PUT" | "UPDATE") + && let Some(item_data) = op.item_data.as_deref() + { + // Recovery can only re-register the committed image: + // the ledger does not persist the pre-commit image, + // so a transaction that crashes between COMMIT and + // reconciliation can leave the item's previous + // expiration entry in the queue. That entry is + // harmless — the worker revalidates the item before + // deleting anything and retires the entry when it + // comes due — but it is queue garbage until then. + self.reconcile_ttl_item_by_table_id(&op.table_id, item_data) + .await?; + } + } } // PREPARING or CANCELLING (or unknown) → rollback _ => { diff --git a/crates/storage-cassandra/src/data/ttl.rs b/crates/storage-cassandra/src/data/ttl.rs new file mode 100644 index 00000000..0081d64f --- /dev/null +++ b/crates/storage-cassandra/src/data/ttl.rs @@ -0,0 +1,1482 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Cassandra TTL expiration queue helpers. +//! +//! DynamoDB TTL cannot use Cassandra native row TTL because native expiry +//! bypasses secondary-index cleanup and DynamoDB Streams. Valid timestamps are +//! indexed into day-bucketed, 64-way sharded partitions and are deleted through +//! the normal item mutation path. + +use cdrs_tokio::query::BatchQueryBuilder; +use extenddb_core::types::{AttributeValue, Item, TableKeyInfo}; +use extenddb_storage::error::StorageError; + +use crate::CassandraEngine; + +pub(crate) const TTL_SHARDS: i32 = 64; +pub(crate) const TTL_BUCKET_SECONDS: i64 = 86_400; +const TTL_QUEUE_TABLE: &str = "ttl_expirations"; +const TTL_BUCKET_TABLE: &str = "ttl_expiration_buckets"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct TtlConfig { + pub attribute: String, + pub generation: uuid::Uuid, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct TtlEntry { + pub bucket: i64, + pub expires_at: i64, + pub shard: i32, + pub key_hash: String, + pub key_data: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TtlWorkState { + Pending, + Claimed, + ClaimAborting, + EffectsApplying, + EffectsApplied, +} + +impl TtlWorkState { + /// Parse a persisted state string. + /// + /// An unrecognised value is an error rather than a silent downgrade to + /// `PENDING`: a row written by a newer state machine must not be re-claimed + /// and re-executed by an older one. + pub(crate) fn parse(value: Option<&str>) -> Result { + match value { + Some("CLAIMED") => Ok(Self::Claimed), + Some("CLAIM_ABORTING") => Ok(Self::ClaimAborting), + Some("EFFECTS_APPLYING") => Ok(Self::EffectsApplying), + Some("EFFECTS_APPLIED") => Ok(Self::EffectsApplied), + Some("PENDING") | None => Ok(Self::Pending), + Some(other) => Err(StorageError::Internal(format!( + "Unknown TTL work state {other:?}" + ))), + } + } +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub(crate) struct TtlStreamPlan { + pub event_id: String, + pub sequence_number: String, + pub created_at_ms: i64, + pub region: String, + pub view_type: extenddb_core::types::StreamViewType, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub(crate) struct TtlWorkData { + pub old_item: Item, + pub delete_timestamp_ms: i64, + pub stream: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct TtlWorkRow { + pub entry: TtlEntry, + pub state: TtlWorkState, + pub work_id: Option, + pub work_data: Option, +} + +pub(crate) fn ttl_epoch_seconds(item: &Item, attribute: &str) -> Option { + match item.get(attribute) { + Some(AttributeValue::N(value)) => value.parse::().ok().filter(|value| *value > 0), + + _ => None, + } +} + +pub(crate) fn entry_for_item( + key_info: &TableKeyInfo, + item: &Item, + ttl_attribute: &str, +) -> Result, StorageError> { + let Some(expires_at) = ttl_epoch_seconds(item, ttl_attribute) else { + return Ok(None); + }; + + let mut key = Item::new(); + for element in &key_info.key_schema { + let value = item.get(&element.attribute_name).ok_or_else(|| { + StorageError::Internal(format!( + "TTL item missing key attribute {}", + element.attribute_name + )) + })?; + key.insert(element.attribute_name.clone(), value.clone()); + } + + let key_data = serde_json::to_string(&key) + .map_err(|error| StorageError::Internal(format!("Serialize TTL key: {error}")))?; + let hash = crc32fast::hash(key_data.as_bytes()); + + Ok(Some(TtlEntry { + bucket: expires_at / TTL_BUCKET_SECONDS, + expires_at, + shard: (hash % TTL_SHARDS as u32) as i32, + key_hash: format!("{hash:08x}"), + key_data, + })) +} + +fn same_queue_key(left: &TtlEntry, right: &TtlEntry) -> bool { + left.bucket == right.bucket + && left.expires_at == right.expires_at + && left.shard == right.shard + && left.key_hash == right.key_hash + && left.key_data == right.key_data +} + +/// Append a statement to a batch held behind a mutable reference. +/// +/// `BatchQueryBuilder::add_query` consumes the builder, so appending through a +/// `&mut` needs a swap. Doing that inline at each call site buried the statements +/// being built. +fn push_query(batch: &mut BatchQueryBuilder, cql: String, values: cdrs_tokio::query::QueryValues) { + let previous = std::mem::replace(batch, BatchQueryBuilder::new()); + *batch = previous.add_query(cql, values); +} + +pub(crate) fn add_ttl_queue_mutations( + batch: &mut BatchQueryBuilder, + account_keyspace: &str, + key_info: &TableKeyInfo, + ttl_attribute: &str, + generation: uuid::Uuid, + old_item: Option<&Item>, + new_item: Option<&Item>, +) -> Result<(), StorageError> { + let old_entry = old_item + .map(|item| entry_for_item(key_info, item, ttl_attribute)) + .transpose()? + .flatten(); + let new_entry = new_item + .map(|item| entry_for_item(key_info, item, ttl_attribute)) + .transpose()? + .flatten(); + let unchanged = matches!( + (&old_entry, &new_entry), + (Some(old), Some(new)) if same_queue_key(old, new) + ); + + if let Some(old) = old_entry.filter(|_| !unchanged) { + let cql = format!( + "DELETE FROM {account_keyspace}.{TTL_QUEUE_TABLE} \ + WHERE table_id = ? AND generation = ? AND bucket = ? AND shard = ? \ + AND expires_at = ? AND key_hash = ? AND key_data = ?" + ); + let values = cdrs_tokio::query_values!( + key_info.table_id.as_str(), + cdrs_tokio::types::value::Bytes::new(generation.as_bytes().to_vec()), + old.bucket, + old.shard, + old.expires_at, + old.key_hash.as_str(), + old.key_data.as_str() + ); + push_query(batch, cql, values); + } + + if let Some(new) = new_entry { + let bucket_cql = format!( + "INSERT INTO {account_keyspace}.{TTL_BUCKET_TABLE} \ + (table_id, generation, bucket, shard) VALUES (?, ?, ?, ?)" + ); + push_query( + batch, + bucket_cql, + cdrs_tokio::query_values!( + key_info.table_id.as_str(), + cdrs_tokio::types::value::Bytes::new(generation.as_bytes().to_vec()), + new.bucket, + new.shard + ), + ); + + let entry_cql = format!( + "INSERT INTO {account_keyspace}.{TTL_QUEUE_TABLE} \ + (table_id, generation, bucket, shard, expires_at, key_hash, key_data, \ + state, work_id, work_data) VALUES (?, ?, ?, ?, ?, ?, ?, 'PENDING', null, null)" + ); + push_query( + batch, + entry_cql, + cdrs_tokio::query_values!( + key_info.table_id.as_str(), + cdrs_tokio::types::value::Bytes::new(generation.as_bytes().to_vec()), + new.bucket, + new.shard, + new.expires_at, + new.key_hash.as_str(), + new.key_data.as_str() + ), + ); + } + + Ok(()) +} + +/// Add the durable reconciliation-outbox row for a write, when one is needed. +/// +/// The outbox is not a duplicate of the in-batch queue mutations. The queue +/// table takes concurrent Paxos writes from the expiration worker: a worker +/// that claimed the same queue key can supersede the batch's insert by cell +/// timestamp, and its abort path row-deletes with a tombstone that erases the +/// batch's insert outright. If the worker then crashes before its compensating +/// reconcile, the item is left with no expiration entry and nothing else would +/// ever rebuild one. The outbox row survives until a quorum-authoritative +/// reconciliation restores both the bucket registration and queue row. +/// Destroyers first write a separate non-dischargeable inflight marker; after +/// a definitive LWT response they hand it to this normal outbox before removing +/// the marker, so an ambiguous late destroy can never outlive its obligation. +/// +/// A write whose item carries no valid TTL adds nothing to the queue, so there +/// is nothing to repair and no row is written. The old entry, if any, is only +/// ever *removed* by this write, and a lost removal is inert: the sweep +/// revalidates the item before deleting anything and retires the stale entry. +pub(crate) fn add_ttl_reconciliation_mutation( + batch: &mut BatchQueryBuilder, + account_keyspace: &str, + key_info: &TableKeyInfo, + ttl_attribute: &str, + item: &Item, +) -> Result<(), StorageError> { + if ttl_epoch_seconds(item, ttl_attribute).is_none() { + return Ok(()); + } + let mut key = Item::new(); + for element in &key_info.key_schema { + let value = item.get(&element.attribute_name).ok_or_else(|| { + StorageError::Internal(format!( + "TTL reconciliation item missing key attribute {}", + element.attribute_name + )) + })?; + key.insert(element.attribute_name.clone(), value.clone()); + } + let key_data = serde_json::to_string(&key).map_err(|error| { + StorageError::Internal(format!("Serialize TTL reconciliation key: {error}")) + })?; + let partition = (crc32fast::hash(key_data.as_bytes()) % TTL_SHARDS as u32) as i32; + let cql = format!( + "INSERT INTO {account_keyspace}.ttl_reconcile_pending \ + (worker_partition, id, table_id, account_id, table_name, key_data) \ + VALUES (?, now(), ?, ?, ?, ?)" + ); + push_query( + batch, + cql, + cdrs_tokio::query_values!( + partition, + key_info.table_id.as_str(), + key_info.account_id.as_str(), + key_info.table_name.as_str(), + key_data.as_str() + ), + ); + Ok(()) +} + +/// Durably record that `key` may need its queue entry rebuilt before an +/// active-generation queue-row destroy is attempted. +/// +/// The marker is acknowledged at `LOCAL_QUORUM` before the destroy starts. It +/// is not age-dischargeable: an ambiguous LWT error or process crash leaves it +/// behind, and the worker repeatedly reconciles its key so even an arbitrarily +/// late destroy is repaired on a later pass. After a definitive applied +/// true/false response, [`TtlRepairGuard::complete`] first writes a normal +/// dischargeable outbox record at quorum and only then removes this marker. +pub(crate) async fn record_ttl_repair( + engine: &CassandraEngine, + account_keyspace: &str, + key_info: &TableKeyInfo, + generation: uuid::Uuid, + key_data: &str, +) -> Result { + let worker_partition = (crc32fast::hash(key_data.as_bytes()) % TTL_SHARDS as u32) as i32; + // Per-attempt on purpose: a marker shared between two concurrent destroy + // attempts would let one attempt's definitive completion delete the marker + // guarding the other's still-ambiguous LWT — and a late retire can land on + // a re-created PENDING row, so "same condition already read false" does not + // make the second attempt harmless. Markers accumulate one per *ambiguous + // incident*, not per write, and are surfaced by the repair worker's metric. + let repair_id = uuid::Uuid::new_v4(); + + // The registry row and the marker are one LOGGED batch at LOCAL_QUORUM: + // one round trip, both mutations durable together, so every acknowledged + // marker is discoverable without scanning empty partitions. Re-writing the + // registry row with each marker (it is a single-cell upsert) is what makes + // account deletion + same-id recreation safe in a multi-host fleet — a + // process-lifetime "already registered" cache on any one host would go + // stale the moment another host dropped and recreated the keyspace. + let batch = cdrs_tokio::query::BatchQueryBuilder::new() + .with_consistency(cdrs_tokio::consistency::Consistency::LocalQuorum) + .add_query( + format!( + "INSERT INTO {account_keyspace}.ttl_repair_inflight_partitions \ + (worker_partition) VALUES (?)" + ), + cdrs_tokio::query_values!(worker_partition), + ) + .add_query( + format!( + "INSERT INTO {account_keyspace}.ttl_repair_inflight \ + (worker_partition, repair_id, table_id, account_id, table_name, generation, key_data, created_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?, toTimestamp(now()))" + ), + cdrs_tokio::query_values!( + worker_partition, + cdrs_tokio::types::value::Bytes::new(repair_id.as_bytes().to_vec()), + key_info.table_id.as_str(), + key_info.account_id.as_str(), + key_info.table_name.as_str(), + cdrs_tokio::types::value::Bytes::new(generation.as_bytes().to_vec()), + key_data + ), + ); + let built = batch + .build() + .map_err(|error| StorageError::Internal(format!("Build TTL repair batch: {error}")))?; + engine + .session + .batch(built) + .await + .map_err(|error| StorageError::Internal(format!("Record TTL repair inflight: {error}")))?; + + Ok(TtlRepairGuard { + worker_partition, + repair_id, + table_id: key_info.table_id.clone(), + account_id: key_info.account_id.clone(), + table_name: key_info.table_name.clone(), + key_data: key_data.to_owned(), + }) +} + +/// A durable marker for one queue-row destroy attempt. +#[must_use = "a definitive destroy result must complete the repair handoff"] +pub(crate) struct TtlRepairGuard { + worker_partition: i32, + repair_id: uuid::Uuid, + table_id: String, + account_id: String, + table_name: String, + key_data: String, +} + +impl TtlRepairGuard { + /// Hand a definitively completed destroy to the normal reconciliation + /// outbox, then remove the non-dischargeable inflight marker. + /// + /// The ordering is the safety property: every crash point leaves at least + /// one quorum-durable obligation. If marker deletion fails, both records + /// remain and reconciliation is merely repeated. + pub(crate) async fn complete( + self, + engine: &CassandraEngine, + account_keyspace: &str, + ) -> Result<(), StorageError> { + crate::cassandra_util::execute_quorum( + &engine.session, + &format!( + "INSERT INTO {account_keyspace}.ttl_reconcile_pending \ + (worker_partition, id, table_id, account_id, table_name, key_data) \ + VALUES (?, now(), ?, ?, ?, ?)" + ), + cdrs_tokio::query_values!( + self.worker_partition, + self.table_id.as_str(), + self.account_id.as_str(), + self.table_name.as_str(), + self.key_data.as_str() + ), + "complete TTL repair outbox handoff", + ) + .await?; + + crate::cassandra_util::execute_quorum( + &engine.session, + &format!( + "DELETE FROM {account_keyspace}.ttl_repair_inflight \ + WHERE worker_partition = ? AND repair_id = ?" + ), + cdrs_tokio::query_values!( + self.worker_partition, + cdrs_tokio::types::value::Bytes::new(self.repair_id.as_bytes().to_vec()) + ), + "complete TTL repair inflight marker", + ) + .await + } +} + +async fn ensure_ttl_bucket_registration( + engine: &CassandraEngine, + account_keyspace: &str, + table_id: &str, + generation: uuid::Uuid, + entry: &TtlEntry, +) -> Result<(), StorageError> { + let generation_bytes = cdrs_tokio::types::value::Bytes::new(generation.as_bytes().to_vec()); + crate::cassandra_util::execute_quorum( + &engine.session, + &format!( + "INSERT INTO {account_keyspace}.{TTL_BUCKET_TABLE} \ + (table_id, generation, bucket, shard) VALUES (?, ?, ?, ?)" + ), + cdrs_tokio::query_values!( + table_id, + generation_bytes.clone(), + entry.bucket, + entry.shard + ), + "restore TTL bucket registration", + ) + .await?; + let visible = crate::cassandra_util::query_rows_quorum( + &engine.session, + &format!( + "SELECT shard FROM {account_keyspace}.{TTL_BUCKET_TABLE} \ + WHERE table_id = ? AND generation = ? AND bucket = ? AND shard = ?" + ), + cdrs_tokio::query_values!(table_id, generation_bytes, entry.bucket, entry.shard), + "confirm restored TTL bucket registration", + ) + .await?; + if visible.is_empty() { + return Err(StorageError::Transient( + "TTL bucket restoration is not yet visible at quorum".to_owned(), + )); + } + Ok(()) +} + +pub(crate) async fn insert_ttl_entry( + engine: &CassandraEngine, + account_keyspace: &str, + table_id: &str, + generation: uuid::Uuid, + entry: &TtlEntry, +) -> Result<(), StorageError> { + let generation_bytes = cdrs_tokio::types::value::Bytes::new(generation.as_bytes().to_vec()); + + // Restore discoverability before the fast path, then confirm it again after + // the queue row is known durable so a concurrent empty-partition retirement + // cannot be the last mutation. + ensure_ttl_bucket_registration(engine, account_keyspace, table_id, generation, entry).await?; + + // Common case after restoring discoverability: the entry is already there + // because the write's own logged batch inserted it. A plain quorum read + // avoids Paxos when that row is still PENDING. + let existing = crate::cassandra_util::query_rows_quorum( + &engine.session, + &format!( + "SELECT state FROM {account_keyspace}.{TTL_QUEUE_TABLE} \ + WHERE table_id = ? AND generation = ? AND bucket = ? AND shard = ? \ + AND expires_at = ? AND key_hash = ? AND key_data = ?" + ), + cdrs_tokio::query_values!( + table_id, + generation_bytes.clone(), + entry.bucket, + entry.shard, + entry.expires_at, + entry.key_hash.as_str(), + entry.key_data.as_str() + ), + "ttl_reconcile_precheck", + ) + .await?; + if let Some(row) = existing.first() { + use cdrs_tokio::types::IntoRustByName; + let state: Option = row.get_by_name("state").ok().flatten(); + if TtlWorkState::parse(state.as_deref())? == TtlWorkState::Pending { + ensure_ttl_bucket_registration(engine, account_keyspace, table_id, generation, entry) + .await?; + return Ok(()); + } + // Claimed work owns this key and may row-delete it while crashing + // before its compensating reconcile. Reporting success here would + // remove the outbox row that repairs exactly that, so stay retryable. + return Err(StorageError::Transient( + "TTL reconciliation deferred by in-flight expiration work".to_owned(), + )); + } + + let entry_cql = format!( + "INSERT INTO {account_keyspace}.{TTL_QUEUE_TABLE} \ + (table_id, generation, bucket, shard, expires_at, key_hash, key_data, state) \ + VALUES (?, ?, ?, ?, ?, ?, ?, 'PENDING') IF NOT EXISTS" + ); + let result = crate::cassandra_util::query_lwt( + &engine.session, + &entry_cql, + cdrs_tokio::query_values!( + table_id, + generation_bytes, + entry.bucket, + entry.shard, + entry.expires_at, + entry.key_hash.as_str(), + entry.key_data.as_str() + ), + ) + .await?; + let rows = result + .response_body() + .map_err(|error| StorageError::Internal(format!("Parse TTL reconcile LWT: {error}")))? + .into_rows() + .unwrap_or_default(); + let Some(row) = rows.first() else { + return Err(StorageError::Internal( + "TTL reconcile LWT returned no result".to_owned(), + )); + }; + use cdrs_tokio::types::IntoRustByName; + let applied: bool = row + .get_r_by_name("[applied]") + .map_err(|error| StorageError::Internal(format!("Parse TTL reconcile result: {error}")))?; + if applied { + ensure_ttl_bucket_registration(engine, account_keyspace, table_id, generation, entry) + .await?; + return Ok(()); + } + let state: Option = row.get_by_name("state").ok().flatten(); + if state.as_deref() == Some("PENDING") { + ensure_ttl_bucket_registration(engine, account_keyspace, table_id, generation, entry) + .await?; + return Ok(()); + } + Err(StorageError::Transient( + "TTL reconciliation deferred by in-flight expiration work".to_owned(), + )) +} + +pub(crate) async fn claim_ttl_work( + engine: &CassandraEngine, + account_keyspace: &str, + table_id: &str, + generation: uuid::Uuid, + entry: &TtlEntry, + work_id: uuid::Uuid, + work_data: &TtlWorkData, +) -> Result { + let cql = format!( + "UPDATE {account_keyspace}.{TTL_QUEUE_TABLE} SET state = 'CLAIMED', \ + work_id = ?, work_data = ? WHERE table_id = ? AND generation = ? \ + AND bucket = ? AND shard = ? AND expires_at = ? AND key_hash = ? \ + AND key_data = ? IF state = 'PENDING'" + ); + let work_json = serde_json::to_string(work_data) + .map_err(|error| StorageError::Internal(format!("Serialize TTL work: {error}")))?; + let result = crate::cassandra_util::query_lwt( + &engine.session, + &cql, + cdrs_tokio::query_values!( + cdrs_tokio::types::value::Bytes::new(work_id.as_bytes().to_vec()), + work_json.as_str(), + table_id, + cdrs_tokio::types::value::Bytes::new(generation.as_bytes().to_vec()), + entry.bucket, + entry.shard, + entry.expires_at, + entry.key_hash.as_str(), + entry.key_data.as_str() + ), + ) + .await?; + work_lwt_applied(&result) +} + +pub(crate) async fn mark_ttl_effects_applying( + engine: &CassandraEngine, + account_keyspace: &str, + table_id: &str, + generation: uuid::Uuid, + row: &TtlWorkRow, +) -> Result { + let Some(work_id) = row.work_id else { + return Ok(false); + }; + let cql = format!( + "UPDATE {account_keyspace}.{TTL_QUEUE_TABLE} SET state = 'EFFECTS_APPLYING' \ + WHERE table_id = ? AND generation = ? AND bucket = ? AND shard = ? \ + AND expires_at = ? AND key_hash = ? AND key_data = ? \ + IF state = 'CLAIMED' AND work_id = ?" + ); + let result = crate::cassandra_util::query_lwt( + &engine.session, + &cql, + cdrs_tokio::query_values!( + table_id, + cdrs_tokio::types::value::Bytes::new(generation.as_bytes().to_vec()), + row.entry.bucket, + row.entry.shard, + row.entry.expires_at, + row.entry.key_hash.as_str(), + row.entry.key_data.as_str(), + cdrs_tokio::types::value::Bytes::new(work_id.as_bytes().to_vec()) + ), + ) + .await?; + work_lwt_applied(&result) +} + +pub(crate) async fn mark_ttl_effects_applied( + engine: &CassandraEngine, + account_keyspace: &str, + table_id: &str, + generation: uuid::Uuid, + row: &TtlWorkRow, +) -> Result { + let Some(work_id) = row.work_id else { + return Ok(false); + }; + let cql = format!( + "UPDATE {account_keyspace}.{TTL_QUEUE_TABLE} SET state = 'EFFECTS_APPLIED' \ + WHERE table_id = ? AND generation = ? AND bucket = ? AND shard = ? \ + AND expires_at = ? AND key_hash = ? AND key_data = ? \ + IF state = 'EFFECTS_APPLYING' AND work_id = ?" + ); + let result = crate::cassandra_util::query_lwt( + &engine.session, + &cql, + cdrs_tokio::query_values!( + table_id, + cdrs_tokio::types::value::Bytes::new(generation.as_bytes().to_vec()), + row.entry.bucket, + row.entry.shard, + row.entry.expires_at, + row.entry.key_hash.as_str(), + row.entry.key_data.as_str(), + cdrs_tokio::types::value::Bytes::new(work_id.as_bytes().to_vec()) + ), + ) + .await?; + work_lwt_applied(&result) +} + +pub(crate) async fn complete_ttl_work( + engine: &CassandraEngine, + account_keyspace: &str, + table_id: &str, + generation: uuid::Uuid, + row: &TtlWorkRow, +) -> Result { + let Some(work_id) = row.work_id else { + return Ok(false); + }; + let cql = format!( + "DELETE FROM {account_keyspace}.{TTL_QUEUE_TABLE} WHERE table_id = ? \ + AND generation = ? AND bucket = ? AND shard = ? AND expires_at = ? \ + AND key_hash = ? AND key_data = ? IF state = 'EFFECTS_APPLIED' AND work_id = ?" + ); + let result = crate::cassandra_util::query_lwt( + &engine.session, + &cql, + cdrs_tokio::query_values!( + table_id, + cdrs_tokio::types::value::Bytes::new(generation.as_bytes().to_vec()), + row.entry.bucket, + row.entry.shard, + row.entry.expires_at, + row.entry.key_hash.as_str(), + row.entry.key_data.as_str(), + cdrs_tokio::types::value::Bytes::new(work_id.as_bytes().to_vec()) + ), + ) + .await?; + work_lwt_applied(&result) +} + +/// Complete active-generation work under a durable repair handoff. +/// +/// Retired generations may call [`complete_ttl_work`] directly because active +/// writers cannot insert into those generation partitions. +pub(crate) async fn complete_live_ttl_work( + engine: &CassandraEngine, + account_keyspace: &str, + key_info: &TableKeyInfo, + generation: uuid::Uuid, + row: &TtlWorkRow, +) -> Result { + let guard = record_ttl_repair( + engine, + account_keyspace, + key_info, + generation, + &row.entry.key_data, + ) + .await?; + let applied = complete_ttl_work( + engine, + account_keyspace, + &key_info.table_id, + generation, + row, + ) + .await?; + guard.complete(engine, account_keyspace).await?; + Ok(applied) +} + +pub(crate) async fn retire_pending_ttl_work( + engine: &CassandraEngine, + account_keyspace: &str, + key_info: &TableKeyInfo, + generation: uuid::Uuid, + entry: &TtlEntry, +) -> Result { + // The quorum-durable inflight marker remains if the LWT result is + // ambiguous. A definitive true/false result is handed to the normal outbox + // before the marker is removed. + let guard = record_ttl_repair( + engine, + account_keyspace, + key_info, + generation, + &entry.key_data, + ) + .await?; + let applied = retire_pending_row( + engine, + account_keyspace, + &key_info.table_id, + generation, + entry, + ) + .await?; + guard.complete(engine, account_keyspace).await?; + Ok(applied) +} + +/// The raw conditional retire, without the repair record. Only correct for a +/// retired generation: current-config writers never insert into its partition +/// (the generation is part of the partition key), and a stale-config writer's +/// own outbox row reconciles into the current generation regardless of what +/// happens to its dead row here. +async fn retire_pending_row( + engine: &CassandraEngine, + account_keyspace: &str, + table_id: &str, + generation: uuid::Uuid, + entry: &TtlEntry, +) -> Result { + let cql = format!( + "DELETE FROM {account_keyspace}.{TTL_QUEUE_TABLE} WHERE table_id = ? \ + AND generation = ? AND bucket = ? AND shard = ? AND expires_at = ? \ + AND key_hash = ? AND key_data = ? IF state = 'PENDING'" + ); + let result = crate::cassandra_util::query_lwt( + &engine.session, + &cql, + cdrs_tokio::query_values!( + table_id, + cdrs_tokio::types::value::Bytes::new(generation.as_bytes().to_vec()), + entry.bucket, + entry.shard, + entry.expires_at, + entry.key_hash.as_str(), + entry.key_data.as_str() + ), + ) + .await?; + work_lwt_applied(&result) +} + +pub(crate) async fn mark_ttl_claim_aborting( + engine: &CassandraEngine, + account_keyspace: &str, + table_id: &str, + generation: uuid::Uuid, + row: &TtlWorkRow, +) -> Result { + let Some(work_id) = row.work_id else { + return Ok(false); + }; + let cql = format!( + "UPDATE {account_keyspace}.{TTL_QUEUE_TABLE} SET state = 'CLAIM_ABORTING' \ + WHERE table_id = ? AND generation = ? AND bucket = ? AND shard = ? \ + AND expires_at = ? AND key_hash = ? AND key_data = ? \ + IF state = 'CLAIMED' AND work_id = ?" + ); + let result = crate::cassandra_util::query_lwt( + &engine.session, + &cql, + cdrs_tokio::query_values!( + table_id, + cdrs_tokio::types::value::Bytes::new(generation.as_bytes().to_vec()), + row.entry.bucket, + row.entry.shard, + row.entry.expires_at, + row.entry.key_hash.as_str(), + row.entry.key_data.as_str(), + cdrs_tokio::types::value::Bytes::new(work_id.as_bytes().to_vec()) + ), + ) + .await?; + work_lwt_applied(&result) +} + +pub(crate) async fn abort_claimed_ttl_work( + engine: &CassandraEngine, + account_keyspace: &str, + key_info: &TableKeyInfo, + generation: uuid::Uuid, + row: &TtlWorkRow, +) -> Result { + let Some(work_id) = row.work_id else { + return Ok(false); + }; + let table_id = key_info.table_id.as_str(); + let guard = record_ttl_repair( + engine, + account_keyspace, + key_info, + generation, + &row.entry.key_data, + ) + .await?; + let cql = format!( + "DELETE FROM {account_keyspace}.{TTL_QUEUE_TABLE} WHERE table_id = ? \ + AND generation = ? AND bucket = ? AND shard = ? AND expires_at = ? \ + AND key_hash = ? AND key_data = ? IF state = 'CLAIM_ABORTING' AND work_id = ?" + ); + let result = crate::cassandra_util::query_lwt( + &engine.session, + &cql, + cdrs_tokio::query_values!( + table_id, + cdrs_tokio::types::value::Bytes::new(generation.as_bytes().to_vec()), + row.entry.bucket, + row.entry.shard, + row.entry.expires_at, + row.entry.key_hash.as_str(), + row.entry.key_data.as_str(), + cdrs_tokio::types::value::Bytes::new(work_id.as_bytes().to_vec()) + ), + ) + .await?; + let applied = work_lwt_applied(&result)?; + guard.complete(engine, account_keyspace).await?; + Ok(applied) +} + +fn work_lwt_applied(result: &cdrs_tokio::frame::Envelope) -> Result { + use cdrs_tokio::types::IntoRustByName; + + let rows = result + .response_body() + .map_err(|error| StorageError::Internal(format!("Parse TTL work LWT: {error}")))? + .into_rows() + .unwrap_or_default(); + let Some(row) = rows.first() else { + return Err(StorageError::Internal( + "TTL work LWT returned no result".to_owned(), + )); + }; + row.get_r_by_name("[applied]") + .map_err(|error| StorageError::Internal(format!("Parse TTL work result: {error}"))) +} + +/// Remove a `(bucket, shard)` registration from the bucket registry. +/// +/// `guard_timestamp` must be a microsecond timestamp captured *before* the +/// caller observed the partition to be empty. Passing it makes the delete lose +/// to any queue insert that commits afterwards: every insert re-registers the +/// bucket in the same logged batch as the entry, and that insert carries a +/// later coordinator timestamp, so an entry can never be left behind with its +/// registration deleted. `None` skips the guard and is only correct when the +/// generation is being retired and no further inserts can target it. +async fn retire_bucket_registration( + engine: &CassandraEngine, + account_keyspace: &str, + table_id: &str, + generation: uuid::Uuid, + bucket: i64, + shard: i32, + guard_timestamp: Option, +) -> Result<(), StorageError> { + let using = guard_timestamp + .map(|timestamp| format!(" USING TIMESTAMP {timestamp}")) + .unwrap_or_default(); + let cql = format!( + "DELETE FROM {account_keyspace}.{TTL_BUCKET_TABLE}{using} \ + WHERE table_id = ? AND generation = ? AND bucket = ? AND shard = ?" + ); + crate::cassandra_util::execute_quorum( + &engine.session, + &cql, + cdrs_tokio::query_values!( + table_id, + cdrs_tokio::types::value::Bytes::new(generation.as_bytes().to_vec()), + bucket, + shard + ), + "retire TTL bucket registration", + ) + .await +} + +/// Upper bound on registry partitions a single sweep cycle will visit. +/// +/// The registry holds one row per `(day bucket, shard)` ever written, so +/// without a cap the per-cycle query fan-out grows linearly with the age of +/// the table. Partitions are rotated between cycles, so capping bounds the +/// cost of a cycle without starving any partition. +const TTL_MAX_PARTITIONS_PER_CYCLE: usize = 512; + +pub(crate) async fn load_due_ttl_work( + engine: &CassandraEngine, + account_keyspace: &str, + table_id: &str, + generation: uuid::Uuid, + now: i64, + limit: usize, +) -> Result, StorageError> { + use cdrs_tokio::types::IntoRustByName; + + if limit == 0 { + return Ok(Vec::new()); + } + // Captured before the emptiness observations below, so it can safely guard + // the retirement of any partition this cycle finds drained. + let retire_guard = chrono::Utc::now().timestamp_micros(); + let generation_bytes = cdrs_tokio::types::value::Bytes::new(generation.as_bytes().to_vec()); + let current_bucket = now / TTL_BUCKET_SECONDS; + let bucket_query = format!( + "SELECT bucket, shard FROM {account_keyspace}.{TTL_BUCKET_TABLE} \ + WHERE table_id = ? AND generation = ? AND bucket <= ?" + ); + let bucket_rows = crate::cassandra_util::query_rows( + &engine.session, + &bucket_query, + cdrs_tokio::query_values!(table_id, generation_bytes.clone(), current_bucket), + "load_due_ttl_buckets", + ) + .await?; + let mut partitions: Vec<(i64, i32)> = Vec::with_capacity(bucket_rows.len()); + for row in bucket_rows { + partitions.push(( + crate::cassandra_util::get_column(&row, "bucket", "load_due_ttl_buckets")?, + crate::cassandra_util::get_column(&row, "shard", "load_due_ttl_buckets")?, + )); + } + if !partitions.is_empty() { + let partition_count = partitions.len(); + partitions.rotate_left(((now / 60) as usize) % partition_count); + partitions.truncate(TTL_MAX_PARTITIONS_PER_CYCLE); + } + + let mut work = Vec::with_capacity(limit); + for (index, (bucket, shard)) in partitions.iter().enumerate() { + let remaining = limit - work.len(); + if remaining == 0 { + break; + } + let partitions_left = partitions.len() - index; + let partition_limit = remaining.div_ceil(partitions_left).max(1); + let query = format!( + "SELECT expires_at, key_hash, key_data, state, work_id, work_data \ + FROM {account_keyspace}.{TTL_QUEUE_TABLE} WHERE table_id = ? \ + AND generation = ? AND bucket = ? AND shard = ? AND expires_at <= ? \ + LIMIT {partition_limit}" + ); + let rows = crate::cassandra_util::query_rows_quorum( + &engine.session, + &query, + cdrs_tokio::query_values!(table_id, generation_bytes.clone(), *bucket, *shard, now), + "load_due_ttl_work", + ) + .await?; + // A fully past day bucket that yields nothing at LOCAL_QUORUM is + // drained: every entry it could hold is already due. The guarded + // retirement timestamp was captured before this authoritative read, and + // reconciliation performs a final bucket restore after queue durability. + if rows.is_empty() && *bucket < current_bucket { + retire_bucket_registration( + engine, + account_keyspace, + table_id, + generation, + *bucket, + *shard, + Some(retire_guard), + ) + .await?; + continue; + } + for row in rows { + let state: Option = row.get_by_name("state").ok().flatten(); + let work_id: Option = row.get_by_name("work_id").ok().flatten(); + let work_data: Option = row.get_by_name("work_data").ok().flatten(); + work.push(TtlWorkRow { + entry: TtlEntry { + bucket: *bucket, + shard: *shard, + expires_at: crate::cassandra_util::get_column( + &row, + "expires_at", + "load_due_ttl_work", + )?, + key_hash: crate::cassandra_util::get_column( + &row, + "key_hash", + "load_due_ttl_work", + )?, + key_data: crate::cassandra_util::get_column( + &row, + "key_data", + "load_due_ttl_work", + )?, + }, + state: TtlWorkState::parse(state.as_deref())?, + work_id, + work_data: work_data + .map(|value| serde_json::from_str(&value)) + .transpose() + .map_err(|error| { + StorageError::Internal(format!("Parse TTL work data: {error}")) + })?, + }); + } + } + Ok(work) +} + +/// Load every queue row for a generation regardless of due time, in any state. +/// +/// Used to drain a retired generation: unlike [`load_due_ttl_work`] this does +/// not filter by `expires_at`, because cleanup has to account for work that was +/// claimed before the generation was retired. +pub(crate) async fn load_generation_work( + engine: &CassandraEngine, + account_keyspace: &str, + table_id: &str, + generation: uuid::Uuid, + limit: usize, +) -> Result, StorageError> { + use cdrs_tokio::types::IntoRustByName; + + let generation_bytes = cdrs_tokio::types::value::Bytes::new(generation.as_bytes().to_vec()); + let mut work = Vec::new(); + for (bucket, shard) in + generation_partitions(engine, account_keyspace, table_id, generation).await? + { + if work.len() >= limit { + break; + } + // Quorum: work this read misses would be abandoned holding a base-row + // claim, and the generation would be reported drained when it is not. + let rows = crate::cassandra_util::query_rows_quorum( + &engine.session, + &format!( + "SELECT expires_at, key_hash, key_data, state, work_id, work_data \ + FROM {account_keyspace}.{TTL_QUEUE_TABLE} WHERE table_id = ? \ + AND generation = ? AND bucket = ? AND shard = ?" + ), + cdrs_tokio::query_values!(table_id, generation_bytes.clone(), bucket, shard), + "load_generation_work", + ) + .await?; + for row in rows { + let state: Option = row.get_by_name("state").ok().flatten(); + let work_id: Option = row.get_by_name("work_id").ok().flatten(); + let work_data: Option = row.get_by_name("work_data").ok().flatten(); + work.push(TtlWorkRow { + entry: TtlEntry { + bucket, + shard, + expires_at: crate::cassandra_util::get_column( + &row, + "expires_at", + "load_generation_work", + )?, + key_hash: crate::cassandra_util::get_column( + &row, + "key_hash", + "load_generation_work", + )?, + key_data: crate::cassandra_util::get_column( + &row, + "key_data", + "load_generation_work", + )?, + }, + state: TtlWorkState::parse(state.as_deref())?, + work_id, + work_data: work_data + .map(|value| serde_json::from_str(&value)) + .transpose() + .map_err(|error| { + StorageError::Internal(format!("Parse TTL work data: {error}")) + })?, + }); + } + } + Ok(work) +} + +/// List the `(bucket, shard)` partitions registered for a generation. +async fn generation_partitions( + engine: &CassandraEngine, + account_keyspace: &str, + table_id: &str, + generation: uuid::Uuid, +) -> Result, StorageError> { + let rows = crate::cassandra_util::query_rows_quorum( + &engine.session, + &format!( + "SELECT bucket, shard FROM {account_keyspace}.{TTL_BUCKET_TABLE} \ + WHERE table_id = ? AND generation = ?" + ), + cdrs_tokio::query_values!( + table_id, + cdrs_tokio::types::value::Bytes::new(generation.as_bytes().to_vec()) + ), + "generation_partitions", + ) + .await?; + let mut partitions = Vec::with_capacity(rows.len()); + for row in rows { + partitions.push(( + crate::cassandra_util::get_column(&row, "bucket", "generation_partitions")?, + crate::cassandra_util::get_column(&row, "shard", "generation_partitions")?, + )); + } + Ok(partitions) +} + +/// Remove a retired generation's queue rows. +/// +/// Only `PENDING` rows are removed. A row in `CLAIMED`, `CLAIM_ABORTING`, +/// `EFFECTS_APPLYING`, or `EFFECTS_APPLIED` owns durable state — a base-row +/// claim, and possibly must-complete or already-applied index and stream +/// effects — so deleting it would strand that state with nothing left to +/// drive recovery. Those rows are left in place and reported by the `false` +/// return, which keeps `ttl_cleanup_generation` set so the worker drains +/// them and retries. +pub(crate) async fn clear_ttl_generation( + engine: &CassandraEngine, + account_keyspace: &str, + table_id: &str, + generation: uuid::Uuid, +) -> Result { + let generation_bytes = cdrs_tokio::types::value::Bytes::new(generation.as_bytes().to_vec()); + let mut fully_drained = true; + for (bucket, shard) in + generation_partitions(engine, account_keyspace, table_id, generation).await? + { + // Quorum: `partition_drained` below decides whether to delete the bucket + // registration and whether the generation can be reported fully drained. + let rows = crate::cassandra_util::query_rows_quorum( + &engine.session, + &format!( + "SELECT expires_at, key_hash, key_data, state \ + FROM {account_keyspace}.{TTL_QUEUE_TABLE} WHERE table_id = ? \ + AND generation = ? AND bucket = ? AND shard = ?" + ), + cdrs_tokio::query_values!(table_id, generation_bytes.clone(), bucket, shard), + "clear_ttl_generation", + ) + .await?; + let mut partition_drained = true; + for row in rows { + use cdrs_tokio::types::IntoRustByName; + let state: Option = row.get_by_name("state").ok().flatten(); + if TtlWorkState::parse(state.as_deref())? != TtlWorkState::Pending { + partition_drained = false; + fully_drained = false; + continue; + } + let entry = TtlEntry { + bucket, + shard, + expires_at: crate::cassandra_util::get_column( + &row, + "expires_at", + "clear_ttl_generation", + )?, + key_hash: crate::cassandra_util::get_column( + &row, + "key_hash", + "clear_ttl_generation", + )?, + key_data: crate::cassandra_util::get_column( + &row, + "key_data", + "clear_ttl_generation", + )?, + }; + if !retire_pending_row(engine, account_keyspace, table_id, generation, &entry).await? { + // Claimed between the read and the delete; leave it for the + // drain pass. + partition_drained = false; + fully_drained = false; + } + } + if partition_drained { + retire_bucket_registration( + engine, + account_keyspace, + table_id, + generation, + bucket, + shard, + None, + ) + .await?; + } + } + Ok(fully_drained) +} + +pub(crate) async fn clear_ttl_entries( + engine: &CassandraEngine, + account_keyspace: &str, + table_id: &str, +) -> Result<(), StorageError> { + let bucket_query = format!( + "SELECT generation, bucket, shard FROM {account_keyspace}.{TTL_BUCKET_TABLE} \ + WHERE table_id = ?" + ); + let rows = crate::cassandra_util::query_rows( + &engine.session, + &bucket_query, + cdrs_tokio::query_values!(table_id), + "clear_ttl_entries", + ) + .await?; + for row in rows { + let generation: uuid::Uuid = + crate::cassandra_util::get_column(&row, "generation", "clear_ttl_entries")?; + let bucket: i64 = crate::cassandra_util::get_column(&row, "bucket", "clear_ttl_entries")?; + let shard: i32 = crate::cassandra_util::get_column(&row, "shard", "clear_ttl_entries")?; + let delete = format!( + "DELETE FROM {account_keyspace}.{TTL_QUEUE_TABLE} \ + WHERE table_id = ? AND generation = ? AND bucket = ? AND shard = ?" + ); + engine + .session + .query_with_values( + &delete, + cdrs_tokio::query_values!( + table_id, + cdrs_tokio::types::value::Bytes::new(generation.as_bytes().to_vec()), + bucket, + shard + ), + ) + .await + .map_err(|error| StorageError::Internal(format!("Clear TTL partition: {error}")))?; + } + let delete_buckets = + format!("DELETE FROM {account_keyspace}.{TTL_BUCKET_TABLE} WHERE table_id = ?"); + engine + .session + .query_with_values(&delete_buckets, cdrs_tokio::query_values!(table_id)) + .await + .map_err(|error| StorageError::Internal(format!("Clear TTL buckets: {error}")))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use extenddb_core::types::{ + AttributeDefinition, KeySchemaElement, KeyType, ScalarAttributeType, + }; + + fn key_info() -> TableKeyInfo { + TableKeyInfo { + table_name: "table".to_owned(), + account_id: "123456789012".to_owned(), + table_id: "table-id".to_owned(), + key_schema: vec![KeySchemaElement { + attribute_name: "id".to_owned(), + key_type: KeyType::Hash, + }], + base_key_schema: vec![KeySchemaElement { + attribute_name: "id".to_owned(), + key_type: KeyType::Hash, + }], + attribute_definitions: vec![AttributeDefinition { + attribute_name: "id".to_owned(), + attribute_type: ScalarAttributeType::S, + }], + has_lsi: false, + global_secondary_indexes: Vec::new(), + local_secondary_indexes: Vec::new(), + vector_indexes: Vec::new(), + stream_specification: None, + } + } + + #[test] + fn ttl_epoch_accepts_only_positive_integral_numbers() { + let mut item = Item::new(); + for (value, expected) in [("123", Some(123)), ("0", None), ("-1", None), ("1.5", None)] { + item.insert("ttl".to_owned(), AttributeValue::N(value.to_owned())); + assert_eq!(ttl_epoch_seconds(&item, "ttl"), expected); + } + item.insert("ttl".to_owned(), AttributeValue::S("123".to_owned())); + assert_eq!(ttl_epoch_seconds(&item, "ttl"), None); + } + + #[test] + fn ttl_entry_is_stable_and_bounded_to_a_shard() { + let mut item = Item::new(); + item.insert("id".to_owned(), AttributeValue::S("a".to_owned())); + item.insert("ttl".to_owned(), AttributeValue::N("123".to_owned())); + let first = entry_for_item(&key_info(), &item, "ttl").unwrap().unwrap(); + let second = entry_for_item(&key_info(), &item, "ttl").unwrap().unwrap(); + assert_eq!(first, second); + assert!((0..TTL_SHARDS).contains(&first.shard)); + assert_eq!(first.bucket, first.expires_at / TTL_BUCKET_SECONDS); + } + + #[test] + fn unchanged_ttl_does_not_delete_and_reinsert_same_queue_key() { + let key_info = key_info(); + let mut item = Item::new(); + item.insert("id".to_owned(), AttributeValue::S("a".to_owned())); + item.insert("ttl".to_owned(), AttributeValue::N("123".to_owned())); + let mut batch = BatchQueryBuilder::new(); + add_ttl_queue_mutations( + &mut batch, + "account_keyspace", + &key_info, + "ttl", + uuid::Uuid::new_v4(), + Some(&item), + Some(&item), + ) + .unwrap(); + let statements = built_statements(batch); + // The point of the test is the absence of the DELETE: re-deleting and + // re-inserting the same queue key would let a writer erase expiration + // work that a sweep had already claimed. + assert!( + !statements.iter().any(|cql| cql.contains("DELETE")), + "an unchanged TTL must not delete its own queue entry: {statements:?}" + ); + assert_eq!( + statements.len(), + 2, + "expected only the bucket and entry upserts: {statements:?}" + ); + assert!( + statements[0].contains(&format!("INSERT INTO account_keyspace.{TTL_BUCKET_TABLE}")) + ); + assert!(statements[1].contains(&format!("INSERT INTO account_keyspace.{TTL_QUEUE_TABLE}"))); + } + + /// A changed TTL value must retire the old queue key as well as register the + /// new one, otherwise the old entry outlives the change. + #[test] + fn changed_ttl_retires_the_previous_queue_key() { + let key_info = key_info(); + let mut old = Item::new(); + old.insert("id".to_owned(), AttributeValue::S("a".to_owned())); + old.insert("ttl".to_owned(), AttributeValue::N("123".to_owned())); + let mut new = old.clone(); + new.insert("ttl".to_owned(), AttributeValue::N("456".to_owned())); + + let mut batch = BatchQueryBuilder::new(); + add_ttl_queue_mutations( + &mut batch, + "account_keyspace", + &key_info, + "ttl", + uuid::Uuid::new_v4(), + Some(&old), + Some(&new), + ) + .unwrap(); + let statements = built_statements(batch); + assert_eq!(statements.len(), 3, "{statements:?}"); + assert!( + statements[0].contains(&format!("DELETE FROM account_keyspace.{TTL_QUEUE_TABLE}")), + "the previous queue key must be deleted first: {statements:?}" + ); + assert!( + statements[1].contains(&format!("INSERT INTO account_keyspace.{TTL_BUCKET_TABLE}")) + ); + assert!(statements[2].contains(&format!("INSERT INTO account_keyspace.{TTL_QUEUE_TABLE}"))); + } + + fn built_statements(batch: BatchQueryBuilder) -> Vec { + use cdrs_tokio::frame::message_batch::BatchQuerySubj; + + batch + .build() + .unwrap() + .request + .queries + .into_iter() + .map(|query| match query.subject { + BatchQuerySubj::QueryString(cql) => cql.to_string(), + BatchQuerySubj::PreparedId(_) => { + panic!("TTL queue mutations are built as CQL strings") + } + }) + .collect() + } + + /// Every TTL claim and the exact base-row delete condition on + /// `item_data = ?`, where the expected value is produced by re-serialising + /// an item that was parsed out of the stored string. That only works while + /// re-serialising a stored form reproduces it byte for byte, so an + /// accidental change to `AttributeValue`'s serde representation would + /// silently stop TTL deleting anything and start failing writes with + /// `TransactionConflict`. This asserts the invariant directly rather than + /// leaving it to a live Cassandra test to notice. + /// + /// The stored form is whatever the write path produced, so the property + /// under test is that serialisation is canonical: a value that has been + /// serialised once is unchanged by every later round trip. Note that this + /// means the first serialisation *does* normalise — `N: "1.500"` is stored + /// as `1.5` — which is why the claim compares against the stored form and + /// never against a client-supplied string. + #[test] + fn stored_item_json_round_trips_byte_for_byte() { + let submitted = [ + r#"{"id":{"S":"a"}}"#, + r#"{"id":{"S":""}}"#, + r#"{"id":{"S":"a"},"n":{"N":"0"}}"#, + r#"{"id":{"S":"a"},"n":{"N":"-1"}}"#, + r#"{"id":{"S":"a"},"n":{"N":"1.500"}}"#, + r#"{"id":{"S":"a"},"n":{"N":"1e3"}}"#, + r#"{"id":{"S":"a"},"n":{"N":"123456789012345678901234567890"}}"#, + r#"{"b":{"B":"aGVsbG8="},"id":{"S":"a"}}"#, + r#"{"bool":{"BOOL":true},"id":{"S":"a"},"null":{"NULL":true}}"#, + r#"{"id":{"S":"a"},"l":{"L":[{"S":"x"},{"N":"1"}]}}"#, + r#"{"id":{"S":"a"},"m":{"M":{"a":{"S":"x"},"b":{"N":"2"}}}}"#, + r#"{"id":{"S":"a"},"ss":{"SS":["a","b"]}}"#, + r#"{"id":{"S":"a"},"unicode":{"S":"héllo → 世界"}}"#, + ]; + for original in submitted { + let parsed: Item = serde_json::from_str(original) + .unwrap_or_else(|error| panic!("parse {original}: {error}")); + // What the write path persists into `item_data`. + let stored = serde_json::to_string(&parsed).expect("serialize item"); + // What a claim or exact delete reconstructs from it. + let reloaded: Item = serde_json::from_str(&stored) + .unwrap_or_else(|error| panic!("parse stored {stored}: {error}")); + let expected = serde_json::to_string(&reloaded).expect("serialize reloaded item"); + assert_eq!( + expected, stored, + "item_data round trip is not byte-stable for {original}" + ); + } + } +} diff --git a/crates/storage-cassandra/src/data/update_item.rs b/crates/storage-cassandra/src/data/update_item.rs index 730094c0..c9b29670 100644 --- a/crates/storage-cassandra/src/data/update_item.rs +++ b/crates/storage-cassandra/src/data/update_item.rs @@ -51,19 +51,21 @@ impl CassandraEngine { let pk_text = pk_to_text(pk_value)?; let catalog_keyspace = self.catalog_keyspace(); - let indexes = super::index::fetch_indexes_for_table( - &key_info.table_id, - &self.session, - &catalog_keyspace, - ) - .await?; + // Both are catalog reads with no data dependency; overlap them. + let (indexes, ttl_config) = futures::try_join!( + super::index::fetch_indexes_for_table( + &key_info.table_id, + &self.session, + &catalog_keyspace, + ), + self.ttl_config_for_table(&key_info.account_id, &key_info.table_name), + )?; let sys_delay = if indexes.is_empty() { 0 } else { self.gsi_default_delay_ms .load(std::sync::atomic::Ordering::Relaxed) }; - // Resolve sort key once — used in every iteration of the OCC loop. let (sk_opt, sk_col_opt) = if let Some(sk_elem) = key_info .key_schema @@ -87,15 +89,31 @@ impl CassandraEngine { for attempt in 0..=OCC_MAX_RETRIES { // --- READ --- - let (old_json, version) = self + let read = self .occ_read( &data_keyspace, &ddb_table, &pk_text, sk_opt.as_ref(), sk_col_opt, + ttl_config.is_some(), ) - .await?; + .await; + let (old_json, version) = match read { + Ok(read) => read, + // Another owner holds the row. On a TTL-enabled table that is + // transient, so treat it like a lost OCC race and re-read. + Err(StorageError::TransactionConflict(message)) => { + if attempt == OCC_MAX_RETRIES { + return Err(StorageError::TransactionConflict(message)); + } + let window_ms = OCC_BASE_DELAY_MS * (1u64 << attempt.min(OCC_EXP_CAP)); + let sleep_ms = rand::random::() % window_ms.max(1); + tokio::time::sleep(std::time::Duration::from_millis(sleep_ms)).await; + continue; + } + Err(error) => return Err(error), + }; let item_existed = old_json.is_some(); let mut item = old_json.clone().unwrap_or_else(|| key.clone()); @@ -118,8 +136,14 @@ impl CassandraEngine { Err(e) => return Err(e), } - let old_item = if return_old && item_existed { Some(item.clone()) } else { None }; - let pre_mutation_item = if (!indexes.is_empty() || stream.is_some()) && item_existed { + let old_item = if return_old && item_existed { + Some(item.clone()) + } else { + None + }; + let pre_mutation_item = if item_existed + && (!indexes.is_empty() || stream.is_some() || ttl_config.is_some()) + { Some(item.clone()) } else { None @@ -137,8 +161,32 @@ impl CassandraEngine { serde_json::to_value(&item).map_err(|e| StorageError::Internal(e.to_string()))?; let item_json_str = item_json.to_string(); - // --- WRITE (with OCC guard) --- - let applied = self + let ttl_claim = if ttl_config.is_some() { + match self + .acquire_ttl_mutation_claim(key_info, key, pre_mutation_item.as_ref()) + .await + { + Ok(claim) => claim, + // Losing the claim is the same class of event as losing the + // OCC race: the image moved, so re-read and rebuild rather + // than failing the caller's write. + Err(StorageError::TransactionConflict(_)) => { + if attempt < OCC_MAX_RETRIES { + let window_ms = OCC_BASE_DELAY_MS * (1u64 << attempt.min(OCC_EXP_CAP)); + let sleep_ms = rand::random::() % window_ms.max(1); + tokio::time::sleep(std::time::Duration::from_millis(sleep_ms)).await; + } + continue; + } + Err(error) => return Err(error), + } + } else { + None + }; + let mutation_timestamp = ttl_claim.map(|_| chrono::Utc::now().timestamp_micros()); + + // --- WRITE (with OCC or TTL-claim guard) --- + let write_result = self .occ_write( &data_keyspace, &ddb_table, @@ -154,8 +202,23 @@ impl CassandraEngine { &item, sys_delay, stream, + ttl_config.as_ref().map(|config| config.attribute.as_str()), + ttl_config.as_ref().map(|config| config.generation), + ttl_claim, + mutation_timestamp, ) - .await?; + .await; + // On success the claimed path's batch cleared the claim columns at + // its pinned timestamp; the detached exact release covers a + // trailing local clock. A failed write may still hold the claim, + // so it releases synchronously. + if write_result.is_err() { + self.release_ttl_mutation_claim(key_info, key, ttl_claim) + .await; + } else { + self.spawn_release_ttl_claim(key_info, key, ttl_claim); + } + let applied = write_result?; if applied { return Ok((old_item, new_item)); @@ -169,7 +232,7 @@ impl CassandraEngine { } } - Err(StorageError::Internal( + Err(StorageError::TransactionConflict( "update_item: too many concurrent writers on this item".to_owned(), )) } @@ -187,6 +250,7 @@ impl CassandraEngine { pk_text: &str, sk: Option<&extenddb_storage::util::SortKeyValue>, sk_col: Option<&'static str>, + ttl_enabled: bool, ) -> Result<(Option, Option), StorageError> { use cdrs_tokio::types::IntoRustByName as _; @@ -221,25 +285,20 @@ impl CassandraEngine { let prepared_txn_id: Option = row.get_by_name("prepared_txn_id").ok().flatten(); if prepared_txn_id.is_some() { - return Err(StorageError::TransactionCanceled(vec![ - extenddb_core::types::CancellationReason { - code: "TransactionConflict".to_owned(), - message: Some("Item is being modified by a concurrent transaction".to_owned()), - item: None, - }, - ])); + return Err(super::delete_item::concurrent_owner_error(ttl_enabled)); } - let item_data: String = crate::cassandra_util::get_column(&row, "item_data", "occ_read")?; + // Static partition metadata can produce a physical row with no logical item. + let item_data: Option = row.get_by_name("item_data").ok().flatten(); let version: Option = row.get_by_name("version").ok().flatten(); - Ok((Some(json_to_item(item_data)?), version)) + Ok((item_data.map(json_to_item).transpose()?, version)) } /// Attempt the OCC write. Returns `true` if `[applied]`, `false` on lost race. /// /// Uses `IF version = ? AND prepared_txn_id = null` for existing items, or - /// `INSERT ... IF NOT EXISTS` for new items (upsert). + /// `IF item_data = null AND prepared_txn_id = null` for logical creation. #[allow(clippy::too_many_arguments)] async fn occ_write( &self, @@ -257,6 +316,10 @@ impl CassandraEngine { new_item: &Item, sys_delay: u64, stream: Option<&StreamCapture>, + ttl_attribute: Option<&str>, + ttl_generation: Option, + ttl_claim: Option, + mutation_timestamp: Option, ) -> Result { let stream_stmt = stream.and_then(|cap| { stream_record_statement( @@ -273,9 +336,8 @@ impl CassandraEngine { let next_version = version.unwrap_or(0) + 1; - // Build the LWT statement and its values. + // Build the LWT statement used when no TTL claim owns the existing row. let (lwt_cql, lwt_qv) = if item_existed { - // UPDATE with IF version = ? AND prepared_txn_id = null let version_cond = if version.is_some() { "version = ?".to_owned() } else { @@ -293,83 +355,102 @@ impl CassandraEngine { cdrs_tokio::types::value::Value::from(pk_text), super::index::sk_to_value(sk), ]; - if version.is_some() { - vals.push(version.unwrap().into()); + if let Some(version) = version { + vals.push(version.into()); } (cql, cdrs_tokio::query::QueryValues::SimpleValues(vals)) } else { let cql = format!( "UPDATE {data_keyspace}.{ddb_table} SET item_data = ?, version = ? \ - WHERE pk = ? \ - IF {version_cond} AND prepared_txn_id = null" + WHERE pk = ? IF {version_cond} AND prepared_txn_id = null" ); let mut vals: Vec = vec![ item_json_str.into(), next_version.into(), cdrs_tokio::types::value::Value::from(pk_text), ]; - if version.is_some() { - vals.push(version.unwrap().into()); + if let Some(version) = version { + vals.push(version.into()); } (cql, cdrs_tokio::query::QueryValues::SimpleValues(vals)) } + } else if let (Some(sk), Some(sk_col)) = (sk, sk_col) { + let cql = format!( + "UPDATE {data_keyspace}.{ddb_table} SET item_data = ?, version = 1 \ + WHERE pk = ? AND {sk_col} = ? \ + IF item_data = null AND prepared_txn_id = null" + ); + let vals = vec![ + item_json_str.into(), + cdrs_tokio::types::value::Value::from(pk_text), + super::index::sk_to_value(sk), + ]; + (cql, cdrs_tokio::query::QueryValues::SimpleValues(vals)) } else { - // INSERT IF NOT EXISTS for new items - if let (Some(sk), Some(sk_col)) = (sk, sk_col) { + let cql = format!( + "UPDATE {data_keyspace}.{ddb_table} SET item_data = ?, version = 1 \ + WHERE pk = ? IF item_data = null AND prepared_txn_id = null" + ); + let vals = vec![ + item_json_str.into(), + cdrs_tokio::types::value::Value::from(pk_text), + ]; + (cql, cdrs_tokio::query::QueryValues::SimpleValues(vals)) + }; + + if ttl_claim.is_none() { + // Preserve f997485's Paxos linearization for ordinary writes. + let result = self + .session + .query_with_values(&lwt_cql, lwt_qv) + .await + .map_err(|e| StorageError::Internal(format!("occ_write lwt: {e}")))?; + if !occ_applied(&result)? { + return Ok(false); + } + if indexes.is_empty() && stream_stmt.is_none() { + return Ok(true); + } + } + + // Cassandra cannot mix an LWT with non-LWT statements in one batch. For a + // claimed row, the exact claim is the fence and the base write joins this + // LOGGED BATCH. For an unclaimed row, the LWT above commits first and this + // batch durably records all secondary effects and reconciliation work. + let mut batch = BatchQueryBuilder::new().with_consistency(Consistency::LocalQuorum); + if let Some(timestamp) = mutation_timestamp { + batch = batch.with_timestamp(timestamp); + } + + if ttl_claim.is_some() { + let (update_cql, update_qv) = if let (Some(sk), Some(sk_col)) = (sk, sk_col) { let cql = format!( - "INSERT INTO {data_keyspace}.{ddb_table} (pk, {sk_col}, item_data, version) \ - VALUES (?, ?, ?, ?) IF NOT EXISTS" + "UPDATE {data_keyspace}.{ddb_table} SET item_data = ?, version = ?, \ + prepared_txn_id = null, prepared_txn_timestamp = null \ + WHERE pk = ? AND {sk_col} = ?" ); let vals = vec![ + item_json_str.into(), + next_version.into(), cdrs_tokio::types::value::Value::from(pk_text), super::index::sk_to_value(sk), - item_json_str.into(), - 1i64.into(), ]; (cql, cdrs_tokio::query::QueryValues::SimpleValues(vals)) } else { let cql = format!( - "INSERT INTO {data_keyspace}.{ddb_table} (pk, item_data, version) \ - VALUES (?, ?, ?) IF NOT EXISTS" + "UPDATE {data_keyspace}.{ddb_table} SET item_data = ?, version = ?, \ + prepared_txn_id = null, prepared_txn_timestamp = null WHERE pk = ?" ); let vals = vec![ - cdrs_tokio::types::value::Value::from(pk_text), item_json_str.into(), - 1i64.into(), + next_version.into(), + cdrs_tokio::types::value::Value::from(pk_text), ]; (cql, cdrs_tokio::query::QueryValues::SimpleValues(vals)) - } - }; - - // If no indexes or stream, execute the LWT directly and check [applied]. - if indexes.is_empty() && stream_stmt.is_none() { - let result = self - .session - .query_with_values(&lwt_cql, lwt_qv) - .await - .map_err(|e| StorageError::Internal(format!("occ_write: {e}")))?; - return occ_applied(&result); - } - - // With indexes/stream we need a LOGGED BATCH. However, Cassandra does not - // allow LWT statements inside a LOGGED BATCH with non-LWT statements. - // Strategy: run the LWT alone first; if it applies, run the index/stream - // updates in a separate UNLOGGED BATCH. The window between the two is safe - // because the item is already committed — index staleness is acceptable - // (the async GSI queue handles eventual consistency). - let result = self - .session - .query_with_values(&lwt_cql, lwt_qv) - .await - .map_err(|e| StorageError::Internal(format!("occ_write lwt: {e}")))?; - - if !occ_applied(&result)? { - return Ok(false); + }; + batch = batch.add_query(update_cql, update_qv); } - // LWT applied — now fire index/stream updates in a best-effort batch. - let mut batch = BatchQueryBuilder::new().with_consistency(Consistency::LocalQuorum); - if !indexes.is_empty() { super::index::sync_indexes( &mut batch, @@ -399,18 +480,39 @@ impl CassandraEngine { 0 }; + if let (Some(_), Some(attribute)) = (ttl_claim, ttl_attribute) { + super::ttl::add_ttl_reconciliation_mutation( + &mut batch, + data_keyspace, + key_info, + attribute, + new_item, + )?; + } + + if let (Some(attribute), Some(generation)) = (ttl_attribute, ttl_generation) { + super::ttl::add_ttl_queue_mutations( + &mut batch, + data_keyspace, + key_info, + attribute, + generation, + pre_mutation_item, + Some(new_item), + )?; + } + if let Some(stmt) = stream_stmt { batch = batch.add_query(stmt, cdrs_tokio::query::QueryValues::SimpleValues(vec![])); } - // Only execute the batch if it has statements (sync_indexes / enqueue / stream may add them). - // BatchQueryBuilder has no public len(); check via build and catch empty-batch errors gracefully. - if let Ok(built) = batch.build() { - self.session - .batch(built) - .await - .map_err(|e| StorageError::Internal(format!("occ_write index batch: {e}")))?; - } + let built = batch + .build() + .map_err(|error| StorageError::Internal(error.to_string()))?; + self.session + .batch(built) + .await + .map_err(|error| StorageError::Internal(format!("occ_write batch: {error}")))?; if async_enqueued > 0 { self.gsi_queue.notify_workers(); diff --git a/crates/storage-cassandra/src/delete_table.rs b/crates/storage-cassandra/src/delete_table.rs index 20bec025..7d26bc73 100644 --- a/crates/storage-cassandra/src/delete_table.rs +++ b/crates/storage-cassandra/src/delete_table.rs @@ -80,10 +80,17 @@ impl CassandraEngine { // Delete physical tables from the owning account keyspace. Catalog // metadata and user data intentionally live in different keyspaces. + // + // The base table is dropped *before* the TTL queue is cleared. Clearing + // first would leave a still-live, still-TTL-enabled table whose items + // have no expiration entries and nothing to rebuild them if the drop + // below fails. let account_keyspace = self.account_keyspace(account_id); self.drop_data_table(&account_keyspace, &table_id) .await .map_err(|e| StorageError::Internal(e.to_string()))?; + self.clear_ttl_entries_for_table_id(account_id, &table_id) + .await?; // Delete index data tables for idx_row in &index_rows { diff --git a/crates/storage-cassandra/src/engine.rs b/crates/storage-cassandra/src/engine.rs index ebdcf9c1..13e934f1 100644 --- a/crates/storage-cassandra/src/engine.rs +++ b/crates/storage-cassandra/src/engine.rs @@ -55,8 +55,25 @@ pub struct CassandraEngine { /// Stream record TTL in seconds (default: 30 hours = 108000) pub(crate) stream_retention_seconds: u32, + + /// Bounds in-flight detached TTL claim releases. Each successful write on + /// a TTL-enabled table fires one off the request path; without a bound, + /// degraded LWT latency would let them accumulate without limit. When + /// saturated, releases are dropped — the claim's own TTL bounds the + /// residue, so dropping is safe by construction. + pub(crate) ttl_release_permits: Arc, + + /// Per-(keyspace, partition) resume cursor for the inflight repair-marker + /// scan, so every marker is deterministically visited within a bounded + /// number of cycles regardless of partition size. In-process on purpose: + /// a restart merely restarts the traversal from the front. + pub(crate) ttl_repair_scan_cursors: + Arc>>, } +/// Cap on concurrently in-flight detached TTL claim releases. +const TTL_RELEASE_MAX_IN_FLIGHT: usize = 1_024; + impl CassandraEngine { /// Create a new Cassandra storage engine. pub async fn new(config: &CassandraStorageConfig, region: &str) -> Result { @@ -76,6 +93,10 @@ impl CassandraEngine { config.instance_id.as_deref().unwrap_or("default"), ), stream_retention_seconds: 108_000, // 30 hours; overridden by spawn_workers (Step 7) + ttl_release_permits: Arc::new(tokio::sync::Semaphore::new(TTL_RELEASE_MAX_IN_FLIGHT)), + ttl_repair_scan_cursors: Arc::new(std::sync::Mutex::new( + std::collections::HashMap::new(), + )), }) } diff --git a/crates/storage-cassandra/src/lib.rs b/crates/storage-cassandra/src/lib.rs index c6ed76fd..50df1425 100644 --- a/crates/storage-cassandra/src/lib.rs +++ b/crates/storage-cassandra/src/lib.rs @@ -28,6 +28,7 @@ mod stream_engine; pub mod stream_util; pub mod table_engine; mod table_helpers; +pub mod ttl_worker; mod update_table; mod worker_store; pub mod workers; @@ -108,7 +109,25 @@ impl ServerRuntimeHooks for CassandraRuntimeHooks { let guard = workers::spawn_gsi_workers(self.engine.clone()); let _ = self.gsi_worker_guard.set(guard); - vec![control_plane, transaction_recovery, gsi_delay_poller] + let ttl_engine = self.engine.clone(); + let ttl_metrics = ctx.metrics.clone(); + let ttl_shutdown = ctx.shutdown.clone(); + let ttl_cleanup = tokio::spawn(async move { + ttl_worker::ttl_cleanup_worker(ttl_engine, ttl_metrics, ttl_shutdown).await + }); + let ttl_repair_engine = self.engine.clone(); + let ttl_repair_shutdown = ctx.shutdown.clone(); + let ttl_repair = tokio::spawn(async move { + ttl_worker::ttl_repair_worker(ttl_repair_engine, ttl_repair_shutdown).await + }); + + vec![ + control_plane, + transaction_recovery, + gsi_delay_poller, + ttl_cleanup, + ttl_repair, + ] } fn backend_info(&self) -> Option { diff --git a/crates/storage-cassandra/src/metadata_engine.rs b/crates/storage-cassandra/src/metadata_engine.rs index fa6c0c09..195c8721 100755 --- a/crates/storage-cassandra/src/metadata_engine.rs +++ b/crates/storage-cassandra/src/metadata_engine.rs @@ -3,34 +3,813 @@ //! `MetadataEngine` trait implementation for `CassandraEngine`. -use extenddb_core::types::{Item, Tag, TimeToLiveDescription}; +use cdrs_tokio::types::IntoRustByName; +use extenddb_core::types::{Item, Tag, TimeToLiveDescription, TimeToLiveStatus}; use extenddb_storage::MetadataEngine; use extenddb_storage::error::StorageError; use futures::future::BoxFuture; use crate::CassandraEngine; +/// Bounded retries for a TTL lifecycle change that collides with a sweep lease. +const TTL_CONTROL_MAX_RETRIES: u32 = 4; +const TTL_CONTROL_RETRY_DELAY_MS: u64 = 25; + +impl CassandraEngine { + pub(crate) async fn ttl_config_for_table( + &self, + account_id: &str, + table_name: &str, + ) -> Result, StorageError> { + self.ttl_config_for_table_at(account_id, table_name, false) + .await + } + + /// Read TTL configuration at `LOCAL_QUORUM` for an authoritative absence + /// decision. Durable repair records must not be discharged from a stale + /// replica that still reports TTL disabled or an old generation. + pub(crate) async fn ttl_config_for_table_quorum( + &self, + account_id: &str, + table_name: &str, + ) -> Result, StorageError> { + self.ttl_config_for_table_at(account_id, table_name, true) + .await + } + + /// One implementation for both consistencies, so the legacy null-generation + /// adoption cannot drift between them. An applied adoption LWT is already + /// authoritative (Paxos), so no re-read is needed in that arm at either + /// consistency; a lost adoption race re-reads to pick up the winner. + async fn ttl_config_for_table_at( + &self, + account_id: &str, + table_name: &str, + quorum: bool, + ) -> Result, StorageError> { + let query = format!( + "SELECT ttl_attribute, ttl_generation FROM {}.tables \ + WHERE account_id = ? AND table_name = ?", + self.catalog_keyspace() + ); + let read = |context: &'static str| { + let query = query.clone(); + async move { + let rows = if quorum { + crate::cassandra_util::query_rows_quorum( + &self.session, + &query, + cdrs_tokio::query_values!(account_id, table_name), + context, + ) + .await? + } else { + crate::cassandra_util::query_rows( + &self.session, + &query, + cdrs_tokio::query_values!(account_id, table_name), + context, + ) + .await? + }; + Ok::<_, StorageError>(rows.into_iter().next()) + } + }; + let parse = |row: &cdrs_tokio::types::rows::Row| { + let attribute: Option = row.get_by_name("ttl_attribute").ok().flatten(); + let generation: Option = row.get_by_name("ttl_generation").ok().flatten(); + (attribute, generation) + }; + + let Some(row) = read("ttl_config_for_table").await? else { + return Ok(None); + }; + let (attribute, generation) = parse(&row); + let Some(attribute) = attribute else { + return Ok(None); + }; + if let Some(generation) = generation { + return Ok(Some(crate::data::ttl::TtlConfig { + attribute, + generation, + })); + } + + // Legacy row with an attribute but no generation: adopt one via LWT. + let generation = uuid::Uuid::new_v4(); + let adopt = format!( + "UPDATE {}.tables SET ttl_generation = ?, ttl_index_ready = false \ + WHERE account_id = ? AND table_name = ? \ + IF ttl_attribute = ? AND ttl_generation = null", + self.catalog_keyspace() + ); + let result = crate::cassandra_util::query_lwt( + &self.session, + &adopt, + cdrs_tokio::query_values!( + cdrs_tokio::types::value::Bytes::new(generation.as_bytes().to_vec()), + account_id, + table_name, + attribute.as_str() + ), + ) + .await?; + if metadata_lwt_applied(&result)? { + return Ok(Some(crate::data::ttl::TtlConfig { + attribute, + generation, + })); + } + + // Lost the adoption race; re-read to pick up the winner's generation. + let row = read("ttl_config_for_table_recheck").await?; + Ok(row.and_then(|row| { + let (attribute, generation) = parse(&row); + attribute + .zip(generation) + .map(|(attribute, generation)| crate::data::ttl::TtlConfig { + attribute, + generation, + }) + })) + } + + pub(crate) async fn clear_ttl_entries_for_table_id( + &self, + account_id: &str, + table_id: &str, + ) -> Result<(), StorageError> { + crate::data::ttl::clear_ttl_entries(self, &self.account_keyspace(account_id), table_id) + .await + } + + #[doc(hidden)] + pub async fn acquire_current_ttl_sweep_lease( + &self, + account_id: &str, + table_name: &str, + ) -> Result, StorageError> { + let Some(config) = self.ttl_config_for_table(account_id, table_name).await? else { + return Ok(None); + }; + self.acquire_ttl_sweep_lease(account_id, table_name, &config) + .await + } + + pub(crate) async fn acquire_ttl_control_lease( + &self, + account_id: &str, + table_name: &str, + ) -> Result, StorageError> { + let owner = uuid::Uuid::new_v4(); + let query = format!( + "UPDATE {}.tables USING TTL 900 SET ttl_sweep_owner = ? \ + WHERE account_id = ? AND table_name = ? IF ttl_sweep_owner = null \ + AND table_status = 'ACTIVE'", + self.catalog_keyspace() + ); + let result = crate::cassandra_util::query_lwt( + &self.session, + &query, + cdrs_tokio::query_values!( + cdrs_tokio::types::value::Bytes::new(owner.as_bytes().to_vec()), + account_id, + table_name + ), + ) + .await?; + Ok(metadata_lwt_applied(&result)?.then_some(owner)) + } + pub(crate) async fn acquire_ttl_sweep_lease( + &self, + account_id: &str, + table_name: &str, + config: &crate::data::ttl::TtlConfig, + ) -> Result, StorageError> { + let owner = uuid::Uuid::new_v4(); + let query = format!( + "UPDATE {}.tables USING TTL 900 SET ttl_sweep_owner = ? \ + WHERE account_id = ? AND table_name = ? IF ttl_sweep_owner = null \ + AND ttl_attribute = ? AND ttl_generation = ? AND ttl_index_ready = true", + self.catalog_keyspace() + ); + let result = crate::cassandra_util::query_lwt( + &self.session, + &query, + cdrs_tokio::query_values!( + cdrs_tokio::types::value::Bytes::new(owner.as_bytes().to_vec()), + account_id, + table_name, + config.attribute.as_str(), + cdrs_tokio::types::value::Bytes::new(config.generation.as_bytes().to_vec()) + ), + ) + .await?; + Ok(metadata_lwt_applied(&result)?.then_some(owner)) + } + + pub(crate) async fn renew_ttl_sweep_lease( + &self, + account_id: &str, + table_name: &str, + config: &crate::data::ttl::TtlConfig, + owner: uuid::Uuid, + ) -> Result { + let query = format!( + "UPDATE {}.tables USING TTL 900 SET ttl_sweep_owner = ? \ + WHERE account_id = ? AND table_name = ? IF ttl_sweep_owner = ? \ + AND ttl_attribute = ? AND ttl_generation = ? AND ttl_index_ready = true", + self.catalog_keyspace() + ); + let owner_bytes = cdrs_tokio::types::value::Bytes::new(owner.as_bytes().to_vec()); + let result = crate::cassandra_util::query_lwt( + &self.session, + &query, + cdrs_tokio::query_values!( + owner_bytes.clone(), + account_id, + table_name, + owner_bytes, + config.attribute.as_str(), + cdrs_tokio::types::value::Bytes::new(config.generation.as_bytes().to_vec()) + ), + ) + .await?; + metadata_lwt_applied(&result) + } + + #[doc(hidden)] + pub async fn release_ttl_sweep_lease( + &self, + account_id: &str, + table_name: &str, + owner: uuid::Uuid, + ) -> Result<(), StorageError> { + let query = format!( + "UPDATE {}.tables SET ttl_sweep_owner = null \ + WHERE account_id = ? AND table_name = ? IF ttl_sweep_owner = ?", + self.catalog_keyspace() + ); + let result = crate::cassandra_util::query_lwt( + &self.session, + &query, + cdrs_tokio::query_values!( + account_id, + table_name, + cdrs_tokio::types::value::Bytes::new(owner.as_bytes().to_vec()) + ), + ) + .await?; + let _ = metadata_lwt_applied(&result)?; + Ok(()) + } + + pub(crate) async fn pending_ttl_cleanups( + &self, + ) -> Result, StorageError> { + let mut pending = Vec::new(); + for account_id in self.account_ids().await? { + let query = format!( + "SELECT table_name, table_id, ttl_cleanup_generation FROM {}.tables \ + WHERE account_id = ?", + self.catalog_keyspace() + ); + let rows = crate::cassandra_util::query_rows( + &self.session, + &query, + cdrs_tokio::query_values!(account_id.as_str()), + "pending_ttl_cleanups", + ) + .await?; + for row in rows { + let generation: Option = + row.get_by_name("ttl_cleanup_generation").ok().flatten(); + if let Some(generation) = generation { + pending.push(( + account_id.clone(), + crate::cassandra_util::get_column( + &row, + "table_name", + "pending_ttl_cleanups", + )?, + crate::cassandra_util::get_column( + &row, + "table_id", + "pending_ttl_cleanups", + )?, + generation, + )); + } + } + } + Ok(pending) + } + + /// Finish retiring a TTL generation. + /// + /// Drains any work that was already claimed when the generation was retired, + /// then removes the generation's `PENDING` rows. The + /// `ttl_cleanup_generation` marker is cleared only once nothing is left, so + /// a partial pass is retried by the worker instead of stranding durable + /// state. + pub(crate) async fn complete_ttl_cleanup( + &self, + account_id: &str, + table_name: &str, + table_id: &str, + generation: uuid::Uuid, + ) -> Result<(), StorageError> { + crate::ttl_worker::drain_retired_generation(self, account_id, table_name, generation) + .await?; + let fully_drained = crate::data::ttl::clear_ttl_generation( + self, + &self.account_keyspace(account_id), + table_id, + generation, + ) + .await?; + if !fully_drained { + tracing::info!( + table = %table_name, + "TTL generation cleanup still has in-flight work; will retry" + ); + return Ok(()); + } + let query = format!( + "UPDATE {}.tables SET ttl_cleanup_generation = null \ + WHERE account_id = ? AND table_name = ? IF ttl_cleanup_generation = ?", + self.catalog_keyspace() + ); + let result = crate::cassandra_util::query_lwt( + &self.session, + &query, + cdrs_tokio::query_values!( + account_id, + table_name, + cdrs_tokio::types::value::Bytes::new(generation.as_bytes().to_vec()) + ), + ) + .await?; + let _ = metadata_lwt_applied(&result)?; + Ok(()) + } + + /// Read the current item for a key, but only when the table has TTL enabled. + /// + /// Used by the transaction commit path to capture the pre-commit image it + /// needs in order to retire the item's previous expiration entry. + pub(crate) async fn pre_commit_ttl_image( + &self, + key_info: &extenddb_core::types::TableKeyInfo, + key: &Item, + ) -> Result, StorageError> { + if self + .ttl_config_for_table(&key_info.account_id, &key_info.table_name) + .await? + .is_none() + { + return Ok(None); + } + self.get_item_quorum(key_info, key).await + } + + /// Move an item's expiration entry from the queue key implied by `old` to + /// the one implied by `new`. + /// + /// A stale entry is only removed while it is still `PENDING`. If expiration + /// work has already claimed it, the claim is left alone: the worker + /// revalidates the item image and retires its own work. + pub(crate) async fn reconcile_ttl_transition( + &self, + key_info: &extenddb_core::types::TableKeyInfo, + old: Option<&Item>, + new: Option<&Item>, + ) -> Result<(), StorageError> { + let Some(config) = self + .ttl_config_for_table(&key_info.account_id, &key_info.table_name) + .await? + else { + return Ok(()); + }; + let account_keyspace = self.account_keyspace(&key_info.account_id); + let old_entry = old + .map(|item| crate::data::ttl::entry_for_item(key_info, item, &config.attribute)) + .transpose()? + .flatten(); + let new_entry = new + .map(|item| crate::data::ttl::entry_for_item(key_info, item, &config.attribute)) + .transpose()? + .flatten(); + + if let Some(old_entry) = old_entry.filter(|old| Some(old) != new_entry.as_ref()) { + crate::data::ttl::retire_pending_ttl_work( + self, + &account_keyspace, + key_info, + config.generation, + &old_entry, + ) + .await?; + } + if let Some(new_entry) = new_entry { + crate::data::ttl::insert_ttl_entry( + self, + &account_keyspace, + &key_info.table_id, + config.generation, + &new_entry, + ) + .await?; + } + Ok(()) + } + + pub(crate) async fn reconcile_ttl_item( + &self, + key_info: &extenddb_core::types::TableKeyInfo, + item: &Item, + ) -> Result<(), StorageError> { + let Some(config) = self + .ttl_config_for_table(&key_info.account_id, &key_info.table_name) + .await? + else { + return Ok(()); + }; + self.reconcile_ttl_item_with_config(key_info, item, &config) + .await + } + + /// [`Self::reconcile_ttl_item`] for callers that already hold the table's + /// TTL configuration, so reconciling does not re-read the catalog. The + /// worker calls this once per processed row; the caller is responsible for + /// the config being current, which the sweep already guarantees by + /// re-checking it between rows. + pub(crate) async fn reconcile_ttl_item_with_config( + &self, + key_info: &extenddb_core::types::TableKeyInfo, + item: &Item, + config: &crate::data::ttl::TtlConfig, + ) -> Result<(), StorageError> { + let Some(entry) = crate::data::ttl::entry_for_item(key_info, item, &config.attribute)? + else { + return Ok(()); + }; + crate::data::ttl::insert_ttl_entry( + self, + &self.account_keyspace(&key_info.account_id), + &key_info.table_id, + config.generation, + &entry, + ) + .await + } + + pub(crate) async fn reconcile_ttl_item_by_table_id( + &self, + table_id: &str, + item_data: &str, + ) -> Result<(), StorageError> { + let query = format!( + "SELECT account_id, table_name FROM {}.tables WHERE table_id = ?", + self.catalog_keyspace() + ); + let rows = crate::cassandra_util::query_rows( + &self.session, + &query, + cdrs_tokio::query_values!(table_id), + "reconcile_ttl_item_by_table_id", + ) + .await?; + let Some(row) = rows.first() else { + return Ok(()); + }; + let account_id: String = + crate::cassandra_util::get_column(row, "account_id", "reconcile_ttl_item_by_table_id")?; + let table_name: String = + crate::cassandra_util::get_column(row, "table_name", "reconcile_ttl_item_by_table_id")?; + let key_info = self.fetch_table_key_info(&account_id, &table_name).await?; + let item: Item = serde_json::from_str(item_data).map_err(|error| { + StorageError::Internal(format!("Parse recovered TTL item: {error}")) + })?; + self.reconcile_ttl_item(&key_info, &item).await + } + + /// Scan the table and register an expiration entry for every item that + /// carries a valid TTL timestamp, then publish the generation as ready. + /// + /// Runs under the caller's control lease. The scan has no durable cursor, so + /// a failure restarts it from the beginning on the next cycle; entry inserts + /// are conditional, so repeating the scan is idempotent. + async fn backfill_ttl_queue( + &self, + account_id: &str, + table_name: &str, + ttl_attribute: &str, + config: &crate::data::ttl::TtlConfig, + ) -> Result<(), StorageError> { + let key_info = self.fetch_table_key_info(account_id, table_name).await?; + let account_keyspace = self.account_keyspace(account_id); + + let mut start_key = None; + loop { + if self.ttl_config_for_table(account_id, table_name).await? != Some(config.clone()) { + return Ok(()); + } + let (items, next_key) = self + .scan_impl(&key_info, Some(1_000), start_key.as_ref(), None, None, None) + .await?; + for item in items { + if let Some(entry) = + crate::data::ttl::entry_for_item(&key_info, &item, ttl_attribute)? + { + crate::data::ttl::insert_ttl_entry( + self, + &account_keyspace, + &key_info.table_id, + config.generation, + &entry, + ) + .await?; + } + } + match next_key { + Some(key) => start_key = Some(key), + None => break, + } + } + + let query = format!( + "UPDATE {}.tables SET ttl_index_ready = true \ + WHERE account_id = ? AND table_name = ? \ + IF ttl_attribute = ? AND ttl_generation = ? AND table_status = 'ACTIVE'", + self.catalog_keyspace() + ); + let ready_result = crate::cassandra_util::query_lwt( + &self.session, + &query, + cdrs_tokio::query_values!( + account_id, + table_name, + ttl_attribute, + cdrs_tokio::types::value::Bytes::new(config.generation.as_bytes().to_vec()) + ), + ) + .await + .map_err(|error| StorageError::Internal(format!("Mark TTL queue ready: {error}")))?; + let _ = metadata_lwt_applied(&ready_result)?; + Ok(()) + } + + async fn account_ids(&self) -> Result, StorageError> { + let query = format!( + "SELECT account_id FROM {}.accounts", + self.catalog_keyspace() + ); + let rows = crate::cassandra_util::query_rows( + &self.session, + &query, + cdrs_tokio::query_values!(), + "ttl_account_ids", + ) + .await?; + rows.iter() + .map(|row| crate::cassandra_util::get_column(row, "account_id", "ttl_account_ids")) + .collect() + } + + async fn ttl_tables_for_account( + &self, + account_id: &str, + require_ready: bool, + ) -> Result, StorageError> { + let query = format!( + "SELECT table_name, table_status, ttl_attribute, ttl_index_ready \ + FROM {}.tables WHERE account_id = ?", + self.catalog_keyspace() + ); + let rows = crate::cassandra_util::query_rows( + &self.session, + &query, + cdrs_tokio::query_values!(account_id), + "ttl_tables_for_account", + ) + .await?; + let mut tables = Vec::new(); + for row in rows { + let status: String = + crate::cassandra_util::get_column(&row, "table_status", "ttl_tables_for_account")?; + let attribute: Option = row.get_by_name("ttl_attribute").ok().flatten(); + let ready: bool = row + .get_by_name("ttl_index_ready") + .ok() + .flatten() + .unwrap_or(false); + if status == "ACTIVE" && (!require_ready || ready) { + let Some(attribute) = attribute else { + continue; + }; + let table_name: String = crate::cassandra_util::get_column( + &row, + "table_name", + "ttl_tables_for_account", + )?; + tables.push((table_name, attribute)); + } + } + Ok(tables) + } +} + impl MetadataEngine for CassandraEngine { fn describe_ttl( &self, - _account_id: &str, - _table_name: &str, + account_id: &str, + table_name: &str, ) -> BoxFuture<'_, Result> { - Box::pin(async move { todo!("describe_ttl not implemented") }) + let account_id = account_id.to_owned(); + let table_name = table_name.to_owned(); + Box::pin(async move { + let query = format!( + "SELECT ttl_attribute FROM {}.tables WHERE account_id = ? AND table_name = ?", + self.catalog_keyspace() + ); + let row = crate::cassandra_util::query_optional( + &self.session, + &query, + cdrs_tokio::query_values!(account_id.as_str(), table_name.as_str()), + "describe_ttl", + ) + .await? + .ok_or_else(|| StorageError::TableNotFound(table_name.clone()))?; + let attribute: Option = row.get_by_name("ttl_attribute").ok().flatten(); + Ok(TimeToLiveDescription { + time_to_live_status: if attribute.is_some() { + TimeToLiveStatus::Enabled + } else { + TimeToLiveStatus::Disabled + }, + attribute_name: attribute, + }) + }) } fn update_ttl( &self, - _account_id: &str, - _table_name: &str, - _attribute_name: &str, - _enabled: bool, + account_id: &str, + table_name: &str, + attribute_name: &str, + enabled: bool, ) -> BoxFuture<'_, Result<(), StorageError>> { - Box::pin(async move { todo!("update_ttl not implemented") }) + let account_id = account_id.to_owned(); + let table_name = table_name.to_owned(); + let attribute_name = attribute_name.to_owned(); + Box::pin(async move { + let status_query = format!( + "SELECT table_status, table_id, ttl_generation FROM {}.tables \ + WHERE account_id = ? AND table_name = ?", + self.catalog_keyspace() + ); + let row = crate::cassandra_util::query_optional( + &self.session, + &status_query, + cdrs_tokio::query_values!(account_id.as_str(), table_name.as_str()), + "update_ttl_status", + ) + .await? + .ok_or_else(|| StorageError::TableNotFound(table_name.clone()))?; + let status: String = + crate::cassandra_util::get_column(&row, "table_status", "update_ttl_status")?; + let table_id: String = + crate::cassandra_util::get_column(&row, "table_id", "update_ttl_status")?; + let previous_generation: Option = + row.get_by_name("ttl_generation").ok().flatten(); + if status != "ACTIVE" { + return Err(StorageError::TableNotActive(table_name)); + } + if enabled { + let indexes = crate::data::index::fetch_indexes_for_table( + &table_id, + &self.session, + &self.catalog_keyspace(), + ) + .await?; + let default_delay = self + .gsi_default_delay_ms + .load(std::sync::atomic::Ordering::Relaxed); + if indexes.iter().any(|index| { + index.index_type == "GSI" + && crate::data::index::effective_delay(index, default_delay) != 0 + }) { + return Err(StorageError::Validation( + "TTL is not supported while a table has asynchronously propagated GSIs" + .to_owned(), + )); + } + } + + let generation = uuid::Uuid::new_v4(); + let cleanup_generation = previous_generation.unwrap_or(generation); + // Work that is already claimed is not a reason to refuse the + // change. Disabling records `ttl_cleanup_generation` and the + // cleanup pass drains that work before removing the generation, so + // the caller does not have to observe or retry around it. + let query = if enabled { + format!( + "UPDATE {}.tables SET ttl_attribute = ?, ttl_generation = ?, \ + ttl_index_ready = false WHERE account_id = ? AND table_name = ? \ + IF table_status = 'ACTIVE' AND ttl_sweep_owner = null \ + AND ttl_cleanup_generation = null \ + AND ttl_attribute = null AND ttl_generation = null", + self.catalog_keyspace() + ) + } else { + format!( + "UPDATE {}.tables SET ttl_attribute = null, ttl_generation = null, \ + ttl_cleanup_generation = ?, ttl_index_ready = false \ + WHERE account_id = ? AND table_name = ? \ + IF table_status = 'ACTIVE' AND ttl_sweep_owner = null \ + AND ttl_cleanup_generation = null \ + AND ttl_attribute = ? AND ttl_generation = ?", + self.catalog_keyspace() + ) + }; + let values = if enabled { + cdrs_tokio::query_values!( + attribute_name.as_str(), + cdrs_tokio::types::value::Bytes::new(generation.as_bytes().to_vec()), + account_id.as_str(), + table_name.as_str() + ) + } else { + cdrs_tokio::query_values!( + cdrs_tokio::types::value::Bytes::new(cleanup_generation.as_bytes().to_vec()), + account_id.as_str(), + table_name.as_str(), + attribute_name.as_str(), + cdrs_tokio::types::value::Bytes::new(cleanup_generation.as_bytes().to_vec()) + ) + }; + + // A sweep holds `ttl_sweep_owner` for the duration of one batch, and + // a sweep starts every scan interval for every TTL-enabled table, so + // colliding with one is routine. Retry briefly rather than making + // the caller absorb a lease collision as a table-state error. + let mut applied = false; + let mut sweep_in_progress = false; + for attempt in 0..=TTL_CONTROL_MAX_RETRIES { + let result = + crate::cassandra_util::query_lwt(&self.session, &query, values.clone()).await?; + let row = result + .response_body() + .ok() + .and_then(|body| body.into_rows()) + .and_then(|rows| rows.into_iter().next()); + let Some(row) = row else { break }; + applied = row.get_r_by_name("[applied]").unwrap_or(false); + if applied { + break; + } + let sweep_owner: Option = + row.get_by_name("ttl_sweep_owner").ok().flatten(); + sweep_in_progress = sweep_owner.is_some(); + if !sweep_in_progress || attempt == TTL_CONTROL_MAX_RETRIES { + break; + } + tokio::time::sleep(std::time::Duration::from_millis( + TTL_CONTROL_RETRY_DELAY_MS * u64::from(attempt + 1), + )) + .await; + } + if !applied { + if sweep_in_progress { + return Err(StorageError::IndexesInUse(format!( + "Time to live for table {table_name} cannot be changed while an \ + expiration sweep is in progress. Retry the request." + ))); + } + let row = crate::cassandra_util::query_optional( + &self.session, + &status_query, + cdrs_tokio::query_values!(account_id.as_str(), table_name.as_str()), + "update_ttl_recheck", + ) + .await?; + return if row.is_some() { + Err(StorageError::TableNotActive(table_name)) + } else { + Err(StorageError::TableNotFound(table_name)) + }; + } + if !enabled { + self.complete_ttl_cleanup(&account_id, &table_name, &table_id, cleanup_generation) + .await?; + } + Ok(()) + }) } fn tag_resource(&self, arn: &str, tags: &[Tag]) -> BoxFuture<'_, Result<(), StorageError>> { - let arn = arn.to_string(); + let arn = arn.to_owned(); let tags = tags.to_vec(); let catalog = self.catalog_keyspace(); Box::pin(async move { @@ -48,8 +827,8 @@ impl MetadataEngine for CassandraEngine { ), ) .await - .map_err(|e| { - tracing::error!("tag_resource: {e}"); + .map_err(|error| { + tracing::error!("tag_resource: {error}"); StorageError::Internal("Database error".to_owned()) })?; } @@ -62,7 +841,7 @@ impl MetadataEngine for CassandraEngine { arn: &str, tag_keys: &[String], ) -> BoxFuture<'_, Result<(), StorageError>> { - let arn = arn.to_string(); + let arn = arn.to_owned(); let tag_keys = tag_keys.to_vec(); let catalog = self.catalog_keyspace(); Box::pin(async move { @@ -75,8 +854,8 @@ impl MetadataEngine for CassandraEngine { cdrs_tokio::query_values!(arn.as_str(), key.as_str()), ) .await - .map_err(|e| { - tracing::error!("untag_resource: {e}"); + .map_err(|error| { + tracing::error!("untag_resource: {error}"); StorageError::Internal("Database error".to_owned()) })?; } @@ -85,7 +864,7 @@ impl MetadataEngine for CassandraEngine { } fn list_tags(&self, arn: &str) -> BoxFuture<'_, Result, StorageError>> { - let arn = arn.to_string(); + let arn = arn.to_owned(); let catalog = self.catalog_keyspace(); Box::pin(async move { let query = @@ -118,66 +897,345 @@ impl MetadataEngine for CassandraEngine { fn tables_with_ttl( &self, - _account_id: &str, + account_id: &str, ) -> BoxFuture<'_, Result, StorageError>> { - Box::pin(async move { todo!("tables_with_ttl not implemented") }) + let account_id = account_id.to_owned(); + Box::pin(async move { self.ttl_tables_for_account(&account_id, false).await }) } fn all_tables_with_ttl( &self, ) -> BoxFuture<'_, Result, StorageError>> { - Box::pin(async move { todo!("all_tables_with_ttl not implemented") }) + Box::pin(async move { + let mut result = Vec::new(); + for account_id in self.account_ids().await? { + for (table_name, attribute) in + self.ttl_tables_for_account(&account_id, false).await? + { + result.push((account_id.clone(), table_name, attribute)); + } + } + Ok(result) + }) } fn all_tables_with_ttl_index_ready( &self, ) -> BoxFuture<'_, Result, StorageError>> { - Box::pin(async move { todo!("all_tables_with_ttl_index_ready not implemented") }) + Box::pin(async move { + let mut result = Vec::new(); + for account_id in self.account_ids().await? { + for (table_name, attribute) in + self.ttl_tables_for_account(&account_id, true).await? + { + result.push((account_id.clone(), table_name, attribute)); + } + } + Ok(result) + }) } fn create_ttl_index( &self, - _account_id: &str, - _table_name: &str, - _ttl_attribute: &str, + account_id: &str, + table_name: &str, + ttl_attribute: &str, ) -> BoxFuture<'_, Result<(), StorageError>> { - Box::pin(async move { todo!("create_ttl_index not implemented") }) + let account_id = account_id.to_owned(); + let table_name = table_name.to_owned(); + let ttl_attribute = ttl_attribute.to_owned(); + Box::pin(async move { + let Some(config) = self.ttl_config_for_table(&account_id, &table_name).await? else { + return Ok(()); + }; + if config.attribute != ttl_attribute { + return Ok(()); + } + // The backfill is a full table scan. Take the table's control lease + // so one host scans it at a time: the enable request and every + // host's retry pass all land here, and without the lease they would + // each rescan the whole table. Returning early is safe — whoever + // holds the lease publishes `ttl_index_ready`. + let Some(owner) = self + .acquire_ttl_control_lease(&account_id, &table_name) + .await? + else { + return Ok(()); + }; + let result = self + .backfill_ttl_queue(&account_id, &table_name, &ttl_attribute, &config) + .await; + let _ = self + .release_ttl_sweep_lease(&account_id, &table_name, owner) + .await; + result + }) } fn drop_ttl_index( &self, - _account_id: &str, - _table_name: &str, + account_id: &str, + table_name: &str, ) -> BoxFuture<'_, Result<(), StorageError>> { - Box::pin(async move { todo!("drop_ttl_index not implemented") }) + let account_id = account_id.to_owned(); + let table_name = table_name.to_owned(); + Box::pin(async move { + let ready_query = format!( + "UPDATE {}.tables SET ttl_index_ready = false \ + WHERE account_id = ? AND table_name = ? IF ttl_attribute = null", + self.catalog_keyspace() + ); + let ready_result = crate::cassandra_util::query_lwt( + &self.session, + &ready_query, + cdrs_tokio::query_values!(account_id.as_str(), table_name.as_str()), + ) + .await + .map_err(|error| StorageError::Internal(format!("Disable TTL queue: {error}")))?; + let _ = metadata_lwt_applied(&ready_result)?; + Ok(()) + }) } fn find_expired_items_indexed( &self, - _account_id: &str, - _table_name: &str, - _ttl_attribute: &str, - _limit: usize, + account_id: &str, + table_name: &str, + ttl_attribute: &str, + limit: usize, ) -> BoxFuture<'_, Result, StorageError>> { - Box::pin(async move { todo!("find_expired_items_indexed not implemented") }) + let account_id = account_id.to_owned(); + let table_name = table_name.to_owned(); + let ttl_attribute = ttl_attribute.to_owned(); + Box::pin(async move { + if limit == 0 { + return Ok(Vec::new()); + } + let Some(config) = self.ttl_config_for_table(&account_id, &table_name).await? else { + return Ok(Vec::new()); + }; + if config.attribute != ttl_attribute { + return Ok(Vec::new()); + } + let key_info = self.fetch_table_key_info(&account_id, &table_name).await?; + let account_keyspace = self.account_keyspace(&account_id); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + let current_bucket = now / crate::data::ttl::TTL_BUCKET_SECONDS; + let bucket_query = format!( + "SELECT bucket, shard FROM {account_keyspace}.ttl_expiration_buckets \ + WHERE table_id = ? AND generation = ? AND bucket <= ?" + ); + let bucket_rows = crate::cassandra_util::query_rows( + &self.session, + &bucket_query, + cdrs_tokio::query_values!( + key_info.table_id.as_str(), + cdrs_tokio::types::value::Bytes::new(config.generation.as_bytes().to_vec()), + current_bucket + ), + "find_expired_buckets", + ) + .await?; + let mut partitions: Vec<(i64, i32)> = Vec::with_capacity(bucket_rows.len()); + for row in bucket_rows { + partitions.push(( + crate::cassandra_util::get_column(&row, "bucket", "find_expired_buckets")?, + crate::cassandra_util::get_column(&row, "shard", "find_expired_buckets")?, + )); + } + if !partitions.is_empty() { + let rotation = ((now / 60) as usize) % partitions.len(); + partitions.rotate_left(rotation); + } + + let mut expired = Vec::with_capacity(limit); + for (index, (bucket, shard)) in partitions.iter().enumerate() { + let remaining = limit - expired.len(); + if remaining == 0 { + break; + } + let partitions_left = partitions.len() - index; + let partition_limit = remaining.div_ceil(partitions_left).max(1); + let query = format!( + "SELECT expires_at, key_hash, key_data \ + FROM {account_keyspace}.ttl_expirations \ + WHERE table_id = ? AND generation = ? AND bucket = ? AND shard = ? \ + AND expires_at <= ? LIMIT {partition_limit}" + ); + let rows = crate::cassandra_util::query_rows( + &self.session, + &query, + cdrs_tokio::query_values!( + key_info.table_id.as_str(), + cdrs_tokio::types::value::Bytes::new(config.generation.as_bytes().to_vec()), + *bucket, + *shard, + now + ), + "find_expired_items", + ) + .await?; + for row in rows { + let entry = crate::data::ttl::TtlEntry { + bucket: *bucket, + expires_at: crate::cassandra_util::get_column( + &row, + "expires_at", + "find_expired_items", + )?, + shard: *shard, + key_hash: crate::cassandra_util::get_column( + &row, + "key_hash", + "find_expired_items", + )?, + key_data: crate::cassandra_util::get_column( + &row, + "key_data", + "find_expired_items", + )?, + }; + let key: Item = serde_json::from_str(&entry.key_data).map_err(|error| { + StorageError::Internal(format!("Parse TTL key: {error}")) + })?; + let current = self.get_item_quorum(&key_info, &key).await?; + if let Some(item) = current.as_ref().filter(|item| { + crate::data::ttl::ttl_epoch_seconds(item, &ttl_attribute) + == Some(entry.expires_at) + }) { + expired.push(item.clone()); + } else if crate::data::ttl::retire_pending_ttl_work( + self, + &account_keyspace, + &key_info, + config.generation, + &entry, + ) + .await? + && let Some(item) = current + { + self.reconcile_ttl_item_with_config(&key_info, &item, &config) + .await?; + } + } + } + Ok(expired) + }) } fn refresh_table_size( &self, - _account_id: &str, - _table_name: &str, + account_id: &str, + table_name: &str, ) -> BoxFuture<'_, Result<(), StorageError>> { - Box::pin(async move { todo!("refresh_table_size not implemented") }) + let account_id = account_id.to_owned(); + let table_name = table_name.to_owned(); + Box::pin(async move { + let key_info = self.fetch_table_key_info(&account_id, &table_name).await?; + let mut count = 0_i64; + let mut size = 0_i64; + let mut start_key = None; + loop { + let (items, next_key) = self + .scan_impl(&key_info, Some(1_000), start_key.as_ref(), None, None, None) + .await?; + for item in items { + count += 1; + size = size.saturating_add( + serde_json::to_vec(&item) + .map_err(|error| StorageError::Internal(error.to_string()))? + .len() as i64, + ); + } + match next_key { + Some(key) => start_key = Some(key), + None => break, + } + } + let query = format!( + "UPDATE {}.tables SET item_count = ?, table_size_bytes = ? \ + WHERE account_id = ? AND table_name = ?", + self.catalog_keyspace() + ); + self.session + .query_with_values( + &query, + cdrs_tokio::query_values!( + count, + size, + account_id.as_str(), + table_name.as_str() + ), + ) + .await + .map_err(|error| StorageError::Internal(format!("Refresh table size: {error}")))?; + Ok(()) + }) } fn list_active_table_names( &self, - _account_id: &str, + account_id: &str, ) -> BoxFuture<'_, Result, StorageError>> { - Box::pin(async move { todo!("list_active_table_names not implemented") }) + let account_id = account_id.to_owned(); + Box::pin(async move { + let query = format!( + "SELECT table_name, table_status FROM {}.tables WHERE account_id = ?", + self.catalog_keyspace() + ); + let rows = crate::cassandra_util::query_rows( + &self.session, + &query, + cdrs_tokio::query_values!(account_id.as_str()), + "list_active_table_names", + ) + .await?; + let mut tables = Vec::new(); + for row in rows { + let status: String = crate::cassandra_util::get_column( + &row, + "table_status", + "list_active_table_names", + )?; + if status == "ACTIVE" { + tables.push(crate::cassandra_util::get_column( + &row, + "table_name", + "list_active_table_names", + )?); + } + } + Ok(tables) + }) } fn all_active_tables(&self) -> BoxFuture<'_, Result, StorageError>> { - Box::pin(async move { todo!("all_active_tables not implemented") }) + Box::pin(async move { + let mut result = Vec::new(); + for account_id in self.account_ids().await? { + for table_name in self.list_active_table_names(&account_id).await? { + result.push((account_id.clone(), table_name)); + } + } + Ok(result) + }) } } + +fn metadata_lwt_applied(result: &cdrs_tokio::frame::Envelope) -> Result { + let rows = result + .response_body() + .map_err(|error| StorageError::Internal(format!("Parse TTL metadata LWT: {error}")))? + .into_rows() + .unwrap_or_default(); + let Some(row) = rows.first() else { + return Err(StorageError::Internal( + "TTL metadata LWT returned no result".to_owned(), + )); + }; + row.get_r_by_name("[applied]") + .map_err(|error| StorageError::Internal(format!("Parse TTL metadata result: {error}"))) +} diff --git a/crates/storage-cassandra/src/stream_util.rs b/crates/storage-cassandra/src/stream_util.rs index b68b6368..f31f9962 100644 --- a/crates/storage-cassandra/src/stream_util.rs +++ b/crates/storage-cassandra/src/stream_util.rs @@ -110,6 +110,13 @@ pub fn assign_shard_id(partition_key: &str, table_id: &str) -> String { /// Zero sequence number used as the starting point for new shards. pub const ZERO_SEQUENCE: &str = "00000000000000000000000"; // 23 zeros +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct StreamRecordIdentity { + pub event_id: String, + pub sequence_number: String, + pub created_at_ms: i64, +} + /// Build a CQL INSERT statement for a stream record to be included in a LOGGED BATCH. /// /// Returns `None` if no record should be written (both old and new items are absent, @@ -138,6 +145,41 @@ pub fn stream_record_statement( capture: &StreamCapture, hlc: &Arc>, retention_seconds: u32, +) -> Option { + let identity = StreamRecordIdentity { + event_id: uuid::Uuid::new_v4().to_string(), + sequence_number: hlc + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .generate(), + created_at_ms: chrono::Utc::now().timestamp_millis(), + }; + stream_record_statement_with_identity( + account_keyspace, + table_id, + key_info, + old_item, + new_item, + capture, + &identity, + retention_seconds, + ) +} + +/// Build a stream record statement with a caller-supplied event identity. +/// +/// TTL expiration persists its identity before applying effects so that a retry +/// rewrites the same record instead of publishing a second visible `REMOVE`. +#[allow(clippy::too_many_arguments)] +pub fn stream_record_statement_with_identity( + account_keyspace: &str, + table_id: &str, + key_info: &TableKeyInfo, + old_item: Option<&Item>, + new_item: Option<&Item>, + capture: &StreamCapture, + identity: &StreamRecordIdentity, + retention_seconds: u32, ) -> Option { let source = new_item.or(old_item)?; @@ -181,23 +223,19 @@ pub fn stream_record_statement( .unwrap_or_default(); let shard_id = assign_shard_id(&pk_str, table_id); - let sequence_number = hlc - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .generate(); let record = StreamRecord { - event_id: uuid::Uuid::new_v4().to_string(), + event_id: identity.event_id.clone(), event_name: event, event_version: "1.1".to_owned(), event_source: "aws:dynamodb".to_owned(), aws_region: capture.region.to_string(), dynamodb: StreamRecordData { - approximate_creation_date_time: chrono::Utc::now().timestamp(), + approximate_creation_date_time: identity.created_at_ms / 1_000, keys, new_image, old_image, - sequence_number, + sequence_number: identity.sequence_number.clone(), size_bytes: size, stream_view_type: capture.view_type, }, @@ -207,7 +245,7 @@ pub fn stream_record_statement( let record_json = serde_json::to_string(&record).ok()?; let event_name = format!("{:?}", record.event_name); let seq = &record.dynamodb.sequence_number; - let now_ms = chrono::Utc::now().timestamp_millis(); + let now_ms = identity.created_at_ms; Some(format!( "INSERT INTO {account_keyspace}.stream_records \ diff --git a/crates/storage-cassandra/src/ttl_worker.rs b/crates/storage-cassandra/src/ttl_worker.rs new file mode 100644 index 00000000..c65d15a9 --- /dev/null +++ b/crates/storage-cassandra/src/ttl_worker.rs @@ -0,0 +1,1157 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Background processing for DynamoDB TTL expiration. + +use std::sync::Arc; +use std::time::Duration; + +use extenddb_core::metrics::MetricsCollector; +use extenddb_storage::error::StorageError; +use extenddb_storage::{CancellationToken, MetadataEngine, TableEngine, sleep_or_shutdown}; + +use crate::CassandraEngine; + +const SCAN_INTERVAL: Duration = Duration::from_secs(60); +const BATCH_SIZE: usize = 100; +/// Rows drained per cleanup pass for a retired generation. Cleanup is retried +/// every cycle until the generation is empty, so this only bounds one pass. +const DRAIN_BATCH_SIZE: usize = 100; + +pub(crate) async fn ttl_cleanup_worker( + storage: Arc, + metrics: Arc, + token: CancellationToken, +) { + while sleep_or_shutdown(&token, SCAN_INTERVAL).await { + if let Err(error) = reconcile_pending_once(&storage, 1_000).await { + tracing::warn!("TTL worker: reconciliation outbox failed: {error}"); + } + retry_pending_cleanup(&storage).await; + retry_pending_indexes(&storage).await; + sweep_once(&storage, &metrics).await; + } +} + +/// Run ambiguous-destroy repair independently from expiration sweeps. +/// +/// Unresolved markers are deliberately non-dischargeable and can accumulate +/// after repeated process crashes. Keeping this pass on its own task prevents +/// that operational debt from delaying the bounded expiration worker. +pub(crate) async fn ttl_repair_worker(storage: Arc, token: CancellationToken) { + while sleep_or_shutdown(&token, SCAN_INTERVAL).await { + if let Err(error) = reconcile_inflight_repairs_once(&storage).await { + tracing::warn!("TTL worker: inflight repair reconciliation failed: {error}"); + } + } +} + +/// Drain up to `limit` durable reconciliation records. Rows are removed only +/// after the current base item and its bucket registration have been reconciled +/// into the active generation using quorum-authoritative metadata. +pub async fn reconcile_pending_once( + storage: &CassandraEngine, + limit: usize, +) -> Result { + reconcile_pending_older_than(storage, limit, 0).await +} + +/// [`reconcile_pending_once`] with an explicit minimum record age in seconds. +/// A negative age is useful in tests to include a just-inserted coordinator +/// timeuuid. Production no longer needs an age fence: ambiguous destroys retain +/// a separate non-dischargeable inflight marker until their result is definite. +pub async fn reconcile_pending_older_than( + storage: &CassandraEngine, + limit: usize, + min_age_seconds: i64, +) -> Result { + use cdrs_tokio::types::IntoRustByName; + + let discharge_before_ms = chrono::Utc::now().timestamp_millis() - min_age_seconds * 1_000; + if limit == 0 { + return Ok(0); + } + let keyspaces = crate::workers::list_account_keyspaces(storage).await?; + let slots = crate::data::ttl::TTL_SHARDS as usize; + // A global first-N scan can starve later keyspaces forever under sustained + // writes. Treat `limit` as a per-account soft bound and give every one of + // its 64 partitions a quota each pass. Each non-empty slot receives at + // least one row. + let per_slot = limit.div_ceil(slots).max(1); + let mut processed = 0usize; + for keyspace in keyspaces { + for partition in 0..crate::data::ttl::TTL_SHARDS { + // Page through the partition with a keyset cursor so a prefix of + // persistently failing rows cannot permanently hide the valid + // records behind it: each page resumes strictly after the last row + // of the previous one, whether or not that row was processable. + // Pages are bounded per cycle; anything beyond carries over. + let page = per_slot.saturating_mul(4).max(per_slot).min(4_096); + let mut page_cursor: Option = None; + let mut slot_processed = 0usize; + 'pages: for _ in 0..OUTBOX_MAX_PAGES_PER_PARTITION { + let query = match page_cursor { + Some(after) => format!( + "SELECT id, table_id, account_id, table_name, key_data \ + FROM {keyspace}.ttl_reconcile_pending WHERE worker_partition = ? \ + AND id > {after} AND id < maxTimeuuid({discharge_before_ms}) LIMIT {page}" + ), + None => format!( + "SELECT id, table_id, account_id, table_name, key_data \ + FROM {keyspace}.ttl_reconcile_pending WHERE worker_partition = ? \ + AND id < maxTimeuuid({discharge_before_ms}) LIMIT {page}" + ), + }; + let rows = match crate::cassandra_util::query_rows( + &storage.session, + &query, + cdrs_tokio::query_values!(partition), + "ttl_reconcile_pending", + ) + .await + { + Ok(rows) => rows, + Err(error) if crate::workers::is_table_not_found(&error) => break, + // This pass covers every account keyspace; a failure in one + // must not abort the rest. The rows stay for the next cycle. + Err(error) => { + tracing::warn!( + "TTL worker: outbox partition {partition} failed in {keyspace}: {error}" + ); + break 'pages; + } + }; + let page_was_full = rows.len() >= page; + for row in rows { + // the durable guarantee that an item reaches the queue at all, + // so propagating a per-row failure would starve reconciliation + // for every other item — and those items would never expire. + // Each failure is confined to its row, which is left in place + // for the next cycle. + // The cursor advances on the id alone, before the rest of the + // row is parsed: a row with a readable id but unreadable + // payload must still be paged past, or it stalls the cursor on + // itself forever. + let id: uuid::Uuid = match row.get_r_by_name("id") { + Ok(id) => id, + Err(_) => { + tracing::warn!("TTL worker: outbox row with unreadable id skipped"); + continue; + } + }; + page_cursor = Some(id); + let parsed = (|| -> Result<_, StorageError> { + let table_id: String = crate::cassandra_util::get_column( + &row, + "table_id", + "ttl_reconcile_pending", + )?; + let account_id: String = crate::cassandra_util::get_column( + &row, + "account_id", + "ttl_reconcile_pending", + )?; + let table_name: String = crate::cassandra_util::get_column( + &row, + "table_name", + "ttl_reconcile_pending", + )?; + let key_data: String = crate::cassandra_util::get_column( + &row, + "key_data", + "ttl_reconcile_pending", + )?; + let key: extenddb_core::types::Item = serde_json::from_str(&key_data) + .map_err(|error| { + StorageError::Internal(format!("Parse TTL outbox key: {error}")) + })?; + Ok((table_id, account_id, table_name, key)) + })(); + let (table_id, account_id, table_name, key) = match parsed { + Ok(parsed) => parsed, + Err(error) => { + tracing::warn!("TTL worker: unreadable outbox row skipped: {error}"); + continue; + } + }; + + let reconcile = match storage + .fetch_table_key_info_quorum(&account_id, &table_name) + .await + { + Ok(key_info) if key_info.table_id == table_id => { + let config = storage + .ttl_config_for_table_quorum(&account_id, &table_name) + .await; + match (config, storage.get_item_quorum(&key_info, &key).await) { + (Ok(Some(config)), Ok(Some(item))) => { + storage + .reconcile_ttl_item_with_config(&key_info, &item, &config) + .await + } + (Ok(_), Ok(_)) => Ok(()), + (Err(error), _) | (_, Err(error)) => Err(error), + } + } + Ok(_) | Err(StorageError::TableNotFound(_)) => Ok(()), + Err(error) => Err(error), + }; + if let Err(error) = reconcile { + tracing::warn!("TTL worker: reconcile {table_name} failed: {error}"); + continue; + } + + let delete = format!( + "DELETE FROM {keyspace}.ttl_reconcile_pending \ + WHERE worker_partition = ? AND id = ?" + ); + if let Err(error) = crate::cassandra_util::execute_quorum::( + &storage.session, + &delete, + cdrs_tokio::query_values!( + partition, + cdrs_tokio::types::value::Bytes::new(id.as_bytes().to_vec()) + ), + "delete TTL outbox row", + ) + .await + { + // The queue is already reconciled; a row that fails to + // delete is re-verified and re-deleted next cycle. + tracing::warn!("TTL worker: delete outbox row failed: {error}"); + continue; + } + processed += 1; + slot_processed += 1; + if slot_processed >= per_slot { + break 'pages; + } + } + if !page_was_full { + // Partition exhausted within the eligible range. + break 'pages; + } + } + } + } + Ok(processed) +} + +/// Pages the outbox pass may read per partition per cycle. Bounds the work a +/// backlogged or poisoned partition can consume while still guaranteeing the +/// scan advances past a failing prefix. +const OUTBOX_MAX_PAGES_PER_PARTITION: usize = 8; + +/// Reconcile every surviving pre-destroy marker. +/// +/// Active-generation rows are intentionally not removed by the worker: only +/// the destroyer can hand a definitive result to the normal outbox. Once a +/// quorum metadata read proves the table or recorded generation is retired, +/// the old destroy can no longer affect current queue state and the marker is +/// safely removed. The registry is bounded to 64 rows; an accumulation of marker rows +/// represents operational debt and is logged for visibility. +pub async fn reconcile_inflight_repairs_once( + storage: &CassandraEngine, +) -> Result { + use cdrs_tokio::types::IntoRustByName; + + let mut processed = 0usize; + for keyspace in crate::workers::list_account_keyspaces(storage).await? { + let registry = match crate::cassandra_util::query_rows_quorum( + &storage.session, + &format!("SELECT worker_partition FROM {keyspace}.ttl_repair_inflight_partitions"), + cdrs_tokio::query::QueryValues::SimpleValues(Vec::new()), + "TTL repair inflight registry", + ) + .await + { + Ok(rows) => rows, + Err(error) if crate::workers::is_table_not_found(&error) => continue, + Err(error) => { + tracing::warn!("TTL worker: inflight registry failed in {keyspace}: {error}"); + continue; + } + }; + + for registry_row in registry { + let partition: i32 = match crate::cassandra_util::get_column::( + ®istry_row, + "worker_partition", + "TTL repair inflight registry", + ) { + Ok(partition) => partition, + Err(error) => { + tracing::warn!("TTL worker: unreadable inflight registry row: {error}"); + continue; + } + }; + // Bounded AND resumable: markers accumulate one per ambiguous + // incident, so a large partition is itself an anomaly — but active- + // generation markers persist by design, so a fixed first page would + // starve everything behind it forever. Resume each cycle where the + // previous page ended and wrap at the partition's end, so a + // partition of N markers is fully traversed in ceil(N/256) + // consecutive cycles. The cursor is in-process; a restart resumes + // from the ID order's start, which only repeats work. + let cursor_key = (keyspace.clone(), partition); + let cursor = storage + .ttl_repair_scan_cursors + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .get(&cursor_key) + .copied() + .unwrap_or(uuid::Uuid::nil()); + let cursor_bytes = cdrs_tokio::types::value::Bytes::new(cursor.as_bytes().to_vec()); + let page_query = format!( + "SELECT repair_id, table_id, account_id, table_name, generation, key_data \ + FROM {keyspace}.ttl_repair_inflight \ + WHERE worker_partition = ? AND repair_id > ? LIMIT 256" + ); + let wrap_query = format!( + "SELECT repair_id, table_id, account_id, table_name, generation, key_data \ + FROM {keyspace}.ttl_repair_inflight \ + WHERE worker_partition = ? AND repair_id <= ? LIMIT 256" + ); + let mut rows = match crate::cassandra_util::query_rows_quorum::( + &storage.session, + &page_query, + cdrs_tokio::query_values!(partition, cursor_bytes.clone()), + "TTL repair inflight", + ) + .await + { + Ok(rows) => rows, + Err(error) => { + tracing::warn!( + "TTL worker: inflight partition {partition} failed in {keyspace}: {error}" + ); + continue; + } + }; + let exhausted_forward = rows.len() < 256; + if exhausted_forward { + let remaining = 256 - rows.len(); + match crate::cassandra_util::query_rows_quorum::( + &storage.session, + &wrap_query, + cdrs_tokio::query_values!(partition, cursor_bytes), + "TTL repair inflight wrap", + ) + .await + { + Ok(wrapped) => rows.extend(wrapped.into_iter().take(remaining)), + Err(error) => { + tracing::warn!( + "TTL worker: inflight wrap {partition} failed in {keyspace}: {error}" + ); + } + } + } + { + use cdrs_tokio::types::IntoRustByName; + // Advance to just past the highest forward-scanned id; if the + // forward scan was exhausted (we wrapped), restart from nil so + // the next cycle re-covers the front. + let next_cursor = if exhausted_forward { + uuid::Uuid::nil() + } else { + rows.iter() + .filter_map(|row| { + let id: Option = + row.get_by_name("repair_id").ok().flatten(); + id + }) + .max() + .unwrap_or(uuid::Uuid::nil()) + }; + storage + .ttl_repair_scan_cursors + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert(cursor_key, next_cursor); + } + if !rows.is_empty() { + tracing::warn!( + keyspace, + partition, + count = rows.len(), + "TTL repair has unresolved destroy markers" + ); + } + for row in rows { + let parsed = (|| -> Result<_, StorageError> { + let repair_id: uuid::Uuid = + row.get_r_by_name("repair_id").map_err(|error| { + StorageError::Internal(format!("Parse TTL repair id: {error}")) + })?; + let table_id: String = + crate::cassandra_util::get_column(&row, "table_id", "TTL repair inflight")?; + let account_id: String = crate::cassandra_util::get_column( + &row, + "account_id", + "TTL repair inflight", + )?; + let table_name: String = crate::cassandra_util::get_column( + &row, + "table_name", + "TTL repair inflight", + )?; + let generation: uuid::Uuid = crate::cassandra_util::get_column( + &row, + "generation", + "TTL repair inflight", + )?; + let key_data: String = + crate::cassandra_util::get_column(&row, "key_data", "TTL repair inflight")?; + let key: extenddb_core::types::Item = + serde_json::from_str(&key_data).map_err(|error| { + StorageError::Internal(format!("Parse TTL repair key: {error}")) + })?; + Ok((repair_id, table_id, account_id, table_name, generation, key)) + })(); + let (repair_id, table_id, account_id, table_name, generation, key) = match parsed { + Ok(parsed) => parsed, + Err(error) => { + tracing::warn!("TTL worker: unreadable inflight repair skipped: {error}"); + continue; + } + }; + + let (reconcile, terminal) = match storage + .fetch_table_key_info_quorum(&account_id, &table_name) + .await + { + Ok(key_info) if key_info.table_id == table_id => { + let config = storage + .ttl_config_for_table_quorum(&account_id, &table_name) + .await; + let item = storage.get_item_quorum(&key_info, &key).await; + match (config, item) { + (Ok(Some(config)), Ok(Some(item))) => { + let terminal = config.generation != generation; + ( + storage + .reconcile_ttl_item_with_config(&key_info, &item, &config) + .await, + terminal, + ) + } + (Ok(Some(config)), Ok(None)) => { + (Ok(()), config.generation != generation) + } + (Ok(None), Ok(_)) => (Ok(()), true), + (Err(error), _) | (_, Err(error)) => (Err(error), false), + } + } + Ok(_) | Err(StorageError::TableNotFound(_)) => (Ok(()), true), + Err(error) => (Err(error), false), + }; + match reconcile { + Ok(()) => { + processed += 1; + if terminal { + let delete = format!( + "DELETE FROM {keyspace}.ttl_repair_inflight \ + WHERE worker_partition = ? AND repair_id = ?" + ); + if let Err(error) = + crate::cassandra_util::execute_quorum::( + &storage.session, + &delete, + cdrs_tokio::query_values!( + partition, + cdrs_tokio::types::value::Bytes::new( + repair_id.as_bytes().to_vec() + ) + ), + "retire obsolete TTL repair inflight marker", + ) + .await + { + tracing::warn!( + "TTL worker: obsolete inflight marker cleanup failed: {error}" + ); + } + } + } + Err(error) => { + tracing::warn!("TTL worker: inflight repair {table_name} failed: {error}"); + } + } + } + } + } + Ok(processed) +} + +/// Finish or abort work that was already claimed when a TTL generation was +/// retired. +/// +/// Disabling TTL, or re-enabling it under a new generation, must not simply +/// delete the old generation's queue rows: a claimed row owns a base-row claim, +/// and an `EFFECTS_APPLYING` or `EFFECTS_APPLIED` row additionally owns a +/// must-complete effects operation. The rule is decided by how much is already +/// durable: +/// +/// * `CLAIMED` — cleanup first wins a `CLAIM_ABORTING` LWT against any +/// concurrent effects transition. +/// * `CLAIM_ABORTING` — nothing externally visible has happened; release the +/// exact owner and retire the work. +/// * `EFFECTS_APPLYING` — the exact base owner is sealed and effects may be +/// partially or fully visible, so reapply them idempotently and advance. +/// * `EFFECTS_APPLIED` — index and stream effects are already durable, so the +/// base delete must still be completed, otherwise a live item is left with +/// its index rows removed. If the image has since changed, the writer that +/// changed it rewrote its own index rows, so the work is simply completed. +/// +/// `PENDING` rows are left to `clear_ttl_generation`. +pub(crate) async fn drain_retired_generation( + storage: &CassandraEngine, + account_id: &str, + table_name: &str, + generation: uuid::Uuid, +) -> Result<(), StorageError> { + use crate::data::ttl::TtlWorkState; + + let key_info = match storage.fetch_table_key_info(account_id, table_name).await { + Ok(key_info) => key_info, + // The table is gone; its whole keyspace-level queue is removed by the + // table-deletion path instead. + Err(StorageError::TableNotFound(_)) => return Ok(()), + Err(error) => return Err(error), + }; + let account_keyspace = storage.account_keyspace(account_id); + let work = crate::data::ttl::load_generation_work( + storage, + &account_keyspace, + &key_info.table_id, + generation, + DRAIN_BATCH_SIZE, + ) + .await?; + + for mut row in work { + if row.state == TtlWorkState::Pending { + continue; + } + let Some(work_id) = row.work_id else { + continue; + }; + let Some(work_data) = row.work_data.clone() else { + continue; + }; + let key: extenddb_core::types::Item = serde_json::from_str(&row.entry.key_data) + .map_err(|error| StorageError::Internal(format!("Parse TTL work key: {error}")))?; + + if row.state == TtlWorkState::Claimed { + if !crate::data::ttl::mark_ttl_claim_aborting( + storage, + &account_keyspace, + &key_info.table_id, + generation, + &row, + ) + .await? + { + // A stale cleanup attempt must not release an owner after a + // concurrent sweep has crossed the must-complete boundary. + continue; + } + row.state = TtlWorkState::ClaimAborting; + } + + if row.state == TtlWorkState::ClaimAborting { + storage.release_ttl_claim(&key_info, &key, work_id).await?; + let _ = crate::data::ttl::abort_claimed_ttl_work( + storage, + &account_keyspace, + &key_info, + generation, + &row, + ) + .await?; + continue; + } + + if row.state == TtlWorkState::EffectsApplying { + // Owner-only, for the same reason as the active branch: a stale + // writer's batch can change the image under the sealed owner, and + // must-complete work has to go forward from that state, not error. + if !storage.base_row_owned_by(&key_info, &key, work_id).await? { + return Err(StorageError::Internal( + "retired TTL EFFECTS_APPLYING work lost its sealed owner".to_owned(), + )); + } + storage + .apply_ttl_delete_effects( + &key_info, + &work_data.old_item, + work_id, + work_data.delete_timestamp_ms, + work_data.stream.as_ref(), + ) + .await?; + if !crate::data::ttl::mark_ttl_effects_applied( + storage, + &account_keyspace, + &key_info.table_id, + generation, + &row, + ) + .await? + { + return Err(StorageError::Transient( + "retired TTL effects state changed during recovery".to_owned(), + )); + } + row.state = TtlWorkState::EffectsApplied; + } + + // EFFECTS_APPLIED. + let current = storage.get_item_quorum(&key_info, &key).await?; + let exact_deleted = if current.as_ref() == Some(&work_data.old_item) + && storage + .ensure_ttl_work_claim(&key_info, &key, &work_data.old_item, work_id) + .await? + { + storage + .delete_ttl_base_exact(&key_info, &key, &work_data.old_item, work_id) + .await? + } else { + false + }; + if !exact_deleted { + // Either the image was already changed, or the exact delete lost a + // race to a stale writer landing between the read and the Paxos + // delete. Both are the same survivor case: rebuild any index rows + // the replayed old-image tombstones erased, then release the + // sealed owner. Completing without this would wedge the key. + if let Some(item) = storage.get_item_quorum(&key_info, &key).await?.as_ref() { + storage + .restore_sync_indexes_for_item(&key_info, item, work_data.delete_timestamp_ms) + .await?; + } + storage.release_ttl_claim(&key_info, &key, work_id).await?; + } + let _ = crate::data::ttl::complete_ttl_work( + storage, + &account_keyspace, + &key_info.table_id, + generation, + &row, + ) + .await?; + } + Ok(()) +} + +async fn retry_pending_cleanup(storage: &CassandraEngine) { + let pending = match storage.pending_ttl_cleanups().await { + Ok(pending) => pending, + Err(error) => { + tracing::warn!("TTL worker: list pending cleanup failed: {error}"); + return; + } + }; + for (account_id, table_name, table_id, generation) in pending { + if let Err(error) = storage + .complete_ttl_cleanup(&account_id, &table_name, &table_id, generation) + .await + { + tracing::warn!("TTL worker: cleanup retry failed for {table_name}: {error}"); + } + } +} + +/// Retry the queue backfill for any TTL-enabled table that is not yet ready. +/// +/// `create_ttl_index` takes the table's control lease internally, so a table is +/// scanned by one host at a time even though every host runs this pass. +async fn retry_pending_indexes(storage: &CassandraEngine) { + let Ok(enabled) = MetadataEngine::all_tables_with_ttl(storage).await else { + return; + }; + let Ok(ready) = MetadataEngine::all_tables_with_ttl_index_ready(storage).await else { + return; + }; + let ready_set: std::collections::HashSet<(&str, &str)> = ready + .iter() + .map(|(account, table, _)| (account.as_str(), table.as_str())) + .collect(); + + for (account_id, table_name, attribute) in &enabled { + if !ready_set.contains(&(account_id.as_str(), table_name.as_str())) + && let Err(error) = + MetadataEngine::create_ttl_index(storage, account_id, table_name, attribute).await + { + tracing::debug!("TTL worker: queue backfill retry failed for {table_name}: {error}"); + } + } +} + +async fn process_ttl_work_row( + storage: &CassandraEngine, + key_info: &extenddb_core::types::TableKeyInfo, + config: &crate::data::ttl::TtlConfig, + sweep_owner: uuid::Uuid, + mut work: crate::data::ttl::TtlWorkRow, +) -> Result { + use crate::data::ttl::{TtlStreamPlan, TtlWorkData, TtlWorkState}; + + let account_keyspace = storage.account_keyspace(&key_info.account_id); + let key: extenddb_core::types::Item = serde_json::from_str(&work.entry.key_data) + .map_err(|error| StorageError::Internal(format!("Parse TTL work key: {error}")))?; + + if work.state == TtlWorkState::Pending { + let Some(current) = storage.get_item_quorum(key_info, &key).await? else { + let _ = crate::data::ttl::retire_pending_ttl_work( + storage, + &account_keyspace, + key_info, + config.generation, + &work.entry, + ) + .await?; + return Ok(false); + }; + if crate::data::ttl::ttl_epoch_seconds(¤t, &config.attribute) + != Some(work.entry.expires_at) + { + if crate::data::ttl::retire_pending_ttl_work( + storage, + &account_keyspace, + key_info, + config.generation, + &work.entry, + ) + .await? + { + // Re-read after the destroy: the retire's tombstone may have + // erased an insert committed by a writer between our read and + // the retire, so reconciling the image read earlier would + // re-register stale state and drop the writer's. + if let Some(current) = storage.get_item_quorum(key_info, &key).await? { + storage + .reconcile_ttl_item_with_config(key_info, ¤t, config) + .await?; + } + } + return Ok(false); + } + + let stream = key_info + .stream_specification + .as_ref() + .and_then(|specification| { + if specification.stream_enabled { + specification + .stream_view_type + .map(|view_type| TtlStreamPlan { + event_id: uuid::Uuid::new_v4().to_string(), + sequence_number: storage + .hlc + .lock() + .unwrap_or_else(|error| error.into_inner()) + .generate(), + created_at_ms: chrono::Utc::now().timestamp_millis(), + region: storage.region.clone(), + view_type, + }) + } else { + None + } + }); + let work_id = uuid::Uuid::new_v4(); + let work_data = TtlWorkData { + old_item: current, + delete_timestamp_ms: chrono::Utc::now().timestamp_millis(), + stream, + }; + if !crate::data::ttl::claim_ttl_work( + storage, + &account_keyspace, + &key_info.table_id, + config.generation, + &work.entry, + work_id, + &work_data, + ) + .await? + { + return Ok(false); + } + work.state = TtlWorkState::Claimed; + work.work_id = Some(work_id); + work.work_data = Some(work_data); + } + + let Some(work_id) = work.work_id else { + return Err(StorageError::Internal( + "TTL work missing work_id".to_owned(), + )); + }; + let Some(work_data) = work.work_data.as_ref() else { + return Err(StorageError::Internal( + "TTL work missing work_data".to_owned(), + )); + }; + + if work.state == TtlWorkState::ClaimAborting { + storage.release_ttl_claim(key_info, &key, work_id).await?; + let _ = crate::data::ttl::abort_claimed_ttl_work( + storage, + &account_keyspace, + key_info, + config.generation, + &work, + ) + .await?; + return Ok(false); + } + + if work.state == TtlWorkState::Claimed { + let current = storage.get_item_quorum(key_info, &key).await?; + match current { + Some(ref item) if item == &work_data.old_item => { + if !storage + .ensure_ttl_work_claim(key_info, &key, &work_data.old_item, work_id) + .await? + { + return Ok(false); + } + } + Some(item) => { + if !crate::data::ttl::mark_ttl_claim_aborting( + storage, + &account_keyspace, + &key_info.table_id, + config.generation, + &work, + ) + .await? + { + return Ok(false); + } + work.state = TtlWorkState::ClaimAborting; + storage.release_ttl_claim(key_info, &key, work_id).await?; + if crate::data::ttl::abort_claimed_ttl_work( + storage, + &account_keyspace, + key_info, + config.generation, + &work, + ) + .await? + { + storage + .reconcile_ttl_item_with_config(key_info, &item, config) + .await?; + } + return Ok(false); + } + None => { + if !crate::data::ttl::mark_ttl_claim_aborting( + storage, + &account_keyspace, + &key_info.table_id, + config.generation, + &work, + ) + .await? + { + return Ok(false); + } + work.state = TtlWorkState::ClaimAborting; + storage.release_ttl_claim(key_info, &key, work_id).await?; + let _ = crate::data::ttl::abort_claimed_ttl_work( + storage, + &account_keyspace, + key_info, + config.generation, + &work, + ) + .await?; + return Ok(false); + } + } + + // Last gates before irreversible effects. First renew the exact + // generation-bound sweep lease, then refresh the exact base-row owner + // and image. A stale replica cannot satisfy either LWT. If this task + // was suspended past either lease, it walks away before index or stream + // mutations become visible. + let lease_current = match storage + .renew_ttl_sweep_lease( + &key_info.account_id, + &key_info.table_name, + config, + sweep_owner, + ) + .await + { + Ok(current) => current, + Err(error) => return Err(error), + }; + if !lease_current { + return Ok(false); + } + let owner_current = match storage + .seal_ttl_work_claim(key_info, &key, &work_data.old_item, work_id) + .await + { + Ok(current) => current, + Err(error) => return Err(error), + }; + if !owner_current { + return Ok(false); + } + if !crate::data::ttl::mark_ttl_effects_applying( + storage, + &account_keyspace, + &key_info.table_id, + config.generation, + &work, + ) + .await? + { + // A concurrent/recovered transition may already have advanced this + // exact work to EFFECTS_APPLYING. Never release the now-sealed base + // owner on an ambiguous false result; the next quorum scan will + // observe the authoritative state and complete it. + return Ok(false); + } + work.state = TtlWorkState::EffectsApplying; + } + + if work.state == TtlWorkState::EffectsApplying { + // EFFECTS_APPLYING is a durable must-complete state. Its base owner was + // sealed without TTL before the transition, so lifecycle cleanup cannot + // abort it and a suspended task cannot lose the fence to a writer. + // + // Recovery is fenced on the OWNER only, never on the image. A writer + // that pinned its batch timestamp before this work sealed the owner can + // land its unconditional batch afterwards: its owner-null cells lose to + // the newer seal, but its item cells beat the older image — leaving + // (current image, sealed owner). That state is valid and must-complete; + // requiring the image to match would wedge it forever behind a + // non-expiring owner. Effects replay from the recorded OLD image (the + // stream identity is persisted, so no second REMOVE), and the + // post-effects logic below already handles a changed image by releasing + // the owner and repairing what the replayed tombstones took (see + // restore_sync_indexes_for_item). + if !storage.base_row_owned_by(key_info, &key, work_id).await? { + return Err(StorageError::Internal( + "TTL EFFECTS_APPLYING work lost its exact sealed base owner".to_owned(), + )); + } + storage + .apply_ttl_delete_effects( + key_info, + &work_data.old_item, + work_id, + work_data.delete_timestamp_ms, + work_data.stream.as_ref(), + ) + .await?; + if !crate::data::ttl::mark_ttl_effects_applied( + storage, + &account_keyspace, + &key_info.table_id, + config.generation, + &work, + ) + .await? + { + return Ok(false); + } + work.state = TtlWorkState::EffectsApplied; + } + + let current = storage.get_item_quorum(key_info, &key).await?; + let deleted = match current { + Some(ref item) if item == &work_data.old_item => { + if !storage + .ensure_ttl_work_claim(key_info, &key, &work_data.old_item, work_id) + .await? + { + return Ok(false); + } + let deleted = storage + .delete_ttl_base_exact(key_info, &key, &work_data.old_item, work_id) + .await?; + if !deleted { + // The Paxos delete read a different image than the quorum read + // moments ago: a stale writer's batch landed in between, + // changing the item under the sealed owner. Completing now + // would destroy the queue row while the owner stays sealed and + // the survivor's index rows stay tombstoned — a permanent + // wedge. Re-read and treat it exactly like the changed-image + // arm below; if the image reads as unchanged again, leave the + // row EFFECTS_APPLIED for the next pass rather than guessing. + let Some(survivor) = storage.get_item_quorum(key_info, &key).await? else { + // Gone: a late row tombstone erased the item but not the + // newer sealed owner cells. Release and complete below. + storage.release_ttl_claim(key_info, &key, work_id).await?; + let _ = crate::data::ttl::complete_live_ttl_work( + storage, + &account_keyspace, + key_info, + config.generation, + &work, + ) + .await?; + return Ok(false); + }; + if survivor == work_data.old_item { + return Ok(false); + } + storage + .restore_sync_indexes_for_item( + key_info, + &survivor, + work_data.delete_timestamp_ms, + ) + .await?; + storage.release_ttl_claim(key_info, &key, work_id).await?; + if crate::data::ttl::complete_live_ttl_work( + storage, + &account_keyspace, + key_info, + config.generation, + &work, + ) + .await? + { + storage + .reconcile_ttl_item_with_config(key_info, &survivor, config) + .await?; + } + return Ok(false); + } + deleted + } + Some(item) => { + // The replayed effects deleted the OLD image's index rows, and any + // of the current item's index rows sharing those keys lost to the + // replay's newer tombstones. Rebuild them from the current image + // while the sealed owner still fences writers out, then release. + storage + .restore_sync_indexes_for_item(key_info, &item, work_data.delete_timestamp_ms) + .await?; + storage.release_ttl_claim(key_info, &key, work_id).await?; + if crate::data::ttl::complete_live_ttl_work( + storage, + &account_keyspace, + key_info, + config.generation, + &work, + ) + .await? + { + storage + .reconcile_ttl_item_with_config(key_info, &item, config) + .await?; + } + return Ok(false); + } + None => { + storage.release_ttl_claim(key_info, &key, work_id).await?; + false + } + }; + + let _ = crate::data::ttl::complete_live_ttl_work( + storage, + &account_keyspace, + key_info, + config.generation, + &work, + ) + .await?; + Ok(deleted) +} + +/// Run one TTL sweep. Public for direct backend integration tests and manual +/// operational triggering; normal servers call it through `ttl_cleanup_worker`. +pub async fn sweep_once(storage: &CassandraEngine, metrics: &MetricsCollector) { + let tables = match MetadataEngine::all_tables_with_ttl_index_ready(storage).await { + Ok(tables) => tables, + Err(error) => { + tracing::warn!("TTL worker: failed to list tables: {error}"); + return; + } + }; + let now_epoch = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + for (account_id, table_name, ttl_attribute) in &tables { + let config = match storage.ttl_config_for_table(account_id, table_name).await { + Ok(Some(config)) if config.attribute == *ttl_attribute => config, + Ok(_) => continue, + Err(error) => { + tracing::warn!("TTL worker: config lookup failed for {table_name}: {error}"); + continue; + } + }; + let owner = match storage + .acquire_ttl_sweep_lease(account_id, table_name, &config) + .await + { + Ok(Some(owner)) => owner, + Ok(None) => continue, + Err(error) => { + tracing::warn!("TTL worker: lease acquisition failed for {table_name}: {error}"); + continue; + } + }; + + let result: Result = async { + let key_info = TableEngine::table_key_info(storage, account_id, table_name).await?; + let account_keyspace = storage.account_keyspace(account_id); + let work = crate::data::ttl::load_due_ttl_work( + storage, + &account_keyspace, + &key_info.table_id, + config.generation, + now_epoch, + BATCH_SIZE, + ) + .await?; + let mut deleted = 0usize; + for row in work { + if storage.ttl_config_for_table(account_id, table_name).await? + != Some(config.clone()) + || !storage + .renew_ttl_sweep_lease(account_id, table_name, &config, owner) + .await? + { + break; + } + let expires_at = row.entry.expires_at; + if process_ttl_work_row(storage, &key_info, &config, owner, row).await? { + deleted += 1; + metrics.record_ttl_deletion(table_name); + metrics.record_ttl_staleness( + table_name, + now_epoch.saturating_sub(expires_at) as f64, + ); + } + } + Ok(deleted) + } + .await; + + let _ = storage + .release_ttl_sweep_lease(account_id, table_name, owner) + .await; + match result { + Ok(deleted) if deleted > 0 => { + tracing::info!("TTL worker: deleted {deleted} expired items from {table_name}"); + } + Ok(_) => {} + Err(error) => tracing::warn!("TTL worker: sweep failed for {table_name}: {error}"), + } + } +} diff --git a/crates/storage-cassandra/src/update_table.rs b/crates/storage-cassandra/src/update_table.rs index 8551e3be..322c2afe 100644 --- a/crates/storage-cassandra/src/update_table.rs +++ b/crates/storage-cassandra/src/update_table.rs @@ -8,7 +8,9 @@ use cdrs_tokio::query::BatchQueryBuilder; use cdrs_tokio::query::QueryValues; use cdrs_tokio::types::IntoRustByName; use cdrs_tokio::types::value::Value; -use extenddb_core::types::{BillingMode, TableDescription, UpdateTableInput}; +use extenddb_core::types::{ + AttributeDefinition, BillingMode, KeySchemaElement, TableDescription, UpdateTableInput, +}; use extenddb_storage::error::StorageError; use extenddb_storage::util::effective_attribute_definitions; @@ -366,6 +368,100 @@ impl CassandraEngine { base_attr_defs = Vec::new(); } + // The table's TTL control lease fences index creation against the TTL + // lifecycle. It is needed in two cases: + // + // * an asynchronously propagated GSI is being created — holding the + // lease stops TTL being enabled underneath it, which the TTL design + // does not admit; and + // * TTL is already enabled — holding the lease keeps the expiration + // sweep out of the window between the catalog publishing the new + // index as ACTIVE and its data table actually existing. Without it a + // sweep can take a base-row claim and then fail applying index + // effects against a table that is not there yet. + let creating_async_gsi = !gsi_creates.is_empty() + && self + .gsi_default_delay_ms + .load(std::sync::atomic::Ordering::Relaxed) + != 0; + let ttl_enabled = self + .ttl_config_for_table(account_id, &input.table_name) + .await? + .is_some(); + let ttl_control_owner = if !gsi_creates.is_empty() && (creating_async_gsi || ttl_enabled) { + let owner = self + .acquire_ttl_control_lease(account_id, &input.table_name) + .await? + .ok_or_else(|| { + StorageError::IndexesInUse(format!( + "Index changes for table {} cannot be applied while an expiration \ + sweep is in progress. Retry the request.", + input.table_name + )) + })?; + if creating_async_gsi && ttl_enabled { + self.release_ttl_sweep_lease(account_id, &input.table_name, owner) + .await?; + return Err(StorageError::Validation( + "Cannot create an asynchronously propagated GSI while TTL is enabled" + .to_owned(), + )); + } + Some(owner) + } else { + None + }; + + // Everything below runs under that lease, so it is released on every + // path rather than only on success. + let outcome = self + .apply_table_update( + account_id, + &input, + batch, + batch_has_statements, + needs_shard_init, + needs_label_restore, + &table_id, + &gsi_creates, + &gsi_deletes, + &base_key_schema, + &base_attr_defs, + ) + .await; + if let Some(owner) = ttl_control_owner + && let Err(error) = self + .release_ttl_sweep_lease(account_id, &input.table_name, owner) + .await + { + tracing::warn!( + table = %input.table_name, + "TTL control lease release failed; TTL lifecycle changes are blocked \ + until it expires: {error}" + ); + } + outcome + } + + /// Apply the catalog batch and the post-batch DDL for `update_table`. + /// + /// Split out so the caller can hold the TTL control lease across it and + /// release it on every path. + #[allow(clippy::too_many_arguments)] + async fn apply_table_update( + &self, + account_id: &str, + input: &UpdateTableInput, + batch: BatchQueryBuilder, + batch_has_statements: bool, + needs_shard_init: bool, + needs_label_restore: bool, + table_id: &str, + gsi_creates: &[(String, String)], + gsi_deletes: &[String], + base_key_schema: &[KeySchemaElement], + base_attr_defs: &[AttributeDefinition], + ) -> Result { // Execute the catalog batch atomically. if batch_has_statements { self.session @@ -384,7 +480,7 @@ impl CassandraEngine { // Post-batch: shard init (has its own internal batch across two keyspaces). if needs_shard_init { let account_ks = self.account_keyspace(account_id); - self.init_stream_shards(account_id, &input.table_name, &account_ks, &table_id) + self.init_stream_shards(account_id, &input.table_name, &account_ks, table_id) .await?; } let _ = needs_label_restore; // handled inside the batch above @@ -394,7 +490,7 @@ impl CassandraEngine { let effective_attr_defs = input .attribute_definitions .as_deref() - .unwrap_or(&base_attr_defs); + .unwrap_or(base_attr_defs); let account_ks = self.account_keyspace(account_id); let mut create_idx = 0usize; @@ -409,8 +505,8 @@ impl CassandraEngine { index_id, &create.key_schema, effective_attr_defs, - &base_key_schema, - &base_attr_defs, + base_key_schema, + base_attr_defs, ) .await?; } diff --git a/crates/storage-cassandra/src/worker_store.rs b/crates/storage-cassandra/src/worker_store.rs index 210ed8d8..1385b1c5 100755 --- a/crates/storage-cassandra/src/worker_store.rs +++ b/crates/storage-cassandra/src/worker_store.rs @@ -128,6 +128,8 @@ impl CassandraEngine { // Delete indexes (catalog + data tables) let account_keyspace = self.account_keyspace(&account_id); + self.clear_ttl_entries_for_table_id(&account_id, &table_id) + .await?; crate::data::index::delete_indexes_for_table( &self.session_arc(), &catalog_keyspace, diff --git a/crates/storage-cassandra/src/workers.rs b/crates/storage-cassandra/src/workers.rs index 09a96c41..3561bd85 100644 --- a/crates/storage-cassandra/src/workers.rs +++ b/crates/storage-cassandra/src/workers.rs @@ -142,7 +142,7 @@ pub(crate) async fn poll_transaction_recovery( } /// List all account keyspaces for this engine (keyspaces matching `{prefix}_account_*`). -async fn list_account_keyspaces( +pub(crate) async fn list_account_keyspaces( engine: &CassandraEngine, ) -> Result, extenddb_storage::error::StorageError> { let prefix = format!("{}_account_", engine.keyspace_prefix); @@ -439,7 +439,7 @@ async fn gsi_apply_index( } /// Returns true if the error indicates the index table no longer exists. -fn is_table_not_found(err: &extenddb_storage::error::StorageError) -> bool { +pub(crate) fn is_table_not_found(err: &extenddb_storage::error::StorageError) -> bool { match err { extenddb_storage::error::StorageError::Internal(msg) => { msg.contains("unconfigured table") || msg.contains("does not exist") diff --git a/crates/storage-cassandra/tests/common/mod.rs b/crates/storage-cassandra/tests/common/mod.rs new file mode 100644 index 00000000..a116c828 --- /dev/null +++ b/crates/storage-cassandra/tests/common/mod.rs @@ -0,0 +1,778 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +#![allow(dead_code)] + +//! Shared test helpers. + +use extenddb_core::types::{ + AttributeDefinition, KeySchemaElement, KeyType, ScalarAttributeType, TableKeyInfo, +}; +use extenddb_storage::TableEngine; +use extenddb_storage_cassandra::{ + CassandraCatalogStore, CassandraEngine, CassandraSession, CassandraStorageConfig, +}; +use std::sync::Arc; + +/// Returns a standard test configuration for Cassandra. +pub fn test_config() -> CassandraStorageConfig { + let mut config = CassandraStorageConfig { + contact_points: vec!["127.0.0.1:9042".to_string()], + username: Some("cassandra".to_string()), + password: Some("cassandra".to_string()), + keyspace_prefix: "extenddb_ttl_test".to_string(), + datacenter: "datacenter1".to_string(), + replication_factor: 1, + max_connections: 5, + cached_connection_string: None, + instance_id: None, + }; + config.ensure_cached_connection_string(); + config +} + +/// Generates a unique test identifier. +pub fn unique_test_id() -> String { + uuid::Uuid::new_v4().simple().to_string() +} + +/// Generates a unique test account ID (12-digit format). +pub fn unique_test_account() -> String { + format!("{:012}", rand::random::() % 1_000_000_000_000) +} + +/// Ensures account exists in catalog and creates keyspace. +pub async fn ensure_test_account( + engine: &CassandraEngine, + account_id: &str, +) -> Result<(), Box> { + let account_keyspace = format!("extenddb_ttl_test_account_{}", account_id); + let catalog_keyspace = "extenddb_ttl_test_catalog"; + + // Create and migrate the catalog keyspace if it doesn't exist yet. + if !engine.keyspace_exists(catalog_keyspace).await? { + engine.create_keyspace(catalog_keyspace).await?; + } + ensure_keyspace_rf(engine, catalog_keyspace).await?; + // Always run migrations — idempotent, guards against concurrent creation races. + extenddb_storage_cassandra::migrations::run_catalog_migrations( + &engine.session_arc(), + catalog_keyspace, + ) + .await + .map_err(|e| format!("Failed to run catalog migrations: {:?}", e))?; + + if !engine.keyspace_exists(&account_keyspace).await? { + engine.create_keyspace(&account_keyspace).await?; + ensure_keyspace_rf(engine, &account_keyspace).await?; + } else { + ensure_keyspace_rf(engine, &account_keyspace).await?; + } + // Always run migrations — idempotent, guards against concurrent creation races. + extenddb_storage_cassandra::migrations::run_data_migrations( + &engine.session_arc(), + &account_keyspace, + ) + .await + .map_err(|e| format!("Failed to run migrations: {:?}", e))?; + + let query = format!( + "INSERT INTO {}.accounts (account_id, account_name, created_at) VALUES (?, ?, toTimestamp(now())) IF NOT EXISTS", + catalog_keyspace + ); + let _ = engine + .session_arc() + .query_with_values( + &query, + cdrs_tokio::query_values!(account_id, "Test Account"), + ) + .await; + + Ok(()) +} + +/// RAII wrapper for test accounts - drops keyspace on cleanup. +pub struct TestAccount { + session: Arc, + keyspace_prefix: String, + pub account_id: String, +} + +impl TestAccount { + pub async fn new(engine: &CassandraEngine, keyspace_prefix: &str) -> Self { + let account_id = unique_test_account(); + ensure_test_account(engine, &account_id).await.unwrap(); + Self { + session: engine.session_arc(), + keyspace_prefix: keyspace_prefix.to_string(), + account_id, + } + } +} + +impl Drop for TestAccount { + fn drop(&mut self) { + let session = self.session.clone(); + let keyspace = format!("{}_account_{}", self.keyspace_prefix, self.account_id); + let query = format!("DROP KEYSPACE IF EXISTS {}", keyspace); + tokio::spawn(async move { + let _ = session.query(query).await; + }); + } +} + +/// RAII wrapper for test tables - deletes table on cleanup. +pub struct TestTable { + session: Arc, + owns_keyspace: bool, + pub key_info: TableKeyInfo, +} + +impl TestTable { + pub async fn new(engine: &CassandraEngine, table_name: &str, has_sort_key: bool) -> Self { + Self::with_account_and_schema( + engine, + &unique_test_account(), + table_name, + if has_sort_key { + Some(ScalarAttributeType::S) + } else { + None + }, + true, + ) + .await + } + + pub async fn with_sort_key_type( + engine: &CassandraEngine, + table_name: &str, + sort_key_type: ScalarAttributeType, + ) -> Self { + Self::with_account_and_schema( + engine, + &unique_test_account(), + table_name, + Some(sort_key_type), + true, + ) + .await + } + + pub async fn with_account( + engine: &CassandraEngine, + account_id: &str, + table_name: &str, + has_sort_key: bool, + ) -> Self { + let sort_key_type = if has_sort_key { + Some(ScalarAttributeType::S) + } else { + None + }; + Self::with_account_and_schema(engine, account_id, table_name, sort_key_type, false).await + } + + pub async fn with_account_and_schema( + engine: &CassandraEngine, + account_id: &str, + table_name: &str, + sort_key_type: Option, + owns_keyspace: bool, + ) -> Self { + ensure_test_account(engine, account_id).await.unwrap(); + + let (key_schema, attribute_definitions) = if let Some(sk_type) = sort_key_type { + ( + vec![ + KeySchemaElement { + attribute_name: "id".to_string(), + key_type: KeyType::Hash, + }, + KeySchemaElement { + attribute_name: "sort".to_string(), + key_type: KeyType::Range, + }, + ], + vec![ + AttributeDefinition { + attribute_name: "id".to_string(), + attribute_type: ScalarAttributeType::S, + }, + AttributeDefinition { + attribute_name: "sort".to_string(), + attribute_type: sk_type, + }, + ], + ) + } else { + ( + vec![KeySchemaElement { + attribute_name: "id".to_string(), + key_type: KeyType::Hash, + }], + vec![AttributeDefinition { + attribute_name: "id".to_string(), + attribute_type: ScalarAttributeType::S, + }], + ) + }; + + let input = extenddb_core::types::CreateTableInput { + table_name: table_name.to_string(), + key_schema: key_schema.clone(), + attribute_definitions: attribute_definitions.clone(), + local_secondary_indexes: None, + global_secondary_indexes: None, + vector_indexes: None, + billing_mode: None, + table_throughput_mode: None, + provisioned_throughput: None, + on_demand_throughput: None, + stream_specification: None, + sse_specification: None, + tags: None, + table_class: None, + deletion_protection_enabled: None, + }; + + let table_desc = engine.create_table(account_id, input).await.unwrap(); + + let key_info = TableKeyInfo { + table_name: table_name.to_string(), + account_id: account_id.to_string(), + table_id: table_desc.table_id, + key_schema: key_schema.clone(), + base_key_schema: key_schema, + attribute_definitions, + has_lsi: false, + global_secondary_indexes: Vec::new(), + local_secondary_indexes: Vec::new(), + vector_indexes: Vec::new(), + stream_specification: None, + }; + + Self { + session: engine.session_arc(), + key_info, + owns_keyspace, + } + } + + /// Create a test table with a GSI (synchronous by default with propagation_delay_ms=0). + pub async fn with_gsi( + engine: &CassandraEngine, + table_name: &str, + gsi_name: &str, + gsi_pk_attr: &str, + ) -> Self { + use extenddb_core::types::GsiInput; + + let account_id = unique_test_account(); + ensure_test_account(engine, &account_id).await.unwrap(); + + let key_schema = vec![KeySchemaElement { + attribute_name: "id".to_string(), + key_type: KeyType::Hash, + }]; + + let attribute_definitions = vec![ + AttributeDefinition { + attribute_name: "id".to_string(), + attribute_type: ScalarAttributeType::S, + }, + AttributeDefinition { + attribute_name: gsi_pk_attr.to_string(), + attribute_type: ScalarAttributeType::S, + }, + ]; + + let gsi = GsiInput { + index_name: gsi_name.to_string(), + key_schema: vec![KeySchemaElement { + attribute_name: gsi_pk_attr.to_string(), + key_type: KeyType::Hash, + }], + projection: extenddb_core::types::Projection { + projection_type: extenddb_core::types::ProjectionType::All, + non_key_attributes: None, + }, + provisioned_throughput: None, + }; + + let input = extenddb_core::types::CreateTableInput { + table_name: table_name.to_string(), + key_schema: key_schema.clone(), + attribute_definitions: attribute_definitions.clone(), + local_secondary_indexes: None, + global_secondary_indexes: Some(vec![gsi]), + vector_indexes: None, + billing_mode: None, + table_throughput_mode: None, + provisioned_throughput: None, + on_demand_throughput: None, + stream_specification: None, + sse_specification: None, + tags: None, + table_class: None, + deletion_protection_enabled: None, + }; + + let table_desc = engine.create_table(&account_id, input).await.unwrap(); + + let key_info = TableKeyInfo { + table_name: table_name.to_string(), + account_id: account_id.to_string(), + table_id: table_desc.table_id, + key_schema: key_schema.clone(), + base_key_schema: key_schema, + attribute_definitions, + has_lsi: false, + global_secondary_indexes: Vec::new(), + local_secondary_indexes: Vec::new(), + vector_indexes: Vec::new(), + stream_specification: None, + }; + + Self { + session: engine.session_arc(), + key_info, + owns_keyspace: true, + } + } + + /// Create a test table with a GSI that has both partition and sort key. + pub async fn with_gsi_with_sk( + engine: &CassandraEngine, + table_name: &str, + gsi_name: &str, + gsi_pk_attr: &str, + gsi_sk_attr: &str, + ) -> Self { + use extenddb_core::types::GsiInput; + + let account_id = unique_test_account(); + ensure_test_account(engine, &account_id).await.unwrap(); + + let key_schema = vec![KeySchemaElement { + attribute_name: "id".to_string(), + key_type: KeyType::Hash, + }]; + + let attribute_definitions = vec![ + AttributeDefinition { + attribute_name: "id".to_string(), + attribute_type: ScalarAttributeType::S, + }, + AttributeDefinition { + attribute_name: gsi_pk_attr.to_string(), + attribute_type: ScalarAttributeType::S, + }, + AttributeDefinition { + attribute_name: gsi_sk_attr.to_string(), + attribute_type: ScalarAttributeType::N, + }, + ]; + + let gsi = GsiInput { + index_name: gsi_name.to_string(), + key_schema: vec![ + KeySchemaElement { + attribute_name: gsi_pk_attr.to_string(), + key_type: KeyType::Hash, + }, + KeySchemaElement { + attribute_name: gsi_sk_attr.to_string(), + key_type: KeyType::Range, + }, + ], + projection: extenddb_core::types::Projection { + projection_type: extenddb_core::types::ProjectionType::All, + non_key_attributes: None, + }, + provisioned_throughput: None, + }; + + let input = extenddb_core::types::CreateTableInput { + table_name: table_name.to_string(), + key_schema: key_schema.clone(), + attribute_definitions: attribute_definitions.clone(), + local_secondary_indexes: None, + global_secondary_indexes: Some(vec![gsi]), + vector_indexes: None, + billing_mode: None, + table_throughput_mode: None, + provisioned_throughput: None, + on_demand_throughput: None, + stream_specification: None, + sse_specification: None, + tags: None, + table_class: None, + deletion_protection_enabled: None, + }; + + let table_desc = engine.create_table(&account_id, input).await.unwrap(); + + let key_info = TableKeyInfo { + table_name: table_name.to_string(), + account_id: account_id.to_string(), + table_id: table_desc.table_id, + key_schema: key_schema.clone(), + base_key_schema: key_schema, + attribute_definitions, + has_lsi: false, + global_secondary_indexes: Vec::new(), + local_secondary_indexes: Vec::new(), + vector_indexes: Vec::new(), + stream_specification: None, + }; + + Self { + session: engine.session_arc(), + key_info, + owns_keyspace: true, + } + } + + /// Create a test table with an LSI (shares PK with base table, different SK). + pub async fn with_lsi( + engine: &CassandraEngine, + table_name: &str, + lsi_name: &str, + lsi_sk_attr: &str, + ) -> Self { + use extenddb_core::types::LsiInput; + + let account_id = unique_test_account(); + ensure_test_account(engine, &account_id).await.unwrap(); + + let key_schema = vec![ + KeySchemaElement { + attribute_name: "id".to_string(), + key_type: KeyType::Hash, + }, + KeySchemaElement { + attribute_name: "sort".to_string(), + key_type: KeyType::Range, + }, + ]; + + let attribute_definitions = vec![ + AttributeDefinition { + attribute_name: "id".to_string(), + attribute_type: ScalarAttributeType::S, + }, + AttributeDefinition { + attribute_name: "sort".to_string(), + attribute_type: ScalarAttributeType::N, + }, + AttributeDefinition { + attribute_name: lsi_sk_attr.to_string(), + attribute_type: ScalarAttributeType::N, + }, + ]; + + let lsi = LsiInput { + index_name: lsi_name.to_string(), + key_schema: vec![ + KeySchemaElement { + attribute_name: "id".to_string(), + key_type: KeyType::Hash, + }, + KeySchemaElement { + attribute_name: lsi_sk_attr.to_string(), + key_type: KeyType::Range, + }, + ], + projection: extenddb_core::types::Projection { + projection_type: extenddb_core::types::ProjectionType::All, + non_key_attributes: None, + }, + }; + + let input = extenddb_core::types::CreateTableInput { + table_name: table_name.to_string(), + key_schema: key_schema.clone(), + attribute_definitions: attribute_definitions.clone(), + local_secondary_indexes: Some(vec![lsi]), + global_secondary_indexes: None, + vector_indexes: None, + billing_mode: None, + table_throughput_mode: None, + provisioned_throughput: None, + on_demand_throughput: None, + stream_specification: None, + sse_specification: None, + tags: None, + table_class: None, + deletion_protection_enabled: None, + }; + + let table_desc = engine.create_table(&account_id, input).await.unwrap(); + + let key_info = TableKeyInfo { + table_name: table_name.to_string(), + account_id: account_id.to_string(), + table_id: table_desc.table_id, + key_schema: key_schema.clone(), + base_key_schema: key_schema, + attribute_definitions, + has_lsi: true, + global_secondary_indexes: Vec::new(), + local_secondary_indexes: Vec::new(), + vector_indexes: Vec::new(), + stream_specification: None, + }; + + Self { + session: engine.session_arc(), + key_info, + owns_keyspace: true, + } + } +} + +impl Drop for TestTable { + fn drop(&mut self) { + let session = self.session.clone(); + let account_id = self.key_info.account_id.clone(); + let table_id = self.key_info.table_id.clone(); + let table_name = self.key_info.table_name.clone(); + let owns_keyspace = self.owns_keyspace; + tokio::spawn(async move { + let catalog_keyspace = "extenddb_ttl_test_catalog"; + let account_keyspace = format!("extenddb_ttl_test_account_{}", account_id); + + if owns_keyspace { + // Drop the entire account keyspace. + let _ = session + .query(format!("DROP KEYSPACE IF EXISTS {account_keyspace}")) + .await; + + // Clean up all catalog entries for this account. + // First collect table_ids so we can delete their index rows. + let select_tables = + format!("SELECT table_id FROM {catalog_keyspace}.tables WHERE account_id = ?"); + let table_ids: Vec = session + .query_with_values( + &select_tables, + cdrs_tokio::query_values!(account_id.as_str()), + ) + .await + .ok() + .and_then(|f| f.response_body().ok()) + .and_then(|b| b.into_rows()) + .unwrap_or_default() + .into_iter() + .filter_map(|row| { + use cdrs_tokio::types::IntoRustByName as _; + row.get_r_by_name("table_id").ok() + }) + .collect(); + + for tid in &table_ids { + let _ = session + .query_with_values( + &format!("DELETE FROM {catalog_keyspace}.indexes WHERE table_id = ?"), + cdrs_tokio::query_values!(tid.as_str()), + ) + .await; + } + + let _ = session + .query_with_values( + &format!("DELETE FROM {catalog_keyspace}.tables WHERE account_id = ?"), + cdrs_tokio::query_values!(account_id.as_str()), + ) + .await; + } else { + // Drop only this table's data table and its catalog entries. + let data_table = format!("ddb_{}", table_id.replace("-", "_")); + let _ = session + .query(format!( + "DROP TABLE IF EXISTS {account_keyspace}.{data_table}" + )) + .await; + + // Fetch index IDs before deleting catalog rows, then drop each index table. + let index_ids: Vec = session + .query_with_values( + &format!( + "SELECT index_id FROM {catalog_keyspace}.indexes WHERE table_id = ?" + ), + cdrs_tokio::query_values!(table_id.as_str()), + ) + .await + .ok() + .and_then(|f| f.response_body().ok()) + .and_then(|b| b.into_rows()) + .unwrap_or_default() + .into_iter() + .filter_map(|row| { + use cdrs_tokio::types::IntoRustByName as _; + row.get_r_by_name("index_id").ok() + }) + .collect(); + + for iid in &index_ids { + let idx_table = format!("index_{}", iid.replace("-", "_")); + let _ = session + .query(format!( + "DROP TABLE IF EXISTS {account_keyspace}.{idx_table}" + )) + .await; + } + + let _ = session + .query_with_values( + &format!("DELETE FROM {catalog_keyspace}.indexes WHERE table_id = ?"), + cdrs_tokio::query_values!(table_id.as_str()), + ) + .await; + + let _ = session + .query_with_values( + &format!("DELETE FROM {catalog_keyspace}.tables WHERE account_id = ? AND table_name = ?"), + cdrs_tokio::query_values!(account_id.as_str(), table_name.as_str()), + ) + .await; + } + }); + } +} + +/// Deprecated: Use TestAccount or ensure_test_account instead. +/// Returns a test account ID, ensuring the account keyspace exists. +/// Also ensures the account is registered in the catalog. +pub async fn test_account_id( + engine: &extenddb_storage_cassandra::CassandraEngine, +) -> Result> { + let account_id = "999999999999"; + ensure_test_account(engine, account_id).await?; + Ok(account_id.to_string()) +} + +/// Ensures a keyspace has RF=1 for single-node testing. +pub async fn ensure_keyspace_rf( + engine: &extenddb_storage_cassandra::CassandraEngine, + keyspace: &str, +) -> Result<(), Box> { + let cql = format!( + "ALTER KEYSPACE {} WITH replication = {{'class': 'NetworkTopologyStrategy', 'datacenter1': 1}}", + keyspace + ); + engine.session_arc().query(cql).await?; + Ok(()) +} + +/// Sets up a CassandraEngine with standard test configuration. +/// Ensures the catalog keyspace exists and is migrated. +pub async fn setup_engine() -> CassandraEngine { + let config = test_config(); + let engine = CassandraEngine::new(&config, "us-east-1").await.unwrap(); + + let catalog_keyspace = format!("{}_catalog", config.keyspace_prefix); + if !engine.keyspace_exists(&catalog_keyspace).await.unwrap() { + engine.create_keyspace(&catalog_keyspace).await.unwrap(); + } + ensure_keyspace_rf(&engine, &catalog_keyspace) + .await + .unwrap(); + // Always run migrations — idempotent, guards against concurrent creation races. + extenddb_storage_cassandra::migrations::run_catalog_migrations( + &engine.session_arc(), + &catalog_keyspace, + ) + .await + .unwrap(); + + engine +} + +/// Creates a CassandraCatalogStore from engine for tests. +pub fn create_catalog_store( + engine: &CassandraEngine, + config: &CassandraStorageConfig, +) -> CassandraCatalogStore { + CassandraCatalogStore::new( + engine.session_arc(), + config.keyspace_prefix.clone(), + config.datacenter.clone(), + config.replication_factor, + ) +} + +/// Helper: insert an item then manually set its `prepared_txn_id` via raw CQL, +/// simulating a transaction that has prepared (locked) the item. +/// +/// The item must have a string `"id"` partition key. If the table has a sort +/// key, the item must have a string `"sort"` attribute. +pub async fn put_item_then_lock( + engine: &CassandraEngine, + table: &TestTable, + item: &std::collections::BTreeMap, +) { + use extenddb_storage::DataEngine; + + engine + .put_item( + &table.key_info, + item.clone(), + false, + None, + &Default::default(), + None, + ) + .await + .expect("Initial put_item should succeed"); + + let account_keyspace = format!("extenddb_ttl_test_account_{}", table.key_info.account_id); + let data_table = format!("items_{}", table.key_info.table_id.replace("-", "_")); + let fake_txn_id = uuid::Uuid::new_v4(); + + let pk = match item.get("id").unwrap() { + extenddb_core::types::AttributeValue::S(s) => s.as_str(), + _ => panic!("Expected string partition key 'id'"), + }; + + let has_sk = table.key_info.key_schema.len() > 1; + + if has_sk { + let sk = match item.get("sort").unwrap() { + extenddb_core::types::AttributeValue::S(s) => s.clone(), + _ => panic!("Expected string sort key 'sort'"), + }; + let query = format!( + "UPDATE {}.{} SET prepared_txn_id = ? WHERE pk = ? AND sk_s = ?", + account_keyspace, data_table + ); + engine + .session_arc() + .query_with_values( + &query, + cdrs_tokio::query_values!( + cdrs_tokio::types::value::Bytes::new(fake_txn_id.as_bytes().to_vec()), + pk, + sk.as_str() + ), + ) + .await + .expect("Setting prepared_txn_id should succeed"); + } else { + let query = format!( + "UPDATE {}.{} SET prepared_txn_id = ? WHERE pk = ?", + account_keyspace, data_table + ); + engine + .session_arc() + .query_with_values( + &query, + cdrs_tokio::query_values!( + cdrs_tokio::types::value::Bytes::new(fake_txn_id.as_bytes().to_vec()), + pk + ), + ) + .await + .expect("Setting prepared_txn_id should succeed"); + } +} diff --git a/crates/storage-cassandra/tests/metadata_integration.rs b/crates/storage-cassandra/tests/metadata_integration.rs new file mode 100644 index 00000000..f847e681 --- /dev/null +++ b/crates/storage-cassandra/tests/metadata_integration.rs @@ -0,0 +1,96 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Integration tests for `MetadataEngine` operations that are not TTL-specific. +//! +//! These exercise the Cassandra adapter directly. The SDK-level suites under +//! `tests/rust` and `tests/python` cover the same semantics through the API, but +//! CI runs those against PostgreSQL, SQLite, and MongoDB only — there is no +//! Cassandra integration workflow — so this is the only coverage that reaches +//! the Cassandra implementation of these calls. + +#[path = "common/mod.rs"] +mod helpers; + +use extenddb_core::types::Tag; +use extenddb_storage::MetadataEngine; + +use crate::helpers::setup_engine; + +fn tag(key: &str, value: &str) -> Tag { + Tag { + key: key.to_owned(), + value: value.to_owned(), + } +} + +/// One phased pass over the tag lifecycle. Each phase asserts a distinct +/// behaviour — empty listing, exact multi-tag persistence and ordering, +/// same-key overwrite, and selective removal — but they share one engine +/// connection, because `setup_engine` reruns catalog migrations and dominated +/// the cost of testing these as four separate cases. +#[tokio::test] +async fn test_resource_tag_lifecycle() { + let engine = setup_engine().await; + let arn = format!( + "arn:aws:dynamodb:us-east-1:123456789012:table/test-{}", + uuid::Uuid::new_v4().simple() + ); + + // An untouched resource has no tags. + assert!( + engine + .list_tags(&arn) + .await + .expect("list_tags on an untagged resource") + .is_empty() + ); + + // Tags persist with their exact keys and values, in clustering-key order. + engine + .tag_resource(&arn, &[tag("env", "staging"), tag("owner", "alice")]) + .await + .expect("tag_resource"); + let tags = engine.list_tags(&arn).await.expect("list_tags"); + assert_eq!( + tags.iter() + .map(|t| (t.key.as_str(), t.value.as_str())) + .collect::>(), + vec![("env", "staging"), ("owner", "alice")] + ); + + // Re-tagging an existing key replaces its value and leaves the other alone. + engine + .tag_resource(&arn, &[tag("env", "prod")]) + .await + .expect("tag_resource overwrite"); + let tags = engine + .list_tags(&arn) + .await + .expect("list_tags after upsert"); + assert_eq!( + tags.iter() + .map(|t| (t.key.as_str(), t.value.as_str())) + .collect::>(), + vec![("env", "prod"), ("owner", "alice")], + "an upsert must replace only the key it names" + ); + + // Untagging removes exactly the named keys. + engine + .tag_resource(&arn, &[tag("team", "storage")]) + .await + .expect("tag_resource third key"); + engine + .untag_resource(&arn, &["env".to_owned(), "team".to_owned()]) + .await + .expect("untag_resource"); + let tags = engine.list_tags(&arn).await.expect("list_tags after untag"); + assert_eq!( + tags.iter() + .map(|t| (t.key.as_str(), t.value.as_str())) + .collect::>(), + vec![("owner", "alice")], + "untag must remove only the keys it names" + ); +} diff --git a/crates/storage-cassandra/tests/ttl_integration.rs b/crates/storage-cassandra/tests/ttl_integration.rs new file mode 100644 index 00000000..e1fe11ef --- /dev/null +++ b/crates/storage-cassandra/tests/ttl_integration.rs @@ -0,0 +1,3018 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Integration tests for Cassandra TTL against a live Cassandra. +//! +//! Requires Cassandra on 127.0.0.1:9042. There is no Cassandra CI workflow, so +//! these are run locally. + +#[path = "common/mod.rs"] +mod helpers; + +use extenddb_storage::MetadataEngine; + +use crate::helpers::setup_engine; + +async fn activate_tables(engine: &extenddb_storage_cassandra::CassandraEngine) { + tokio::time::sleep(std::time::Duration::from_millis(350)).await; + engine + .process_control_plane_transitions() + .await + .expect("process table transitions"); +} + +async fn ttl_generation( + engine: &extenddb_storage_cassandra::CassandraEngine, + account_id: &str, + table_name: &str, +) -> uuid::Uuid { + use cdrs_tokio::types::IntoRustByName; + let query = format!( + "SELECT ttl_generation FROM {}.tables WHERE account_id = ? AND table_name = ?", + engine.catalog_keyspace() + ); + engine + .session_arc() + .query_with_values(&query, cdrs_tokio::query_values!(account_id, table_name)) + .await + .unwrap() + .response_body() + .unwrap() + .into_rows() + .unwrap() + .into_iter() + .next() + .unwrap() + .get_r_by_name("ttl_generation") + .unwrap() +} + +async fn ttl_bucket_count( + engine: &extenddb_storage_cassandra::CassandraEngine, + account_id: &str, + table_id: &str, +) -> usize { + let query = format!( + "SELECT generation, bucket, shard FROM {}.ttl_expiration_buckets WHERE table_id = ?", + engine.account_keyspace(account_id) + ); + engine + .session_arc() + .query_with_values(&query, cdrs_tokio::query_values!(table_id)) + .await + .unwrap() + .response_body() + .unwrap() + .into_rows() + .unwrap_or_default() + .len() +} +async fn ttl_outbox_count( + engine: &extenddb_storage_cassandra::CassandraEngine, + account_id: &str, +) -> usize { + let keyspace = engine.account_keyspace(account_id); + let mut count = 0usize; + for partition in 0..64 { + let query = + format!("SELECT id FROM {keyspace}.ttl_reconcile_pending WHERE worker_partition = ?"); + count += engine + .session_arc() + .query_with_values(&query, cdrs_tokio::query_values!(partition)) + .await + .unwrap() + .response_body() + .unwrap() + .into_rows() + .unwrap_or_default() + .len(); + } + count +} + +async fn ttl_inflight_repair_count( + engine: &extenddb_storage_cassandra::CassandraEngine, + account_id: &str, +) -> usize { + let keyspace = engine.account_keyspace(account_id); + let registry = engine + .session_arc() + .query_with_values( + &format!("SELECT worker_partition FROM {keyspace}.ttl_repair_inflight_partitions"), + cdrs_tokio::query_values!(), + ) + .await + .unwrap() + .response_body() + .unwrap() + .into_rows() + .unwrap_or_default(); + let mut count = 0usize; + for row in registry { + use cdrs_tokio::types::IntoRustByName; + let partition: i32 = row.get_r_by_name("worker_partition").unwrap(); + count += engine + .session_arc() + .query_with_values( + &format!( + "SELECT repair_id FROM {keyspace}.ttl_repair_inflight \ + WHERE worker_partition = ?" + ), + cdrs_tokio::query_values!(partition), + ) + .await + .unwrap() + .response_body() + .unwrap() + .into_rows() + .unwrap_or_default() + .len(); + } + count +} + +#[tokio::test] +async fn test_ttl_metadata_enable_disable_and_listing() { + use extenddb_core::types::{AttributeValue, TimeToLiveStatus}; + + use extenddb_storage::DataEngine; + use extenddb_storage::error::StorageError; + + let engine = setup_engine().await; + let table = crate::helpers::TestTable::new(&engine, "TtlMetadata", false).await; + activate_tables(&engine).await; + + let disabled = engine + .describe_ttl(&table.key_info.account_id, &table.key_info.table_name) + .await + .expect("describe disabled TTL"); + assert_eq!(disabled.time_to_live_status, TimeToLiveStatus::Disabled); + assert!(disabled.attribute_name.is_none()); + + engine + .update_ttl( + &table.key_info.account_id, + &table.key_info.table_name, + "expires_at", + true, + ) + .await + .expect("enable TTL metadata"); + engine + .create_ttl_index( + &table.key_info.account_id, + &table.key_info.table_name, + "expires_at", + ) + .await + .expect("backfill TTL queue"); + let first_generation = ttl_generation( + &engine, + &table.key_info.account_id, + &table.key_info.table_name, + ) + .await; + let mut queued_item = std::collections::BTreeMap::new(); + queued_item.insert("id".to_owned(), AttributeValue::S("queued".to_owned())); + queued_item.insert( + "expires_at".to_owned(), + AttributeValue::N( + (std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + + 3_600) + .to_string(), + ), + ); + engine + .put_item( + &table.key_info, + queued_item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("populate TTL queue"); + assert!( + ttl_bucket_count( + &engine, + &table.key_info.account_id, + &table.key_info.table_id + ) + .await + > 0 + ); + + let enabled = engine + .describe_ttl(&table.key_info.account_id, &table.key_info.table_name) + .await + .expect("describe enabled TTL"); + assert_eq!(enabled.time_to_live_status, TimeToLiveStatus::Enabled); + assert_eq!(enabled.attribute_name.as_deref(), Some("expires_at")); + assert!( + engine + .tables_with_ttl(&table.key_info.account_id) + .await + .expect("list account TTL tables") + .iter() + .any( + |(name, attribute)| name == &table.key_info.table_name && attribute == "expires_at" + ) + ); + assert!( + engine + .all_tables_with_ttl_index_ready() + .await + .expect("list ready TTL tables") + .iter() + .any( + |(account, name, attribute)| account == &table.key_info.account_id + && name == &table.key_info.table_name + && attribute == "expires_at" + ) + ); + + let sweep_owner = engine + .acquire_current_ttl_sweep_lease(&table.key_info.account_id, &table.key_info.table_name) + .await + .expect("acquire TTL sweep lease") + .expect("TTL sweep lease applied"); + let blocked_disable = engine + .update_ttl( + &table.key_info.account_id, + &table.key_info.table_name, + "expires_at", + false, + ) + .await; + assert!(matches!( + blocked_disable, + Err(StorageError::IndexesInUse(_)) + )); + engine + .release_ttl_sweep_lease( + &table.key_info.account_id, + &table.key_info.table_name, + sweep_owner, + ) + .await + .expect("release TTL sweep lease"); + + engine + .update_ttl( + &table.key_info.account_id, + &table.key_info.table_name, + "expires_at", + false, + ) + .await + .expect("disable TTL metadata"); + engine + .drop_ttl_index(&table.key_info.account_id, &table.key_info.table_name) + .await + .expect("clear TTL queue"); + assert_eq!( + ttl_bucket_count( + &engine, + &table.key_info.account_id, + &table.key_info.table_id + ) + .await, + 0 + ); + let disabled_again = engine + .describe_ttl(&table.key_info.account_id, &table.key_info.table_name) + .await + .expect("describe disabled TTL again"); + assert_eq!( + disabled_again.time_to_live_status, + TimeToLiveStatus::Disabled + ); + + engine + .update_ttl( + &table.key_info.account_id, + &table.key_info.table_name, + "expires_at", + true, + ) + .await + .expect("re-enable TTL metadata"); + engine + .create_ttl_index( + &table.key_info.account_id, + &table.key_info.table_name, + "expires_at", + ) + .await + .expect("backfill re-enabled TTL queue"); + let second_generation = ttl_generation( + &engine, + &table.key_info.account_id, + &table.key_info.table_name, + ) + .await; + assert_ne!(first_generation, second_generation); +} + +#[tokio::test] +async fn test_ttl_queue_sweep_and_stale_candidate_protection() { + use std::sync::Arc; + + use extenddb_core::metrics::MetricsCollector; + use extenddb_core::types::AttributeValue; + use extenddb_storage::DataEngine; + + let engine = Arc::new(setup_engine().await); + let table = crate::helpers::TestTable::new(&engine, "TtlSweep", false).await; + activate_tables(&engine).await; + engine + .update_ttl( + &table.key_info.account_id, + &table.key_info.table_name, + "expires_at", + true, + ) + .await + .expect("enable TTL"); + engine + .create_ttl_index( + &table.key_info.account_id, + &table.key_info.table_name, + "expires_at", + ) + .await + .expect("prepare TTL queue"); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let make_item = |id: &str, ttl: Option| { + let mut item = std::collections::BTreeMap::new(); + item.insert("id".to_owned(), AttributeValue::S(id.to_owned())); + if let Some(ttl) = ttl { + item.insert("expires_at".to_owned(), AttributeValue::N(ttl.to_string())); + } + item + }; + + for item in [ + make_item("expired", Some(now - 10)), + make_item("future", Some(now + 3_600)), + make_item("missing", None), + ] { + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put TTL test item"); + } + + // Replacing an expired timestamp with a future timestamp removes the old + // queue entry in the same logged batch, so the item must survive the sweep. + engine + .put_item( + &table.key_info, + make_item("moved", Some(now - 10)), + false, + None, + &Default::default(), + None, + ) + .await + .expect("put initially expired item"); + engine + .put_item( + &table.key_info, + make_item("moved", Some(now + 3_600)), + false, + None, + &Default::default(), + None, + ) + .await + .expect("move TTL into future"); + + assert!(ttl_outbox_count(&engine, &table.key_info.account_id).await > 0); + extenddb_storage_cassandra::ttl_worker::reconcile_pending_older_than(&engine, 1_000, -60) + .await + .expect("drain TTL reconciliation outbox"); + assert_eq!( + ttl_outbox_count(&engine, &table.key_info.account_id).await, + 0 + ); + + let candidates = engine + .find_expired_items_indexed( + &table.key_info.account_id, + &table.key_info.table_name, + "expires_at", + 100, + ) + .await + .expect("find expired items"); + assert_eq!(candidates.len(), 1); + assert_eq!( + candidates[0].get("id"), + Some(&AttributeValue::S("expired".to_owned())) + ); + + extenddb_storage_cassandra::ttl_worker::sweep_once(&engine, &MetricsCollector::new()).await; + + for id in ["expired", "future", "missing", "moved"] { + let mut key = std::collections::BTreeMap::new(); + key.insert("id".to_owned(), AttributeValue::S(id.to_owned())); + let current = engine + .get_item(&table.key_info, &key) + .await + .expect("get item after TTL sweep"); + if id == "expired" { + assert!(current.is_none(), "expired item should be deleted"); + } else { + assert!(current.is_some(), "{id} should survive TTL sweep"); + } + } + assert_eq!( + ttl_inflight_repair_count(&engine, &table.key_info.account_id).await, + 0, + "definitive queue destroys must hand off and remove their inflight markers" + ); +} + +#[tokio::test] +async fn test_ttl_sweep_emits_service_remove_stream_record() { + use std::sync::Arc; + + use cdrs_tokio::types::IntoRustByName; + use extenddb_core::metrics::MetricsCollector; + use extenddb_core::types::{ + AttributeDefinition, AttributeValue, CreateTableInput, KeySchemaElement, KeyType, + ScalarAttributeType, StreamSpecification, StreamViewType, + }; + use extenddb_storage::{DataEngine, TableEngine}; + + let engine = Arc::new(setup_engine().await); + let account = crate::helpers::TestAccount::new(&engine, "extenddb_ttl_test").await; + let table_name = "TtlStream"; + engine + .create_table( + &account.account_id, + CreateTableInput { + table_name: table_name.to_owned(), + key_schema: vec![KeySchemaElement { + attribute_name: "id".to_owned(), + key_type: KeyType::Hash, + }], + attribute_definitions: vec![AttributeDefinition { + attribute_name: "id".to_owned(), + attribute_type: ScalarAttributeType::S, + }], + stream_specification: Some(StreamSpecification { + stream_enabled: true, + stream_view_type: Some(StreamViewType::NewAndOldImages), + }), + local_secondary_indexes: None, + global_secondary_indexes: None, + vector_indexes: None, + billing_mode: None, + table_throughput_mode: None, + provisioned_throughput: None, + on_demand_throughput: None, + sse_specification: None, + tags: None, + table_class: None, + deletion_protection_enabled: None, + }, + ) + .await + .expect("create stream-enabled TTL table"); + activate_tables(&engine).await; + let key_info = engine + .table_key_info(&account.account_id, table_name) + .await + .expect("stream TTL table key info"); + engine + .update_ttl(&account.account_id, table_name, "expires_at", true) + .await + .expect("enable TTL"); + engine + .create_ttl_index(&account.account_id, table_name, "expires_at") + .await + .expect("prepare TTL queue"); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let mut item = std::collections::BTreeMap::new(); + item.insert( + "id".to_owned(), + AttributeValue::S("ttl-stream-item".to_owned()), + ); + item.insert( + "expires_at".to_owned(), + AttributeValue::N((now - 10).to_string()), + ); + item.insert("value".to_owned(), AttributeValue::S("old".to_owned())); + engine + .put_item(&key_info, item, false, None, &Default::default(), None) + .await + .expect("put expired stream item"); + + let account_keyspace = engine.account_keyspace(&account.account_id); + engine + .session_arc() + .query(format!("DROP TABLE {account_keyspace}.stream_records")) + .await + .expect("drop stream table for TTL retry test"); + + let metrics = MetricsCollector::new(); + extenddb_storage_cassandra::ttl_worker::sweep_once(&engine, &metrics).await; + let mut key = std::collections::BTreeMap::new(); + key.insert( + "id".to_owned(), + AttributeValue::S("ttl-stream-item".to_owned()), + ); + assert!( + engine + .get_item(&key_info, &key) + .await + .expect("read item after failed TTL effects") + .is_some() + ); + + engine + .session_arc() + .query(format!( + "CREATE TABLE {account_keyspace}.stream_records (\ + shard_id text, sequence_number text, table_id text, event_name text, \ + record_data text, created_at timestamp, \ + PRIMARY KEY ((shard_id), sequence_number)) \ + WITH CLUSTERING ORDER BY (sequence_number ASC)" + )) + .await + .expect("recreate stream table for TTL retry"); + tokio::join!( + extenddb_storage_cassandra::ttl_worker::sweep_once(&engine, &metrics), + extenddb_storage_cassandra::ttl_worker::sweep_once(&engine, &metrics) + ); + extenddb_storage_cassandra::ttl_worker::sweep_once(&engine, &metrics).await; + let shard_id = extenddb_storage_cassandra::stream_util::assign_shard_id( + "ttl-stream-item", + &key_info.table_id, + ); + let query = format!( + "SELECT event_name, record_data FROM {account_keyspace}.stream_records WHERE shard_id = ?" + ); + let rows = engine + .session_arc() + .query_with_values(&query, cdrs_tokio::query_values!(shard_id.as_str())) + .await + .expect("query TTL stream records") + .response_body() + .expect("parse TTL stream response") + .into_rows() + .unwrap_or_default(); + let remove_records: Vec = rows + .into_iter() + .filter_map(|row| { + let event: String = row.get_r_by_name("event_name").ok()?; + if event == "Remove" { + row.get_r_by_name("record_data").ok() + } else { + None + } + }) + .collect(); + assert_eq!(remove_records.len(), 1); + let record: serde_json::Value = + serde_json::from_str(&remove_records[0]).expect("parse TTL stream record JSON"); + assert_eq!(record["userIdentity"]["Type"], "Service"); + assert_eq!( + record["userIdentity"]["PrincipalId"], + "dynamodb.amazonaws.com" + ); + assert_eq!(record["dynamodb"]["OldImage"]["id"]["S"], "ttl-stream-item"); + + // A worker that published the record but never completed the exact base + // delete would satisfy everything above. + let mut key = extenddb_core::types::Item::new(); + key.insert( + "id".to_owned(), + extenddb_core::types::AttributeValue::S("ttl-stream-item".to_owned()), + ); + assert!( + extenddb_storage::DataEngine::get_item(engine.as_ref(), &key_info, &key) + .await + .unwrap() + .is_none(), + "the retried sweep must also complete the base-row delete" + ); +} + +#[tokio::test] +async fn test_transactional_write_reconciles_ttl_queue() { + use extenddb_core::expression::ExpressionMaps; + use extenddb_core::metrics::MetricsCollector; + use extenddb_core::types::{AttributeValue, Item, ReturnValuesOnConditionCheckFailure}; + use extenddb_storage::{DataEngine, TransactWriteOp}; + + let engine = setup_engine().await; + let table = crate::helpers::TestTable::new(&engine, "TtlTransaction", false).await; + activate_tables(&engine).await; + engine + .update_ttl( + &table.key_info.account_id, + &table.key_info.table_name, + "expires_at", + true, + ) + .await + .expect("enable transaction TTL"); + engine + .create_ttl_index( + &table.key_info.account_id, + &table.key_info.table_name, + "expires_at", + ) + .await + .expect("prepare transaction TTL queue"); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let mut item = Item::new(); + item.insert("id".to_owned(), AttributeValue::S("txn-ttl".to_owned())); + item.insert( + "expires_at".to_owned(), + AttributeValue::N((now - 10).to_string()), + ); + let maps = ExpressionMaps::default(); + engine + .transact_write_items( + &[TransactWriteOp::Put { + key_info: &table.key_info, + item: &item, + condition: None, + maps: &maps, + return_values_on_ccf: ReturnValuesOnConditionCheckFailure::None, + stream: None, + }], + None, + ) + .await + .expect("transactional TTL put"); + + assert!( + engine + .all_tables_with_ttl_index_ready() + .await + .expect("list reconciled TTL queues") + .iter() + .any(|(account, name, _)| account == &table.key_info.account_id + && name == &table.key_info.table_name) + ); + extenddb_storage_cassandra::ttl_worker::sweep_once(&engine, &MetricsCollector::new()).await; + + let mut key = Item::new(); + key.insert("id".to_owned(), AttributeValue::S("txn-ttl".to_owned())); + assert!( + engine + .get_item(&table.key_info, &key) + .await + .expect("read transactional TTL item") + .is_none() + ); +} + +#[tokio::test] +async fn test_ttl_claim_serializes_delayed_writer() { + use extenddb_core::types::{AttributeValue, Item}; + use extenddb_storage::DataEngine; + use extenddb_storage::error::StorageError; + + let engine = setup_engine().await; + let table = crate::helpers::TestTable::new(&engine, "TtlClaimRace", false).await; + activate_tables(&engine).await; + engine + .update_ttl( + &table.key_info.account_id, + &table.key_info.table_name, + "expires_at", + true, + ) + .await + .unwrap(); + engine + .create_ttl_index( + &table.key_info.account_id, + &table.key_info.table_name, + "expires_at", + ) + .await + .unwrap(); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let mut old = Item::new(); + old.insert("id".to_owned(), AttributeValue::S("race".to_owned())); + old.insert( + "expires_at".to_owned(), + AttributeValue::N((now - 10).to_string()), + ); + engine + .put_item( + &table.key_info, + old.clone(), + false, + None, + &Default::default(), + None, + ) + .await + .unwrap(); + let mut key = Item::new(); + key.insert("id".to_owned(), AttributeValue::S("race".to_owned())); + + let first_claim = engine + .acquire_ttl_mutation_claim(&table.key_info, &key, Some(&old)) + .await + .unwrap(); + let second_claim = engine + .acquire_ttl_mutation_claim(&table.key_info, &key, Some(&old)) + .await; + assert!(matches!( + second_claim, + Err(StorageError::TransactionConflict(_)) + )); + + let mut renewed = old.clone(); + renewed.insert( + "expires_at".to_owned(), + AttributeValue::N((now + 3_600).to_string()), + ); + let blocked_write = engine + .put_item( + &table.key_info, + renewed.clone(), + false, + None, + &Default::default(), + None, + ) + .await; + // The write retries the claim on a jittered backoff before giving up, and + // surfaces DynamoDB's canonical single-item conflict error rather than + // TransactionCanceledException, which PutItem does not have. + assert!(matches!( + blocked_write, + Err(StorageError::TransactionConflict(_)) + )); + + engine + .release_ttl_mutation_claim(&table.key_info, &key, first_claim) + .await; + engine + .put_item( + &table.key_info, + renewed.clone(), + false, + None, + &Default::default(), + None, + ) + .await + .expect("renewed write succeeds after claim release"); + + let mut follow_up = renewed.clone(); + follow_up.insert("value".to_owned(), AttributeValue::S("second".to_owned())); + engine + .put_item( + &table.key_info, + follow_up.clone(), + false, + None, + &Default::default(), + None, + ) + .await + .expect("successful put releases its TTL claim immediately"); + engine + .delete_item( + &table.key_info, + &key, + false, + None, + &Default::default(), + None, + ) + .await + .expect("delete succeeds after immediate put follow-up"); + assert!( + engine + .get_item(&table.key_info, &key) + .await + .expect("read after claimed delete") + .is_none(), + "successful delete must not leave a claim-only row" + ); + engine + .put_item( + &table.key_info, + follow_up, + false, + None, + &Default::default(), + None, + ) + .await + .expect("successful delete releases its exact TTL claim immediately"); +} + +/// Two ordinary writers contending for the same key on a TTL-enabled table must +/// both succeed: the base-row claim is an internal serialisation device, not a +/// client-visible failure mode. DynamoDB has no conflict error for concurrent +/// unconditional `PutItem`s, so losing the claim has to be retried internally +/// against a freshly read image rather than surfaced. +#[tokio::test] +async fn test_concurrent_ordinary_writes_on_ttl_table_both_succeed() { + use extenddb_core::types::{AttributeValue, Item}; + use extenddb_storage::DataEngine; + + let engine = std::sync::Arc::new(setup_engine().await); + let table = crate::helpers::TestTable::new(&engine, "TtlConcurrentPut", false).await; + activate_tables(&engine).await; + engine + .update_ttl( + &table.key_info.account_id, + &table.key_info.table_name, + "expires_at", + true, + ) + .await + .unwrap(); + engine + .create_ttl_index( + &table.key_info.account_id, + &table.key_info.table_name, + "expires_at", + ) + .await + .unwrap(); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let write = |value: &str| { + let engine = engine.clone(); + let key_info = table.key_info.clone(); + let value = value.to_owned(); + async move { + let mut item = Item::new(); + item.insert("id".to_owned(), AttributeValue::S("contended".to_owned())); + item.insert( + "expires_at".to_owned(), + AttributeValue::N((now + 3_600).to_string()), + ); + item.insert("value".to_owned(), AttributeValue::S(value)); + engine + .put_item(&key_info, item, false, None, &Default::default(), None) + .await + } + }; + + let (first, second) = tokio::join!(write("a"), write("b")); + first.expect("first concurrent write succeeds"); + second.expect("second concurrent write succeeds"); + + // Sustained contention, so the collision is hit on the pre-read as well as + // on the claim LWT. Every writer must still succeed. + let mut wave = Vec::new(); + for index in 0..8 { + wave.push(write(&format!("wave-{index}"))); + } + for (index, result) in futures::future::join_all(wave) + .await + .into_iter() + .enumerate() + { + result.unwrap_or_else(|error| { + panic!("contended write {index} must not surface a conflict: {error}") + }); + } + + let mut key = Item::new(); + key.insert("id".to_owned(), AttributeValue::S("contended".to_owned())); + let stored = engine + .get_item(&table.key_info, &key) + .await + .expect("read after contended writes") + .expect("one of the writes is durable"); + let expected: Vec = std::iter::once("a".to_owned()) + .chain(std::iter::once("b".to_owned())) + .chain((0..8).map(|index| format!("wave-{index}"))) + .collect(); + let stored_value = match stored.get("value") { + Some(AttributeValue::S(value)) => value.clone(), + other => panic!("expected a string value, got {other:?}"), + }; + assert!( + expected.contains(&stored_value), + "the durable value must be one an actual writer wrote, got {stored_value:?}" + ); + assert!( + stored.contains_key("expires_at"), + "the winning write must have kept the TTL attribute" + ); +} + +/// Manufacture a durable queue row in `state` owning `work_id`, and put the +/// matching exact claim on the base row, as a crashed worker would leave them. +/// Returns the queue row's clustering key. +async fn forge_ttl_work( + engine: &extenddb_storage_cassandra::CassandraEngine, + key_info: &extenddb_core::types::TableKeyInfo, + old_item: &extenddb_core::types::Item, + state: &str, + work_id: uuid::Uuid, +) -> (uuid::Uuid, i64, i32, i64, String, String) { + use cdrs_tokio::types::IntoRustByName; + + let generation = ttl_generation(engine, &key_info.account_id, &key_info.table_name).await; + let keyspace = engine.account_keyspace(&key_info.account_id); + let bucket_row = engine + .session_arc() + .query_with_values( + &format!( + "SELECT bucket, shard FROM {keyspace}.ttl_expiration_buckets \ + WHERE table_id = ? AND generation = ?" + ), + cdrs_tokio::query_values!(key_info.table_id.as_str(), generation), + ) + .await + .unwrap() + .response_body() + .unwrap() + .into_rows() + .unwrap_or_default() + .into_iter() + .next() + .expect("TTL bucket for active generation"); + let bucket: i64 = bucket_row.get_r_by_name("bucket").unwrap(); + let shard: i32 = bucket_row.get_r_by_name("shard").unwrap(); + + let queue_row = engine + .session_arc() + .query_with_values( + &format!( + "SELECT expires_at, key_hash, key_data FROM {keyspace}.ttl_expirations \ + WHERE table_id = ? AND generation = ? AND bucket = ? AND shard = ?" + ), + cdrs_tokio::query_values!(key_info.table_id.as_str(), generation, bucket, shard), + ) + .await + .unwrap() + .response_body() + .unwrap() + .into_rows() + .unwrap_or_default() + .into_iter() + .next() + .expect("pending TTL entry"); + let expires_at: i64 = queue_row.get_r_by_name("expires_at").unwrap(); + let key_hash: String = queue_row.get_r_by_name("key_hash").unwrap(); + let key_data: String = queue_row.get_r_by_name("key_data").unwrap(); + + let work_data = serde_json::json!({ + "old_item": old_item, + "delete_timestamp_ms": std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64, + "stream": null + }) + .to_string(); + engine + .session_arc() + .query_with_values( + &format!( + "UPDATE {keyspace}.ttl_expirations SET state = ?, work_id = ?, work_data = ? \ + WHERE table_id = ? AND generation = ? AND bucket = ? AND shard = ? \ + AND expires_at = ? AND key_hash = ? AND key_data = ?" + ), + cdrs_tokio::query_values!( + state, + work_id, + work_data.as_str(), + key_info.table_id.as_str(), + generation, + bucket, + shard, + expires_at, + key_hash.as_str(), + key_data.as_str() + ), + ) + .await + .unwrap(); + + let data_table = format!("items_{}", key_info.table_id.replace('-', "_")); + let pk = extenddb_storage::util::composite_pk_to_text(old_item, &key_info.key_schema).unwrap(); + engine + .session_arc() + .query_with_values( + &format!("UPDATE {keyspace}.{data_table} SET prepared_txn_id = ? WHERE pk = ?"), + cdrs_tokio::query_values!(work_id, pk.as_str()), + ) + .await + .unwrap(); + + (generation, bucket, shard, expires_at, key_hash, key_data) +} + +async fn base_row_owner( + engine: &extenddb_storage_cassandra::CassandraEngine, + key_info: &extenddb_core::types::TableKeyInfo, + item: &extenddb_core::types::Item, +) -> Option { + use cdrs_tokio::types::IntoRustByName; + let keyspace = engine.account_keyspace(&key_info.account_id); + let data_table = format!("items_{}", key_info.table_id.replace('-', "_")); + let pk = extenddb_storage::util::composite_pk_to_text(item, &key_info.key_schema).unwrap(); + engine + .session_arc() + .query_with_values( + &format!("SELECT prepared_txn_id FROM {keyspace}.{data_table} WHERE pk = ?"), + cdrs_tokio::query_values!(pk.as_str()), + ) + .await + .unwrap() + .response_body() + .unwrap() + .into_rows() + .unwrap_or_default() + .into_iter() + .next() + .and_then(|row| row.get_by_name("prepared_txn_id").ok().flatten()) +} + +async fn ttl_queue_row_count( + engine: &extenddb_storage_cassandra::CassandraEngine, + account_id: &str, + table_id: &str, + generation: uuid::Uuid, + bucket: i64, + shard: i32, +) -> usize { + let keyspace = engine.account_keyspace(account_id); + engine + .session_arc() + .query_with_values( + &format!( + "SELECT key_hash FROM {keyspace}.ttl_expirations WHERE table_id = ? \ + AND generation = ? AND bucket = ? AND shard = ?" + ), + cdrs_tokio::query_values!(table_id, generation, bucket, shard), + ) + .await + .unwrap() + .response_body() + .unwrap() + .into_rows() + .unwrap_or_default() + .len() +} + +/// Set up a TTL-enabled table holding one already-expired item. +async fn ttl_table_with_expired_item( + engine: &extenddb_storage_cassandra::CassandraEngine, + table_name: &str, + age_seconds: i64, +) -> (crate::helpers::TestTable, extenddb_core::types::Item) { + use extenddb_core::types::{AttributeValue, Item}; + use extenddb_storage::DataEngine; + + let table = crate::helpers::TestTable::new(engine, table_name, false).await; + activate_tables(engine).await; + engine + .update_ttl( + &table.key_info.account_id, + &table.key_info.table_name, + "expires_at", + true, + ) + .await + .unwrap(); + engine + .create_ttl_index( + &table.key_info.account_id, + &table.key_info.table_name, + "expires_at", + ) + .await + .unwrap(); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + let mut item = Item::new(); + item.insert("id".to_owned(), AttributeValue::S("drain".to_owned())); + item.insert( + "expires_at".to_owned(), + AttributeValue::N((now - age_seconds).to_string()), + ); + engine + .put_item( + &table.key_info, + item.clone(), + false, + None, + &Default::default(), + None, + ) + .await + .unwrap(); + (table, item) +} + +/// Retiring a generation must not delete a CLAIMED row and walk away: that would +/// strand the base-row claim it owns. Nothing externally visible has happened +/// yet, so the claim is released and the work abandoned — disabling TTL stops +/// the deletion rather than completing it. +#[tokio::test] +async fn test_disable_drains_claimed_work_and_releases_its_claim() { + use extenddb_storage::DataEngine; + + let engine = setup_engine().await; + let (table, item) = ttl_table_with_expired_item(&engine, "TtlDrainClaimed", 10).await; + + let work_id = uuid::Uuid::new_v4(); + let (generation, bucket, shard, ..) = + forge_ttl_work(&engine, &table.key_info, &item, "CLAIMED", work_id).await; + assert_eq!( + base_row_owner(&engine, &table.key_info, &item).await, + Some(work_id), + "precondition: the forged claim is held" + ); + + engine + .update_ttl( + &table.key_info.account_id, + &table.key_info.table_name, + "expires_at", + false, + ) + .await + .expect("disable succeeds even with claimed work in flight"); + + assert_eq!( + base_row_owner(&engine, &table.key_info, &item).await, + None, + "draining a CLAIMED row must release the base-row claim it owned" + ); + assert_eq!( + ttl_queue_row_count( + &engine, + &table.key_info.account_id, + &table.key_info.table_id, + generation, + bucket, + shard + ) + .await, + 0, + "the abandoned queue row must be removed" + ); + + let mut key = extenddb_core::types::Item::new(); + key.insert( + "id".to_owned(), + extenddb_core::types::AttributeValue::S("drain".to_owned()), + ); + assert!( + engine + .get_item(&table.key_info, &key) + .await + .unwrap() + .is_some(), + "disabling TTL must stop the deletion, not complete it" + ); +} + +/// The durable must-complete boundary is entered before effects. Cleanup must +/// replay an `EFFECTS_APPLYING` row idempotently and finish the base delete; +/// treating it like `CLAIMED` would permit a crash-after-effects inconsistency. +#[tokio::test] +async fn test_disable_completes_effects_applying_work() { + use extenddb_storage::DataEngine; + + let engine = setup_engine().await; + let (table, item) = ttl_table_with_expired_item(&engine, "TtlDrainApplying", 10).await; + let work_id = uuid::Uuid::new_v4(); + let (generation, bucket, shard, ..) = + forge_ttl_work(&engine, &table.key_info, &item, "EFFECTS_APPLYING", work_id).await; + + engine + .update_ttl( + &table.key_info.account_id, + &table.key_info.table_name, + "expires_at", + false, + ) + .await + .expect("disable completes effects-applying work"); + + let mut key = extenddb_core::types::Item::new(); + key.insert( + "id".to_owned(), + extenddb_core::types::AttributeValue::S("drain".to_owned()), + ); + assert!( + engine + .get_item(&table.key_info, &key) + .await + .unwrap() + .is_none() + ); + assert_eq!( + ttl_queue_row_count( + &engine, + &table.key_info.account_id, + &table.key_info.table_id, + generation, + bucket, + shard, + ) + .await, + 0 + ); +} + +/// A marker partition holding more than one scan page must still be fully +/// traversed within a bounded number of passes: the resume cursor makes a +/// 300-marker partition drain in exactly two passes here, where a fixed or +/// random page could starve markers indefinitely. +#[tokio::test] +async fn test_inflight_marker_traversal_covers_beyond_one_page() { + let engine = setup_engine().await; + let table = crate::helpers::TestTable::new(&engine, "TtlMarkerTraversal", false).await; + activate_tables(&engine).await; + let keyspace = engine.account_keyspace(&table.key_info.account_id); + + // 300 markers in one worker partition, all naming a table that does not + // exist — quorum metadata proves them terminal, so each visit deletes one. + let partition: i32 = 7; + engine + .session_arc() + .query_with_values( + &format!( + "INSERT INTO {keyspace}.ttl_repair_inflight_partitions (worker_partition) \ + VALUES (?)" + ), + cdrs_tokio::query_values!(partition), + ) + .await + .unwrap(); + for index in 0..300 { + engine + .session_arc() + .query_with_values( + &format!( + "INSERT INTO {keyspace}.ttl_repair_inflight \ + (worker_partition, repair_id, table_id, account_id, table_name, \ + generation, key_data, created_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?, toTimestamp(now()))" + ), + cdrs_tokio::query_values!( + partition, + uuid::Uuid::new_v4(), + format!("gone-{index}"), + table.key_info.account_id.as_str(), + "NoSuchTable", + uuid::Uuid::new_v4(), + r#"{"id":{"S":"x"}}"# + ), + ) + .await + .unwrap(); + } + + let count = |engine: &extenddb_storage_cassandra::CassandraEngine| { + let keyspace = keyspace.clone(); + let session = engine.session_arc(); + async move { + session + .query_with_values( + &format!( + "SELECT repair_id FROM {keyspace}.ttl_repair_inflight \ + WHERE worker_partition = ?" + ), + cdrs_tokio::query_values!(partition), + ) + .await + .unwrap() + .response_body() + .unwrap() + .into_rows() + .unwrap_or_default() + .len() + } + }; + assert_eq!(count(&engine).await, 300); + + // One page of 256, then the remainder: exactly two passes, by construction + // of the resume cursor — not eventually, and not probabilistically. + extenddb_storage_cassandra::ttl_worker::reconcile_inflight_repairs_once(&engine) + .await + .unwrap(); + let after_first = count(&engine).await; + assert!( + after_first <= 44, + "first pass must clear a full page (got {after_first} remaining)" + ); + extenddb_storage_cassandra::ttl_worker::reconcile_inflight_repairs_once(&engine) + .await + .unwrap(); + assert_eq!( + count(&engine).await, + 0, + "the resume cursor must reach every marker within two passes" + ); +} + +/// A prefix of persistently unreconcilable outbox rows must not hide the valid +/// records behind it: the pass pages past the failing prefix within one cycle. +#[tokio::test] +async fn test_outbox_pages_past_poison_prefix() { + use extenddb_core::types::{AttributeValue, Item}; + use extenddb_storage::DataEngine; + + let engine = setup_engine().await; + let table = crate::helpers::TestTable::new(&engine, "TtlPoisonPrefix", false).await; + activate_tables(&engine).await; + engine + .update_ttl( + &table.key_info.account_id, + &table.key_info.table_name, + "expires_at", + true, + ) + .await + .unwrap(); + engine + .create_ttl_index( + &table.key_info.account_id, + &table.key_info.table_name, + "expires_at", + ) + .await + .unwrap(); + let keyspace = engine.account_keyspace(&table.key_info.account_id); + + // A real item whose outbox row will sit BEHIND the poison prefix. + let future = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + + 3_600; + let mut item = Item::new(); + item.insert("id".to_owned(), AttributeValue::S("behind".to_owned())); + item.insert( + "expires_at".to_owned(), + AttributeValue::N(future.to_string()), + ); + engine + .put_item( + &table.key_info, + item.clone(), + false, + None, + &Default::default(), + None, + ) + .await + .unwrap(); + + // 70 rows with unparseable key_data, all in the valid row's partition and + // all inserted AFTER it in the wall-clock sense but with timeuuids that + // cluster BEFORE it cannot be forged — so instead compute the partition + // and pin the poison rows first is not possible either. What matters is + // only that a full page (64) of failing rows precedes the valid one in + // clustering order at scan time, which holds when the poison rows carry + // earlier timeuuids. now() at insert time is monotonic per coordinator, + // so insert the poison rows into the SAME partition and then re-write the + // valid row's outbox record afterwards, giving it the latest timeuuid. + let key_data = r#"{"id":{"S":"behind"}}"#; + let partition = (crc32fast_hash(key_data) % 64) as i32; + // Remove the put's own outbox row so ordering is fully controlled. + engine + .session_arc() + .query(&format!("TRUNCATE {keyspace}.ttl_reconcile_pending")) + .await + .unwrap(); + for _ in 0..70 { + engine + .session_arc() + .query_with_values( + &format!( + "INSERT INTO {keyspace}.ttl_reconcile_pending \ + (worker_partition, id, table_id, account_id, table_name, key_data) \ + VALUES (?, now(), ?, ?, ?, ?)" + ), + cdrs_tokio::query_values!( + partition, + table.key_info.table_id.as_str(), + table.key_info.account_id.as_str(), + table.key_info.table_name.as_str(), + "this is not json" + ), + ) + .await + .unwrap(); + } + engine + .session_arc() + .query_with_values( + &format!( + "INSERT INTO {keyspace}.ttl_reconcile_pending \ + (worker_partition, id, table_id, account_id, table_name, key_data) \ + VALUES (?, now(), ?, ?, ?, ?)" + ), + cdrs_tokio::query_values!( + partition, + table.key_info.table_id.as_str(), + table.key_info.account_id.as_str(), + table.key_info.table_name.as_str(), + key_data + ), + ) + .await + .unwrap(); + + // Default production page for limit=1000 is 64: the prefix fills page one + // exactly, so reaching the valid row requires paging, not luck. + extenddb_storage_cassandra::ttl_worker::reconcile_pending_older_than(&engine, 1_000, -60) + .await + .unwrap(); + + let remaining = engine + .session_arc() + .query_with_values( + &format!("SELECT id FROM {keyspace}.ttl_reconcile_pending WHERE worker_partition = ?"), + cdrs_tokio::query_values!(partition), + ) + .await + .unwrap() + .response_body() + .unwrap() + .into_rows() + .unwrap_or_default() + .len(); + assert_eq!( + remaining, 70, + "the valid row behind the poison prefix must be reconciled and discharged" + ); +} + +fn crc32fast_hash(value: &str) -> u32 { + crc32fast::hash(value.as_bytes()) +} + +/// The mixed-timestamp state a stale writer leaves behind: `EFFECTS_APPLYING` +/// with a sealed owner whose base row now carries a DIFFERENT image, because +/// the writer's unconditional batch (pinned before the seal) landed after it — +/// its owner-null cells lost to the seal, its item cells beat the older image. +/// Recovery must fence on the owner alone and complete: requiring the image to +/// match would wedge the row forever behind a non-expiring owner, block +/// generation cleanup, and (since enable/disable require a null cleanup +/// generation) make TTL permanently un-enableable on the table. +#[tokio::test] +async fn test_effects_applying_with_changed_image_completes_not_wedges() { + use extenddb_core::metrics::MetricsCollector; + use extenddb_core::types::{AttributeValue, Item}; + use extenddb_storage::DataEngine; + + let engine = setup_engine().await; + let (table, old_item) = + ttl_table_with_expired_item(&engine, "TtlApplyingChangedImage", 10).await; + let work_id = uuid::Uuid::new_v4(); + // Forge EFFECTS_APPLYING owning the OLD image, with the claim on the row. + let (generation, bucket, shard, ..) = forge_ttl_work( + &engine, + &table.key_info, + &old_item, + "EFFECTS_APPLYING", + work_id, + ) + .await; + + // The stale writer's batch: change the item under the sealed owner without + // touching the claim columns, exactly what an older-pinned unconditional + // batch does when its owner-null cells lose but its item cells win. + let mut changed = old_item.clone(); + changed.insert( + "value".to_owned(), + AttributeValue::S("stale-write".to_owned()), + ); + // More than two day-buckets ahead, so the changed item's re-registered + // queue entry can never share the old entry's (bucket, shard) partition — + // a +1h offset made the zero-rows assertion below time-of-day dependent + // (it only held within an hour of midnight UTC). + let future = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + + 3 * 86_400; + changed.insert( + "expires_at".to_owned(), + AttributeValue::N(future.to_string()), + ); + let keyspace = engine.account_keyspace(&table.key_info.account_id); + let data_table = format!("items_{}", table.key_info.table_id.replace('-', "_")); + let changed_json = serde_json::to_string(&changed).unwrap(); + engine + .session_arc() + .query_with_values( + &format!("UPDATE {keyspace}.{data_table} SET item_data = ? WHERE pk = ?"), + cdrs_tokio::query_values!(changed_json.as_str(), "drain"), + ) + .await + .unwrap(); + + // The sweep must complete the work, not error out of the table pass. + let metrics = MetricsCollector::new(); + extenddb_storage_cassandra::ttl_worker::sweep_once(&engine, &metrics).await; + + let mut key = Item::new(); + key.insert("id".to_owned(), AttributeValue::S("drain".to_owned())); + let survivor = engine + .get_item(&table.key_info, &key) + .await + .unwrap() + .expect("the changed item must survive — it postdates the recorded image"); + assert_eq!( + survivor.get("value"), + Some(&AttributeValue::S("stale-write".to_owned())) + ); + assert_eq!( + base_row_owner(&engine, &table.key_info, &old_item).await, + None, + "the sealed owner must be released after completion" + ); + assert_eq!( + ttl_queue_row_count( + &engine, + &table.key_info.account_id, + &table.key_info.table_id, + generation, + bucket, + shard, + ) + .await, + 0, + "the must-complete row must have been completed, not wedged \ + (the changed item re-registers in its future bucket, not this one)" + ); + // The key must be writable again (no lingering fence). + let mut rewrite = survivor.clone(); + rewrite.insert("value".to_owned(), AttributeValue::S("after".to_owned())); + engine + .put_item( + &table.key_info, + rewrite, + false, + None, + &Default::default(), + None, + ) + .await + .expect("the key must be writable after recovery"); +} + +/// Same mixed-timestamp state as above, on a table with a synchronous GSI whose +/// key the stale write does NOT change. The replayed effects tombstone the old +/// image's GSI row — which is also the survivor's, since the key is shared — +/// so recovery must rebuild it from the survivor before releasing the sealed +/// owner, at a timestamp strictly above the replay's. Without restoration the +/// live item is left invisible to its own index. +#[tokio::test] +async fn test_effects_applying_recovery_restores_shared_key_gsi_row() { + use extenddb_core::expression::{Expr, ExpressionMaps, KeyCondition, PathElement}; + use extenddb_core::metrics::MetricsCollector; + use extenddb_core::types::AttributeValue; + use extenddb_storage::DataEngine; + + let engine = setup_engine().await; + let table = + crate::helpers::TestTable::with_gsi(&engine, "TtlGsiRestore", "StatusIndex", "status") + .await; + activate_tables(&engine).await; + engine + .session_arc() + .query_with_values( + &format!( + "UPDATE {}.indexes SET propagation_delay_ms = 0 \ + WHERE table_id = ? AND index_name = ?", + engine.catalog_keyspace() + ), + cdrs_tokio::query_values!(table.key_info.table_id.as_str(), "StatusIndex"), + ) + .await + .unwrap(); + engine + .update_ttl( + &table.key_info.account_id, + &table.key_info.table_name, + "expires_at", + true, + ) + .await + .unwrap(); + engine + .create_ttl_index( + &table.key_info.account_id, + &table.key_info.table_name, + "expires_at", + ) + .await + .unwrap(); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let mut old_item = std::collections::BTreeMap::new(); + old_item.insert("id".to_owned(), AttributeValue::S("gsi-restore".to_owned())); + old_item.insert("status".to_owned(), AttributeValue::S("expired".to_owned())); + old_item.insert("value".to_owned(), AttributeValue::S("old".to_owned())); + old_item.insert( + "expires_at".to_owned(), + AttributeValue::N((now - 10).to_string()), + ); + engine + .put_item( + &table.key_info, + old_item.clone(), + false, + None, + &ExpressionMaps::default(), + None, + ) + .await + .unwrap(); + + let work_id = uuid::Uuid::new_v4(); + let (generation, bucket, shard, ..) = forge_ttl_work( + &engine, + &table.key_info, + &old_item, + "EFFECTS_APPLYING", + work_id, + ) + .await; + + // Read back the recorded delete timestamp so the stale write can be pinned + // strictly below the replay's tombstones, as a real pre-seal writer is. + let keyspace = engine.account_keyspace(&table.key_info.account_id); + let work_data_json: String = { + use cdrs_tokio::types::IntoRustByName; + engine + .session_arc() + .query_with_values( + &format!( + "SELECT work_data FROM {keyspace}.ttl_expirations \ + WHERE table_id = ? AND generation = ? AND bucket = ? AND shard = ?" + ), + cdrs_tokio::query_values!( + table.key_info.table_id.as_str(), + generation, + bucket, + shard + ), + ) + .await + .unwrap() + .response_body() + .unwrap() + .into_rows() + .unwrap_or_default() + .first() + .and_then(|row| row.get_by_name("work_data").ok().flatten()) + .expect("forged work data") + }; + let delete_timestamp_ms = + serde_json::from_str::(&work_data_json).unwrap()["delete_timestamp_ms"] + .as_i64() + .unwrap(); + + // The stale writer's base cells: same GSI key, changed projection, future + // TTL (beyond any shared day bucket), stamped below the replay tombstones. + let mut changed = old_item.clone(); + changed.insert("value".to_owned(), AttributeValue::S("survivor".to_owned())); + changed.insert( + "expires_at".to_owned(), + AttributeValue::N((now + 3 * 86_400).to_string()), + ); + let stale_timestamp = delete_timestamp_ms * 1_000 - 1_000; + let data_table = format!("items_{}", table.key_info.table_id.replace('-', "_")); + let changed_json = serde_json::to_string(&changed).unwrap(); + engine + .session_arc() + .query_with_values( + &format!( + "UPDATE {keyspace}.{data_table} USING TIMESTAMP {stale_timestamp} \ + SET item_data = ? WHERE pk = ?" + ), + cdrs_tokio::query_values!(changed_json.as_str(), "gsi-restore"), + ) + .await + .unwrap(); + + extenddb_storage_cassandra::ttl_worker::sweep_once(&engine, &MetricsCollector::new()).await; + + // The survivor must be visible through its own GSI: the replay tombstoned + // the shared-key row, so only restoration can have brought it back. + let condition = KeyCondition { + pk_path: vec![PathElement::Attribute("status".to_owned())], + pk_value: Expr::Placeholder(":status".to_owned()), + sk_condition: None, + extra_pk_conditions: Vec::new(), + extra_sk_conditions: Vec::new(), + }; + let mut maps = ExpressionMaps::default(); + maps.values.insert( + ":status".to_owned(), + AttributeValue::S("expired".to_owned()), + ); + let (index_rows, _) = engine + .query( + &table.key_info, + &condition, + &maps, + true, + None, + None, + Some("StatusIndex"), + ) + .await + .unwrap(); + assert_eq!( + index_rows.len(), + 1, + "the survivor's shared-key GSI row must be restored before the owner drops" + ); + assert_eq!( + index_rows[0].get("value"), + Some(&AttributeValue::S("survivor".to_owned())) + ); + assert_eq!( + base_row_owner(&engine, &table.key_info, &old_item).await, + None, + "the sealed owner must be released" + ); +} + +/// The one phase that must go forward. At EFFECTS_APPLIED the index rows are +/// already deleted and the REMOVE record already published, so abandoning the +/// work would leave a live item with a missing index. Cleanup completes the base +/// delete instead. +#[tokio::test] +async fn test_disable_completes_effects_applied_work() { + use extenddb_storage::DataEngine; + + let engine = setup_engine().await; + let (table, item) = ttl_table_with_expired_item(&engine, "TtlDrainEffects", 10).await; + + let work_id = uuid::Uuid::new_v4(); + let (generation, bucket, shard, ..) = + forge_ttl_work(&engine, &table.key_info, &item, "EFFECTS_APPLIED", work_id).await; + + engine + .update_ttl( + &table.key_info.account_id, + &table.key_info.table_name, + "expires_at", + false, + ) + .await + .expect("disable succeeds with effects-applied work in flight"); + + let mut key = extenddb_core::types::Item::new(); + key.insert( + "id".to_owned(), + extenddb_core::types::AttributeValue::S("drain".to_owned()), + ); + assert!( + engine + .get_item(&table.key_info, &key) + .await + .unwrap() + .is_none(), + "EFFECTS_APPLIED work must complete its base delete, not be abandoned" + ); + assert_eq!( + ttl_queue_row_count( + &engine, + &table.key_info.account_id, + &table.key_info.table_id, + generation, + bucket, + shard + ) + .await, + 0, + "the completed queue row must be removed" + ); +} + +/// A bucket registration is retired once its day is fully past and its partition +/// is confirmed empty at quorum, so per-cycle sweep fan-out stops growing with +/// the age of the table. +#[tokio::test] +async fn test_drained_past_bucket_registration_is_retired() { + use extenddb_core::metrics::MetricsCollector; + + let engine = setup_engine().await; + // Two days old, so the entry lands in a bucket strictly before today's. + let (table, _item) = + ttl_table_with_expired_item(&engine, "TtlBucketRetire", 2 * 86_400 + 60).await; + + let metrics = MetricsCollector::new(); + assert!( + ttl_bucket_count( + &engine, + &table.key_info.account_id, + &table.key_info.table_id + ) + .await + > 0, + "precondition: the past bucket is registered" + ); + + // First sweep deletes the item; the second observes the drained partition. + extenddb_storage_cassandra::ttl_worker::sweep_once(&engine, &metrics).await; + extenddb_storage_cassandra::ttl_worker::sweep_once(&engine, &metrics).await; + + // Retirement is only correct if the partition really drained. Assert the + // item and its queue row are gone first, so a sweep that retired the + // registration while leaving work behind fails here rather than passing. + let mut key = extenddb_core::types::Item::new(); + key.insert( + "id".to_owned(), + extenddb_core::types::AttributeValue::S("drain".to_owned()), + ); + assert!( + extenddb_storage::DataEngine::get_item(&engine, &table.key_info, &key) + .await + .unwrap() + .is_none(), + "the expired item must have been deleted by the sweep" + ); + assert_eq!( + ttl_bucket_count( + &engine, + &table.key_info.account_id, + &table.key_info.table_id + ) + .await, + 0, + "a drained past bucket's registration must be retired" + ); +} + +#[tokio::test] +async fn test_ttl_sweep_removes_synchronous_gsi_entry() { + use extenddb_core::expression::{Expr, ExpressionMaps, KeyCondition, PathElement}; + use extenddb_core::metrics::MetricsCollector; + use extenddb_core::types::AttributeValue; + use extenddb_storage::DataEngine; + + let engine = setup_engine().await; + let table = + crate::helpers::TestTable::with_gsi(&engine, "TtlGsiCleanup", "StatusIndex", "status") + .await; + activate_tables(&engine).await; + engine + .session_arc() + .query_with_values( + &format!( + "UPDATE {}.indexes SET propagation_delay_ms = 0 \ + WHERE table_id = ? AND index_name = ?", + engine.catalog_keyspace() + ), + cdrs_tokio::query_values!(table.key_info.table_id.as_str(), "StatusIndex"), + ) + .await + .unwrap(); + engine + .update_ttl( + &table.key_info.account_id, + &table.key_info.table_name, + "expires_at", + true, + ) + .await + .unwrap(); + engine + .create_ttl_index( + &table.key_info.account_id, + &table.key_info.table_name, + "expires_at", + ) + .await + .unwrap(); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let mut item = std::collections::BTreeMap::new(); + item.insert("id".to_owned(), AttributeValue::S("ttl-gsi".to_owned())); + item.insert("status".to_owned(), AttributeValue::S("expired".to_owned())); + item.insert( + "expires_at".to_owned(), + AttributeValue::N((now - 10).to_string()), + ); + engine + .put_item( + &table.key_info, + item, + false, + None, + &ExpressionMaps::default(), + None, + ) + .await + .unwrap(); + + let condition = KeyCondition { + pk_path: vec![PathElement::Attribute("status".to_owned())], + pk_value: Expr::Placeholder(":status".to_owned()), + sk_condition: None, + extra_pk_conditions: Vec::new(), + extra_sk_conditions: Vec::new(), + }; + let mut maps = ExpressionMaps::default(); + maps.values.insert( + ":status".to_owned(), + AttributeValue::S("expired".to_owned()), + ); + assert_eq!( + engine + .query( + &table.key_info, + &condition, + &maps, + true, + None, + None, + Some("StatusIndex"), + ) + .await + .unwrap() + .0 + .len(), + 1 + ); + + extenddb_storage_cassandra::ttl_worker::sweep_once(&engine, &MetricsCollector::new()).await; + assert!( + engine + .query( + &table.key_info, + &condition, + &maps, + true, + None, + None, + Some("StatusIndex"), + ) + .await + .unwrap() + .0 + .is_empty() + ); +} + +#[tokio::test] +async fn test_ttl_enable_rejects_asynchronous_gsi() { + use extenddb_storage::error::StorageError; + + let engine = setup_engine().await; + let table = + crate::helpers::TestTable::with_gsi(&engine, "TtlAsyncGsi", "StatusIndex", "status").await; + activate_tables(&engine).await; + engine + .session_arc() + .query_with_values( + &format!( + "UPDATE {}.indexes SET propagation_delay_ms = 1000 \ + WHERE table_id = ? AND index_name = ?", + engine.catalog_keyspace() + ), + cdrs_tokio::query_values!(table.key_info.table_id.as_str(), "StatusIndex"), + ) + .await + .unwrap(); + + let result = engine + .update_ttl( + &table.key_info.account_id, + &table.key_info.table_name, + "expires_at", + true, + ) + .await; + assert!( + matches!(result, Err(StorageError::Validation(message)) if message.contains("asynchronously propagated GSIs")) + ); +} + +#[tokio::test] +async fn test_ttl_reconciles_same_expiry_after_queue_only_claim() { + use cdrs_tokio::types::IntoRustByName; + use extenddb_core::metrics::MetricsCollector; + use extenddb_core::types::{AttributeValue, Item}; + use extenddb_storage::DataEngine; + + let engine = setup_engine().await; + let table = crate::helpers::TestTable::new(&engine, "TtlQueueClaimRace", false).await; + activate_tables(&engine).await; + engine + .update_ttl( + &table.key_info.account_id, + &table.key_info.table_name, + "expires_at", + true, + ) + .await + .unwrap(); + engine + .create_ttl_index( + &table.key_info.account_id, + &table.key_info.table_name, + "expires_at", + ) + .await + .unwrap(); + + let expires_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64 + - 10; + let mut old = Item::new(); + old.insert("id".to_owned(), AttributeValue::S("queue-race".to_owned())); + old.insert( + "expires_at".to_owned(), + AttributeValue::N(expires_at.to_string()), + ); + old.insert("value".to_owned(), AttributeValue::S("old".to_owned())); + engine + .put_item( + &table.key_info, + old.clone(), + false, + None, + &Default::default(), + None, + ) + .await + .unwrap(); + + let generation = ttl_generation( + &engine, + &table.key_info.account_id, + &table.key_info.table_name, + ) + .await; + let keyspace = engine.account_keyspace(&table.key_info.account_id); + let bucket_rows = engine + .session_arc() + .query_with_values( + &format!( + "SELECT generation, bucket, shard FROM {keyspace}.ttl_expiration_buckets \ + WHERE table_id = ?" + ), + cdrs_tokio::query_values!(table.key_info.table_id.as_str()), + ) + .await + .unwrap() + .response_body() + .unwrap() + .into_rows() + .unwrap_or_default(); + let bucket_row = bucket_rows + .into_iter() + .find(|row| { + let row_generation: uuid::Uuid = row.get_r_by_name("generation").unwrap(); + row_generation == generation + }) + .expect("TTL bucket for active generation"); + let bucket: i64 = bucket_row.get_r_by_name("bucket").unwrap(); + let shard: i32 = bucket_row.get_r_by_name("shard").unwrap(); + let queue_row = engine + .session_arc() + .query_with_values( + &format!( + "SELECT expires_at, key_hash, key_data FROM {keyspace}.ttl_expirations \ + WHERE table_id = ? AND generation = ? AND bucket = ? AND shard = ?" + ), + cdrs_tokio::query_values!(table.key_info.table_id.as_str(), generation, bucket, shard), + ) + .await + .unwrap() + .response_body() + .unwrap() + .into_rows() + .unwrap_or_default() + .into_iter() + .next() + .expect("pending TTL entry"); + let queued_expiry: i64 = queue_row.get_r_by_name("expires_at").unwrap(); + let key_hash: String = queue_row.get_r_by_name("key_hash").unwrap(); + let key_data: String = queue_row.get_r_by_name("key_data").unwrap(); + let work_id = uuid::Uuid::new_v4(); + let work_data = serde_json::json!({ + "old_item": old, + "delete_timestamp_ms": std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64, + "stream": null + }) + .to_string(); + + let mut updated = old.clone(); + updated.insert("value".to_owned(), AttributeValue::S("new".to_owned())); + engine + .put_item( + &table.key_info, + updated, + false, + None, + &Default::default(), + None, + ) + .await + .expect("same-expiry update commits and records durable reconciliation work"); + assert!(ttl_outbox_count(&engine, &table.key_info.account_id).await > 0); + + // Reproduce the race endpoint: the ordinary batch committed with its older + // request timestamp, while a queue-only claim with old work data is newer. + engine + .session_arc() + .query_with_values( + &format!( + "UPDATE {keyspace}.ttl_expirations SET state = 'CLAIMED', work_id = ?, \ + work_data = ? WHERE table_id = ? AND generation = ? AND bucket = ? \ + AND shard = ? AND expires_at = ? AND key_hash = ? AND key_data = ?" + ), + cdrs_tokio::query_values!( + work_id, + work_data.as_str(), + table.key_info.table_id.as_str(), + generation, + bucket, + shard, + queued_expiry, + key_hash.as_str(), + key_data.as_str() + ), + ) + .await + .unwrap(); + + extenddb_storage_cassandra::ttl_worker::reconcile_pending_older_than(&engine, 1_000, -60) + .await + .expect("conflicting outbox reconciliation is retryable"); + assert!( + ttl_outbox_count(&engine, &table.key_info.account_id).await > 0, + "outbox must remain while old claimed queue work owns the key" + ); + + extenddb_storage_cassandra::ttl_worker::sweep_once(&engine, &MetricsCollector::new()).await; + extenddb_storage_cassandra::ttl_worker::reconcile_pending_older_than(&engine, 1_000, -60) + .await + .expect("reconcile current image after stale work retires"); + assert_eq!( + ttl_outbox_count(&engine, &table.key_info.account_id).await, + 0 + ); + extenddb_storage_cassandra::ttl_worker::sweep_once(&engine, &MetricsCollector::new()).await; + + let mut key = Item::new(); + key.insert("id".to_owned(), AttributeValue::S("queue-race".to_owned())); + assert!( + engine + .get_item(&table.key_info, &key) + .await + .expect("read eventually expired same-TTL update") + .is_none() + ); + + // Reproduce a stale old-timestamp delete leaving only the newer permanent + // TTL work owner. Recovery must release that exact owner before retiring work. + engine + .session_arc() + .query_with_values( + &format!( + "INSERT INTO {keyspace}.ttl_expirations \ + (table_id, generation, bucket, shard, expires_at, key_hash, key_data, \ + state, work_id, work_data) VALUES (?, ?, ?, ?, ?, ?, ?, 'CLAIMED', ?, ?)" + ), + cdrs_tokio::query_values!( + table.key_info.table_id.as_str(), + generation, + bucket, + shard, + queued_expiry, + key_hash.as_str(), + key_data.as_str(), + work_id, + work_data.as_str() + ), + ) + .await + .unwrap(); + let data_table = format!("items_{}", table.key_info.table_id.replace('-', "_")); + let stored_pk = + extenddb_storage::util::composite_pk_to_text(&key, &table.key_info.key_schema).unwrap(); + engine + .session_arc() + .query_with_values( + &format!( + "UPDATE {keyspace}.{data_table} SET prepared_txn_id = ?, \ + prepared_txn_timestamp = ? WHERE pk = ?" + ), + cdrs_tokio::query_values!( + work_id, + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64, + stored_pk.as_str() + ), + ) + .await + .unwrap(); + + extenddb_storage_cassandra::ttl_worker::sweep_once(&engine, &MetricsCollector::new()).await; + let mut recreated = key.clone(); + recreated.insert( + "expires_at".to_owned(), + AttributeValue::N( + (std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + + 3_600) + .to_string(), + ), + ); + engine + .put_item( + &table.key_info, + recreated.clone(), + false, + None, + &Default::default(), + None, + ) + .await + .expect("worker releases orphaned exact work owner before retiring queue work"); + // Prove the recreation is durable and unclaimed, not merely accepted. + let stored = engine + .get_item(&table.key_info, &key) + .await + .unwrap() + .expect("the recreated item must be durable"); + assert_eq!(stored.get("expires_at"), recreated.get("expires_at")); + assert_eq!( + base_row_owner(&engine, &table.key_info, &recreated).await, + None, + "the orphaned exact owner must have been released" + ); +} + +#[tokio::test] +async fn test_ttl_update_recreates_logically_absent_item() { + use extenddb_core::expression::{Expr, ExpressionMaps, PathElement, UpdateAction}; + use extenddb_core::types::{AttributeValue, Item}; + use extenddb_storage::DataEngine; + + let engine = setup_engine().await; + let table = crate::helpers::TestTable::new(&engine, "TtlUpdateAbsent", false).await; + activate_tables(&engine).await; + engine + .update_ttl( + &table.key_info.account_id, + &table.key_info.table_name, + "expires_at", + true, + ) + .await + .unwrap(); + engine + .create_ttl_index( + &table.key_info.account_id, + &table.key_info.table_name, + "expires_at", + ) + .await + .unwrap(); + + let mut key = Item::new(); + key.insert("id".to_owned(), AttributeValue::S("absent".to_owned())); + let future = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + + 3_600; + let actions = vec![ + UpdateAction::Set { + path: vec![PathElement::Attribute("value".to_owned())], + value: Expr::Placeholder("value".to_owned()), + }, + UpdateAction::Set { + path: vec![PathElement::Attribute("expires_at".to_owned())], + value: Expr::Placeholder("expires".to_owned()), + }, + ]; + let maps = ExpressionMaps::new( + std::collections::HashMap::new(), + std::collections::HashMap::from([ + ("value".to_owned(), AttributeValue::S("first".to_owned())), + ("expires".to_owned(), AttributeValue::N(future.to_string())), + ]), + ); + engine + .update_item( + &table.key_info, + &key, + &actions, + false, + false, + None, + &maps, + None, + ) + .await + .expect("TTL UpdateItem creates an absent row through an exact claim"); + // Without this the test would pass on a no-op first update followed by a + // no-op delete, which is the failure it exists to catch. + let created = engine + .get_item(&table.key_info, &key) + .await + .unwrap() + .expect("the first update must have created the item"); + assert_eq!( + created.get("value"), + Some(&AttributeValue::S("first".to_owned())) + ); + engine + .delete_item( + &table.key_info, + &key, + false, + None, + &Default::default(), + None, + ) + .await + .unwrap(); + assert!( + engine + .get_item(&table.key_info, &key) + .await + .unwrap() + .is_none(), + "the delete must have removed it before the recreation is exercised" + ); + + let recreate_maps = ExpressionMaps::new( + std::collections::HashMap::new(), + std::collections::HashMap::from([ + ("value".to_owned(), AttributeValue::S("second".to_owned())), + ("expires".to_owned(), AttributeValue::N(future.to_string())), + ]), + ); + engine + .update_item( + &table.key_info, + &key, + &actions, + false, + false, + None, + &recreate_maps, + None, + ) + .await + .expect("TTL UpdateItem recreates a row that retains partition metadata"); + let item = engine + .get_item(&table.key_info, &key) + .await + .unwrap() + .expect("recreated item exists"); + assert_eq!( + item.get("value"), + Some(&AttributeValue::S("second".to_owned())) + ); +} + +#[tokio::test] +async fn test_conditional_put_recreates_metadata_only_row() { + use extenddb_core::expression::{Expr, PathElement}; + use extenddb_core::types::{AttributeValue, Item}; + use extenddb_storage::DataEngine; + + let engine = setup_engine().await; + let table = crate::helpers::TestTable::new(&engine, "ConditionalPutMetadata", false).await; + activate_tables(&engine).await; + let mut item = Item::new(); + item.insert("id".to_owned(), AttributeValue::S("key".to_owned())); + item.insert("value".to_owned(), AttributeValue::S("old".to_owned())); + engine + .put_item( + &table.key_info, + item.clone(), + false, + None, + &Default::default(), + None, + ) + .await + .unwrap(); + let mut key = Item::new(); + key.insert("id".to_owned(), AttributeValue::S("key".to_owned())); + engine + .delete_item( + &table.key_info, + &key, + false, + None, + &Default::default(), + None, + ) + .await + .unwrap(); + + let (scanned, _) = engine + .scan(&table.key_info, None, None, None, None, None) + .await + .expect("scan must ignore a metadata-only physical row"); + assert!( + scanned.is_empty(), + "deleted logical items must not reappear in Scan" + ); + + item.insert("value".to_owned(), AttributeValue::S("new".to_owned())); + let condition = Expr::Function { + name: "attribute_not_exists".to_owned(), + args: vec![Expr::Path(vec![PathElement::Attribute("id".to_owned())])], + }; + engine + .put_item( + &table.key_info, + item, + false, + Some(&condition), + &Default::default(), + None, + ) + .await + .expect("conditional put treats metadata-only row as absent"); + let stored = engine + .get_item(&table.key_info, &key) + .await + .unwrap() + .expect("the conditional put must be durable"); + assert_eq!( + stored.get("value"), + Some(&AttributeValue::S("new".to_owned())), + "a surviving stale row would satisfy a bare is_some() check" + ); +} + +#[tokio::test] +async fn test_outbox_restores_bucket_for_existing_pending_row() { + use cdrs_tokio::types::IntoRustByName; + use extenddb_core::types::{AttributeValue, Item}; + use extenddb_storage::DataEngine; + + let engine = setup_engine().await; + let table = crate::helpers::TestTable::new(&engine, "TtlRestoreMissingBucket", false).await; + activate_tables(&engine).await; + engine + .update_ttl( + &table.key_info.account_id, + &table.key_info.table_name, + "expires_at", + true, + ) + .await + .unwrap(); + engine + .create_ttl_index( + &table.key_info.account_id, + &table.key_info.table_name, + "expires_at", + ) + .await + .unwrap(); + + let mut item = Item::new(); + item.insert("id".to_owned(), AttributeValue::S("bucket-race".to_owned())); + item.insert( + "expires_at".to_owned(), + AttributeValue::N( + (std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + + 3_600) + .to_string(), + ), + ); + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .unwrap(); + + let generation = ttl_generation( + &engine, + &table.key_info.account_id, + &table.key_info.table_name, + ) + .await; + let keyspace = engine.account_keyspace(&table.key_info.account_id); + let bucket_row = engine + .session_arc() + .query_with_values( + &format!( + "SELECT bucket, shard FROM {keyspace}.ttl_expiration_buckets \ + WHERE table_id = ? AND generation = ?" + ), + cdrs_tokio::query_values!(table.key_info.table_id.as_str(), generation), + ) + .await + .unwrap() + .response_body() + .unwrap() + .into_rows() + .unwrap_or_default() + .into_iter() + .next() + .unwrap(); + let bucket: i64 = bucket_row.get_r_by_name("bucket").unwrap(); + let shard: i32 = bucket_row.get_r_by_name("shard").unwrap(); + + // Manufacture the state left by a delayed timestamped write whose queue + // insert survived but whose bucket insert lost to a newer tombstone. + engine + .session_arc() + .query_with_values( + &format!( + "DELETE FROM {keyspace}.ttl_expiration_buckets WHERE table_id = ? \ + AND generation = ? AND bucket = ? AND shard = ?" + ), + cdrs_tokio::query_values!(table.key_info.table_id.as_str(), generation, bucket, shard), + ) + .await + .unwrap(); + assert_eq!( + ttl_bucket_count( + &engine, + &table.key_info.account_id, + &table.key_info.table_id + ) + .await, + 0 + ); + assert_eq!( + ttl_queue_row_count( + &engine, + &table.key_info.account_id, + &table.key_info.table_id, + generation, + bucket, + shard, + ) + .await, + 1 + ); + + extenddb_storage_cassandra::ttl_worker::reconcile_pending_older_than(&engine, 1_000, -60) + .await + .unwrap(); + assert_eq!( + ttl_bucket_count( + &engine, + &table.key_info.account_id, + &table.key_info.table_id + ) + .await, + 1, + "PENDING fast-path reconciliation must restore discoverability" + ); + assert_eq!( + ttl_outbox_count(&engine, &table.key_info.account_id).await, + 0 + ); +} + +#[tokio::test] +async fn test_inflight_repair_repeats_after_late_queue_destroy() { + use cdrs_tokio::types::IntoRustByName; + use extenddb_core::types::{AttributeValue, Item}; + use extenddb_storage::DataEngine; + + let engine = setup_engine().await; + let table = crate::helpers::TestTable::new(&engine, "TtlInflightRepair", false).await; + activate_tables(&engine).await; + engine + .update_ttl( + &table.key_info.account_id, + &table.key_info.table_name, + "expires_at", + true, + ) + .await + .unwrap(); + engine + .create_ttl_index( + &table.key_info.account_id, + &table.key_info.table_name, + "expires_at", + ) + .await + .unwrap(); + + let mut item = Item::new(); + item.insert( + "id".to_owned(), + AttributeValue::S("late-destroy".to_owned()), + ); + item.insert( + "expires_at".to_owned(), + AttributeValue::N( + (std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + + 3_600) + .to_string(), + ), + ); + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .unwrap(); + extenddb_storage_cassandra::ttl_worker::reconcile_pending_older_than(&engine, 1_000, -60) + .await + .unwrap(); + + let generation = ttl_generation( + &engine, + &table.key_info.account_id, + &table.key_info.table_name, + ) + .await; + let keyspace = engine.account_keyspace(&table.key_info.account_id); + let bucket_row = engine + .session_arc() + .query_with_values( + &format!( + "SELECT bucket, shard FROM {keyspace}.ttl_expiration_buckets \ + WHERE table_id = ? AND generation = ?" + ), + cdrs_tokio::query_values!(table.key_info.table_id.as_str(), generation), + ) + .await + .unwrap() + .response_body() + .unwrap() + .into_rows() + .unwrap_or_default() + .into_iter() + .next() + .unwrap(); + let bucket: i64 = bucket_row.get_r_by_name("bucket").unwrap(); + let shard: i32 = bucket_row.get_r_by_name("shard").unwrap(); + let mut key = Item::new(); + key.insert( + "id".to_owned(), + AttributeValue::S("late-destroy".to_owned()), + ); + let key_data = serde_json::to_string(&key).unwrap(); + let partition = (crc32fast::hash(key_data.as_bytes()) % 64) as i32; + let repair_id = uuid::Uuid::new_v4(); + engine + .session_arc() + .query_with_values( + &format!( + "INSERT INTO {keyspace}.ttl_repair_inflight_partitions \ + (worker_partition) VALUES (?)" + ), + cdrs_tokio::query_values!(partition), + ) + .await + .unwrap(); + engine + .session_arc() + .query_with_values( + &format!( + "INSERT INTO {keyspace}.ttl_repair_inflight \ + (worker_partition, repair_id, table_id, account_id, table_name, generation, key_data) \ + VALUES (?, ?, ?, ?, ?, ?, ?)" + ), + cdrs_tokio::query_values!( + partition, + repair_id, + table.key_info.table_id.as_str(), + table.key_info.account_id.as_str(), + table.key_info.table_name.as_str(), + generation, + key_data.as_str() + ), + ) + .await + .unwrap(); + + for pass in 0..2 { + // The second deletion models a destroy that commits after an earlier + // repair pass. Because the inflight marker is non-dischargeable, the + // next pass must restore both the row and its bucket again. + engine + .session_arc() + .query_with_values( + &format!( + "DELETE FROM {keyspace}.ttl_expirations WHERE table_id = ? \ + AND generation = ? AND bucket = ? AND shard = ?" + ), + cdrs_tokio::query_values!( + table.key_info.table_id.as_str(), + generation, + bucket, + shard + ), + ) + .await + .unwrap(); + engine + .session_arc() + .query_with_values( + &format!( + "DELETE FROM {keyspace}.ttl_expiration_buckets WHERE table_id = ? \ + AND generation = ? AND bucket = ? AND shard = ?" + ), + cdrs_tokio::query_values!( + table.key_info.table_id.as_str(), + generation, + bucket, + shard + ), + ) + .await + .unwrap(); + + assert!( + extenddb_storage_cassandra::ttl_worker::reconcile_inflight_repairs_once(&engine) + .await + .unwrap() + >= 1, + "the worker must process at least this test's inflight marker" + ); + assert_eq!( + ttl_queue_row_count( + &engine, + &table.key_info.account_id, + &table.key_info.table_id, + generation, + bucket, + shard, + ) + .await, + 1, + "repair pass {pass} must restore a queue row after a late destroy" + ); + assert_eq!( + ttl_bucket_count( + &engine, + &table.key_info.account_id, + &table.key_info.table_id + ) + .await, + 1 + ); + assert_eq!( + ttl_inflight_repair_count(&engine, &table.key_info.account_id).await, + 1, + "only the destroyer may discharge an inflight marker" + ); + } + + engine + .session_arc() + .query_with_values( + &format!( + "DELETE FROM {keyspace}.ttl_repair_inflight \ + WHERE worker_partition = ? AND repair_id = ?" + ), + cdrs_tokio::query_values!(partition, repair_id), + ) + .await + .unwrap(); +} + +#[tokio::test] +async fn test_outbox_limit_is_fair_across_partitions() { + let engine = setup_engine().await; + let table = crate::helpers::TestTable::new(&engine, "TtlOutboxFairness", false).await; + activate_tables(&engine).await; + let keyspace = engine.account_keyspace(&table.key_info.account_id); + + for partition in [0_i32, 63_i32] { + engine + .session_arc() + .query_with_values( + &format!( + "INSERT INTO {keyspace}.ttl_reconcile_pending \ + (worker_partition, id, table_id, account_id, table_name, key_data) \ + VALUES (?, now(), ?, ?, ?, ?)" + ), + cdrs_tokio::query_values!( + partition, + "missing-table-id", + table.key_info.account_id.as_str(), + format!("missing-{partition}"), + "{}" + ), + ) + .await + .unwrap(); + } + + assert!( + extenddb_storage_cassandra::ttl_worker::reconcile_pending_older_than(&engine, 1, -60) + .await + .unwrap() + >= 2, + "a soft global limit must still give both edge partitions progress" + ); + for partition in [0_i32, 63_i32] { + let rows = engine + .session_arc() + .query_with_values( + &format!( + "SELECT id FROM {keyspace}.ttl_reconcile_pending \ + WHERE worker_partition = ?" + ), + cdrs_tokio::query_values!(partition), + ) + .await + .unwrap() + .response_body() + .unwrap() + .into_rows() + .unwrap_or_default(); + assert!(rows.is_empty(), "partition {partition} must not starve"); + } +} + +#[tokio::test] +async fn test_non_ttl_put_does_not_enqueue_ttl_reconciliation() { + use extenddb_core::types::{AttributeValue, Item}; + use extenddb_storage::DataEngine; + + let engine = setup_engine().await; + let table = crate::helpers::TestTable::new(&engine, "NonTtlPutFastPath", false).await; + activate_tables(&engine).await; + let mut item = Item::new(); + item.insert("id".to_owned(), AttributeValue::S("key".to_owned())); + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .unwrap(); + assert_eq!( + ttl_outbox_count(&engine, &table.key_info.account_id).await, + 0, + "non-TTL PutItem must not create permanent TTL journal traffic" + ); +} + +#[tokio::test] +async fn test_ttl_enabled_table_rejects_new_async_gsi() { + use extenddb_core::types::{ + AttributeDefinition, CreateGsiAction, GlobalSecondaryIndexUpdate, KeySchemaElement, + KeyType, Projection, ProjectionType, ScalarAttributeType, UpdateTableInput, + }; + use extenddb_storage::TableEngine; + use extenddb_storage::error::StorageError; + + let engine = setup_engine().await; + let table = crate::helpers::TestTable::new(&engine, "TtlRejectLaterGsi", false).await; + activate_tables(&engine).await; + engine + .update_ttl( + &table.key_info.account_id, + &table.key_info.table_name, + "expires_at", + true, + ) + .await + .unwrap(); + engine + .create_ttl_index( + &table.key_info.account_id, + &table.key_info.table_name, + "expires_at", + ) + .await + .unwrap(); + + let result = engine + .update_table( + &table.key_info.account_id, + UpdateTableInput { + table_name: table.key_info.table_name.clone(), + billing_mode: None, + table_throughput_mode: None, + provisioned_throughput: None, + deletion_protection_enabled: None, + global_secondary_index_updates: Some(vec![GlobalSecondaryIndexUpdate { + create: Some(CreateGsiAction { + index_name: "StatusIndex".to_owned(), + key_schema: vec![KeySchemaElement { + attribute_name: "status".to_owned(), + key_type: KeyType::Hash, + }], + projection: Projection { + projection_type: ProjectionType::All, + non_key_attributes: None, + }, + provisioned_throughput: None, + }), + update: None, + delete: None, + }]), + attribute_definitions: Some(vec![ + AttributeDefinition { + attribute_name: "id".to_owned(), + attribute_type: ScalarAttributeType::S, + }, + AttributeDefinition { + attribute_name: "status".to_owned(), + attribute_type: ScalarAttributeType::S, + }, + ]), + stream_specification: None, + table_class: None, + on_demand_throughput: None, + vector_index_updates: None, + }, + ) + .await; + assert!(matches!( + result, + Err(StorageError::Validation(message)) + if message.contains("asynchronously propagated GSI") + )); +} diff --git a/docs/adr/0010-cassandra-ttl-expiration-queue.md b/docs/adr/0010-cassandra-ttl-expiration-queue.md new file mode 100644 index 00000000..4c8b9522 --- /dev/null +++ b/docs/adr/0010-cassandra-ttl-expiration-queue.md @@ -0,0 +1,169 @@ +# ADR-0010: Durable sharded expiration queue for Cassandra TTL + +**Status:** Accepted +**Date:** 2026-09-01 + +## Context + +DynamoDB TTL is more than storage expiry. Removing an expired item must also remove its secondary-index rows and emit a Streams `REMOVE` record whose identity is the DynamoDB service. Cassandra native TTL deletes a base row internally, bypassing both effects, and an item stored as JSON cannot be efficiently searched by an arbitrary configured TTL attribute. + +Cassandra also cannot atomically combine a Paxos condition on the base item with queue, index, and stream mutations in other partitions: a batch may carry conditions for at most one partition. The PostgreSQL backend is therefore a semantic reference, not an implementation whose ACID internals can be reproduced exactly. + +## How it works + +A customer calls `UpdateTimeToLive` naming an attribute. We record the attribute on the table along with a fresh generation UUID, mark the table not-ready, and then scan it. Every item carrying a valid timestamp in that attribute gets a row in an expiration queue, and only once the scan finishes do we mark the generation ready. Until then no sweep will touch the table, so a half-built queue never deletes anything. The generation is what makes disable and re-enable safe: turning TTL off retires the old generation, turning it back on allocates a new one, and work from the old generation can never be mistaken for work in the new one. + +The queue lives in the customer's own keyspace and is two tables. `ttl_expirations` holds one row per item that has a timestamp, partitioned by table, generation, the day the item expires, and one of 64 shards derived from the item's key, then ordered by expiration time. `ttl_expiration_buckets` records which of those partitions exist, so a sweep can find the ones that are due without hunting for them. Partitioning this way is what lets a sweep read the queue with complete partition keys and an ordered range, rather than filtering. + +While TTL is on, writes keep the queue in step with the items. A Put or Update that changes an item's timestamp deletes the queue row for the old timestamp and inserts one for the new, and it does that in the same logged batch as the item write itself, so the two land together. A Delete removes the queue row the same way. Because a batch is not a transaction — and because the sweeper's own conditional writes to the queue can supersede or erase the batch's queue mutation — a write whose item carries a timestamp also puts a small key-only row into a durable outbox in that same batch. A background pass reads the outbox, quorum-reads the item and table metadata, restores the bucket registration, and makes sure the queue row matches before removing the outbox record. Before the sweeper conditionally destroys any active-generation queue row, it writes a separate non-dischargeable inflight repair marker at quorum. After a definitive true or false LWT response it writes a normal outbox record at quorum and only then removes the marker. An ambiguous error or crash therefore leaves the marker behind; a dedicated repair worker reconciles every marker while its recorded table generation remains active, so even a destroy that lands arbitrarily late is repaired by a later pass. Quorum metadata proving the table or generation retired makes the marker terminal and safe to remove. Transactional writes reconcile after commit rather than inside the batch, using the image captured before the transaction ran. + +Writes on a TTL-enabled table also take a short-lived claim on the item's own row, by setting a `prepared_txn_id` with a conditional update. That claim is what stops a write and an expiration from stepping on each other. If a writer finds the claim already held it waits briefly and re-reads rather than failing, so two writers to the same key still behave as last-writer-wins. Releasing the claim costs nothing extra on the request: the write's own batch clears the claim columns at its pinned timestamp, and a detached, bounded background task performs the exact conditional release to cover the case where the local clock trails the coordinator's. + +A worker on every host wakes once a minute and, per table, tries to take a lease. One host wins, and it asks the bucket table which partitions are due, then reads up to a hundred rows whose expiration time has passed. For each one it re-reads the item. If the item is gone, or its timestamp has moved into the future, or the attribute has been removed, there is nothing to expire — the queue row is retired and, where relevant, replaced with one matching the current item. That is the check that stops a renewed item from being deleted by work queued before it was renewed. + +If the item really is expired, the worker moves the queue row through the remaining states, and the states exist so that a crash at any point can be resumed rather than guessed at. It first claims the row, writing into it the exact image of the item it intends to delete, a fixed delete timestamp, and — if the table has a stream — the event ID and sequence number it will use, decided now rather than at write time. It then takes the claim on the item's row. Immediately before effects, it renews the exact generation-bound sweep lease, seals the exact base owner without TTL, and durably moves the queue row to `EFFECTS_APPLYING`. From that point recovery must go forward: it deletes secondary-index rows and writes the deterministic `REMOVE`, advances to `EFFECTS_APPLIED`, then deletes the item conditional on both the owner and recorded image. Finally it deletes the queue row. + +The ordering matters in one direction in particular. Once `EFFECTS_APPLYING` is durable, effects may already be partially visible, so the exact base owner is non-expiring and work has to be replayed through completion — otherwise a live item can be left with a missing index or published `REMOVE`. Everything before that boundary can be safely walked away from, because nothing outside the queue has changed yet. Retiring a generation follows the same rule: it drops queue rows that were only pending, releases and abandons rows that were merely claimed, and pushes applying or applied rows through to completion. + +## Options considered + +### Cassandra native TTL + +Operationally simple, but Cassandra would remove the base row without cleaning ExtendDB's secondary-index tables or writing the DynamoDB-compatible service `REMOVE` stream record. It does not meet the external contract. + +### A backend-wide durable mutation protocol + +Every Put, Update, Delete, transaction, index mutation, and stream mutation could be represented as one durable operation with a commit token and replay state. That is the most complete answer to Cassandra's cross-partition atomicity limits, and it would also improve non-TTL failure handling. + +It is also a much larger storage-engine redesign. It changes every write path, adds permanent journal traffic and recovery machinery to tables that do not use TTL, and needs its own migration, capacity, compaction, and operational model. Shipping that redesign as part of TTL would make the first Cassandra TTL release harder to review and risk unrelated behavior that already works. + +### A TTL-specific durable state machine + +This keeps the durable protocol at the boundary that needs it: TTL queue reconciliation and expiration deletion. Once TTL is enabled, ordinary writes take the same exact row claim used by deletion and commit the base row, TTL queue/outbox, synchronous index changes, and stream record in one logged batch. The deletion worker persists its own retry phases and deterministic stream identity. + +This does not solve every Cassandra write-path limitation. In particular, a write that started before TTL was enabled cannot be retroactively joined to the new TTL generation. Enabling TTL on a table that is already serving writes therefore requires those writes to be briefly quiesced until enable/backfill completes. New tables can enable TTL before accepting traffic and do not have this transition window. + +We chose this option because it gives the supported TTL lifecycle a bounded, testable recovery protocol without pretending to solve the whole backend. It leaves the broader mutation journal as a separate design that can be evaluated on its own merits. + +## Decision + +Use a generation-fenced, durable TTL-specific state machine. + +* Maintain `ttl_expiration_buckets` and `ttl_expirations` in every account keyspace. Queue partitions use table ID, TTL-enable generation, day bucket, and one of 64 deterministic key shards. Entries are ordered by expiration time and key. +* Accept only positive integral DynamoDB `N` values as epoch seconds. Missing, non-numeric, fractional, zero, and negative values are not queued. +* Allocate a fresh generation on every enable. Backfill the active generation synchronously and publish `ttl_index_ready` only after reconciliation. Disable, re-enable, cleanup, and sweeps are fenced by the exact attribute and generation. +* Before enabling TTL on a table that is already accepting writes, quiesce writes that began under the disabled generation until enable and synchronous backfill return. Once enabled, all ordinary writes enter the exact-claim logged-batch path. Enabling TTL before opening a new table to traffic avoids this operational step. +* Record ordinary Put/Update reconciliation in a durable key-only `ttl_reconcile_pending` outbox in the same logged batch as the base mutation, whenever the written item carries a valid timestamp. The worker quorum-reads the committed item and authoritative table identity/configuration, restores the bucket registration at quorum before accepting an existing `PENDING` row, and prefers a plain quorum queue read over Paxos when the entry is already present. A conflict with claimed TTL work remains retryable; the outbox is removed at quorum only after reconciliation succeeds. Symmetrically, every conditional destroy of an active-generation queue row first writes a unique `ttl_repair_inflight` marker at `LOCAL_QUORUM`. After a definitive applied true/false response, the destroyer writes a successor normal outbox record at quorum and only then deletes the inflight marker at quorum. Ambiguous errors and crashes retain the non-dischargeable marker, which the dedicated repair worker repeatedly reconciles while its recorded table generation remains active; a late destroy is therefore repaired on a later pass without relying on wall-clock age or skew. Quorum proof that the table or generation retired makes the marker safe to remove. +* Reconcile transaction Put/Update/Delete results against the pre-commit image before deleting the recovery ledger, so a transactional write retires the entry the item had before it as well as registering the new one. COMMITTING recovery repeats reconciliation from the persisted ledger payload, which holds only the committed image (see *Known gaps*). +* Represent deletion work as `PENDING`, `CLAIMED`, `CLAIM_ABORTING`, `EFFECTS_APPLYING`, and `EFFECTS_APPLIED`. A claim persists the old item image, a stable delete timestamp, a work UUID, and an optional deterministic stream plan. A stale-item or lifecycle abort first wins `CLAIMED` → `CLAIM_ABORTING` on the queue row, then releases the base owner and retires work; a sweep must instead win `CLAIMED` → `EFFECTS_APPLYING` after sealing the exact owner. The mutually exclusive LWTs prevent either side from clearing the other's fence. +* Serialize TTL deletion with ordinary and transactional writes through the base row's `prepared_txn_id` LWT field at `LOCAL_QUORUM`/`LOCAL_SERIAL`. Ordinary request claims and worker claims before the must-complete boundary are time-bounded. Immediately before `EFFECTS_APPLYING`, the worker seals its exact owner without TTL so it cannot lose the fence while effects are suspended; durable queue recovery owns completing and releasing that owner. A request's logged batch is stamped with a timestamp pinned immediately after its claim, so a request that resumes after its claim expired loses to any owner that committed in the meantime rather than overwriting it. +* Treat contention for that claim as contention, not as a client error. A blocked writer retries against a freshly read image on a jittered backoff, and only reports `TransactionConflictException` once those retries are exhausted, per RFC-0003 §4.3. Ordinary concurrent writes to one key therefore remain last-writer-wins. +* Seal the exact base owner without TTL, transition the queue row to `EFFECTS_APPLYING`, then apply synchronous index deletion and the deterministic stream write. Recovery and retired-generation cleanup treat this as must-complete and reapply effects idempotently. Transition to `EFFECTS_APPLIED`, delete the base row with an LWT requiring the exact work UUID and item image, and finally conditionally complete the queue work. +* Read at `LOCAL_QUORUM` wherever an empty or absent result is treated as authoritative and acted on destructively — retiring queue work, dropping an outbox row, or retiring a bucket registration. The default read consistency is `ONE`, at which a lagging replica reads as absent; acting on that would leave an item with no expiration entry and therefore never expiring. Claim and delete LWTs re-verify the exact image and so tolerate a stale read, but decisions about *absence* have nothing to condition on. +* Persist stream event ID, sequence number, timestamp, region, and view type. Retrying side effects overwrites the same stream row rather than creating a second externally visible `REMOVE` event. +* If a stale queue snapshot differs from the current item, conditionally retire that exact work before reconciling the current image. If an old-timestamp delete leaves no item but the permanent work owner survives, recovery conditionally releases that exact UUID before retiring or completing the queue row. +* Use a generation-bound table sweep lease and renew it while processing. TTL lifecycle changes and index creation that could invalidate a live sweep take the same lease, retry briefly when a sweep holds it, and report `ResourceInUseException` only after that. +* Retire a generation by *draining* it, never by deleting its rows outright. Cleanup removes only `PENDING` rows; a `CLAIMED` row must win `CLAIM_ABORTING` before releasing its owner and retiring, while `EFFECTS_APPLYING` work replays effects and advances to `EFFECTS_APPLIED`, whose base delete is then completed. The `ttl_cleanup_generation` marker is cleared only when nothing is left, so a partial pass is retried. +* Retire a `(day bucket, shard)` registration once its partition is observed empty and its day is fully past, using a delete stamped with a timestamp taken before that observation so that any concurrent queue insert wins. Bound the number of registry partitions one sweep cycle visits. +* Reject TTL enable when a table has a GSI whose effective propagation delay is nonzero. The current asynchronous GSI queue has no version-conditional replay fence, so admitting that combination could let an old TTL delete overtake a recreated item's insert. Base tables, LSIs, and synchronous GSIs are supported. + +## Recovery model + +Each phase is idempotent and is entered only from durable state. `work_id` is the exact owner UUID; every transition is conditional on it. + +| Crash point | Durable state | Recovery action | +| --- | --- | --- | +| Before the queue row is claimed | `PENDING` | Re-read the item. Expired and unchanged: claim it. Renewed, changed, or gone: retire this exact entry and re-register the current image. | +| After `CLAIMED`, before must-complete intent | `CLAIMED` + old image + stream plan | Re-read. Image matches: re-take the bounded claim. Image differs or item gone: release the exact claim and abandon the work; nothing externally visible happened. | +| After sealing the owner, before/during effects | `EFFECTS_APPLYING` + non-expiring exact base owner | Re-apply effects. Index deletes are idempotent and the stream write reuses the persisted event identity, then advance to `EFFECTS_APPLIED`. Lifecycle cleanup follows the same must-complete rule. | +| After `EFFECTS_APPLIED`, before the base delete | `EFFECTS_APPLIED` | Complete the base delete under the exact owner and image. This is the one phase that must go forward: index rows are already removed, so abandoning it would leave a live item with a missing index. Generation cleanup honours the same rule. | +| After the base delete, before completion | Base row gone, `EFFECTS_APPLIED` | Complete the queue row conditionally on the exact owner. | +| Ordinary request suspended past its claim | Claim expired; the resumed batch may still land | The batch is stamped before the suspension, so its cells lose to any newer owner's Paxos cells — except the item cells, which can beat the older image under a sealed owner. `EFFECTS_APPLYING` recovery is therefore fenced on the owner alone, and a changed image is handled by the post-effects path: index rows the replay tombstoned are rebuilt from the live image before the sealed owner is released. | +| TTL disabled mid-flight | `ttl_cleanup_generation` set | Drain the generation per the rule above, then remove its `PENDING` rows; retry until empty. | + +## Consequences + +### Positive + +* Expiration lookup uses complete Cassandra partition keys and an ordered clustering range; it does not require `ALLOW FILTERING`. +* Renewed or recreated items cannot be deleted by stale TTL work. +* Worker crashes recover from durable queue state, including crashes after side effects but before the exact base delete. +* Stream retries retain one externally visible service-identified `REMOVE` record. +* Synchronous index rows are removed before final queue completion. +* Queue rebuilds, lifecycle changes, and cleanup are isolated by TTL-enable generation, and no lifecycle change can strand claimed work. +* No pre-effects claim is unbounded. After the must-complete boundary, a non-expiring exact owner is paired with durable queue state whose recovery and lifecycle paths complete the delete and release it. + +### Negative + +* This is practical DynamoDB TTL behavior, not strict PostgreSQL-style ACID equivalence. Index and stream effects become durable immediately before the final exact base delete, so a brief internal visibility gap is possible while writers remain claim-blocked. +* TTL-enabled writes cost more than non-TTL writes: one extra catalog read, one request-path claim LWT, a detached exact-release LWT, and a logged batch instead of a single statement. Non-TTL tables keep the existing fast paths. +* TTL cannot currently be enabled on a table with asynchronously propagated GSIs. Supporting that combination requires versioned, conditionally applied GSI mutations or an equivalent causal replay protocol. +* Enable performs a synchronous table scan under the table's control lease, and the `UpdateTimeToLive` call awaits it. Very large tables will require a durable, checkpointed background backfill before this path is suitable at scale. +* Expiration throughput is bounded by design: one exclusive sweep lease per table, one bounded batch per scan interval. The resulting rate matches the other backends; the lease removes the duplicated work they do rather than reducing throughput. +* LWT claims and lifecycle leases add Cassandra coordination cost to TTL-enabled tables. +* A destroy whose result remains ambiguous leaves a `ttl_repair_inflight` marker intentionally. A dedicated worker rechecks active-generation markers and safely removes markers only after quorum metadata proves their table or generation retired; operators must monitor persistent marker count/age and investigate active-generation entries. +* Cassandra coordinators and ExtendDB hosts must have synchronized clocks, and data-plane requests must have deadlines well below the request claim lifetime. +* Claims and the exact base delete condition on `item_data` string equality, so the persisted JSON encoding of an item is part of the protocol. A unit test pins that encoding as canonical; a version column would be a more robust fence and is a candidate follow-up. + +### Operating envelope + +This version is suitable for production when the deployment stays inside the supported envelope: + +* enable TTL before opening a new table to writes, or briefly quiesce an existing table while enable/backfill completes; +* use no GSI with a nonzero propagation delay on a TTL-enabled table; +* serve writes for a given table from one Cassandra datacenter, because all claims and lifecycle LWTs use `LOCAL_QUORUM`/`LOCAL_SERIAL` and are therefore linearizable only within a datacenter; +* stay under roughly 100 expirations per table per minute. This is the shared TTL worker's rate, not a Cassandra property — every backend uses a 100-item batch per 60-second cycle and none of them gain throughput from a larger fleet (see *Parity with the PostgreSQL backend*). A table expiring faster than that accumulates backlog; +* size and monitor the expiration backlog, Cassandra LWT latency, and worker health; +* keep Cassandra coordinators and ExtendDB hosts time-synchronized, because request batches use explicit timestamps, with request deadlines well below the request claim lifetime; and +* accept DynamoDB-style eventual expiration plus the documented brief internal effects-before-delete window. + +Within that envelope, item renewal, crash recovery, generation changes, synchronous index cleanup, transaction reconciliation, ordinary write contention, and deterministic stream removal are covered by durable state and real-Cassandra tests. This is not yet a claim of unrestricted DynamoDB parity for every Cassandra table configuration. + +### Parity with the PostgreSQL backend + +The bar for this feature is the production readiness of the PostgreSQL TTL implementation. Most of what looks like a Cassandra limitation is in fact the shared TTL design, and it is worth separating the two so that reviewers and operators know which items are Cassandra's to answer. + +**Shared with PostgreSQL — same behaviour, same code path or same shared handler:** + +| Behaviour | Evidence | +| --- | --- | +| ~100 expirations per table per minute, and no increase with fleet size | Both backends use `SCAN_INTERVAL = 60s` and `BATCH_SIZE = 100`. PostgreSQL runs without a lease but every host issues the same `ORDER BY ttl LIMIT 100` query, so hosts contend for the same rows and the loser's delete fails its TTL condition. | +| `UpdateTimeToLive` blocks instead of returning immediately | The shared handler awaits `create_ttl_index` on every backend. | +| No `ENABLING`/`DISABLING` status | `TimeToLiveStatus` has only two variants; both backends derive status from catalog presence. | +| No five-year cutoff on old timestamps | PostgreSQL matches on `BETWEEN 1 AND now`; Cassandra accepts any positive `i64`. | +| TTL deletion bypasses write-capacity accounting and throttling | Both workers call the storage layer directly, below the request capacity path. | +| No caching of TTL configuration | Neither backend caches it. Cassandra pays a per-write catalog read because it needs the configuration on the write path at all; PostgreSQL does not need it, because expiry is derived from the item by a database index. | + +**Where Cassandra is stricter than PostgreSQL:** + +| Behaviour | Detail | +| --- | --- | +| Fractional TTL values | Cassandra ignores `N: "1.5"`. PostgreSQL casts the stored text to `BIGINT` in both the expression index and the sweep query, so a fractional value can raise a runtime error rather than being ignored. | +| Backfill restart safety | A failed Cassandra backfill loses only its scan position; entries already registered survive because inserts are conditional. A failed PostgreSQL `CREATE INDEX CONCURRENTLY` can leave an invalid index of the same name, after which `IF NOT EXISTS` no-ops and `ttl_index_ready` is set anyway. | +| Duplicate sweep work | Cassandra's per-table lease means the work is done once. PostgreSQL hosts each read the same candidate rows every cycle. | + +**Genuinely Cassandra-specific, and why:** + +| Gap | Cause | Fixable here? | +| --- | --- | --- | +| Deletion is a crash-recoverable saga, not one atomic transaction, so a brief internal effects-before-delete window exists | Cassandra cannot combine a Paxos condition on the base row with mutations in other partitions. PostgreSQL does base delete, index cleanup, stream record, and async-index enqueue in one `BEGIN`/`COMMIT`. | No — this is the fundamental blocker. It is what the queue, the claims, and the state machine exist to compensate for. | +| TTL cannot be enabled alongside an asynchronously propagated GSI | The async GSI queue has no version-conditional replay fence, so an old TTL delete could overtake a recreated item's insert. PostgreSQL enqueues async GSI cleanup inside the delete transaction, so it has no such race. | No — needs versioned, conditionally applied GSI mutations. This is the one *functional* restriction relative to PostgreSQL. | +| Enabling TTL on a live table needs writes quiesced | Expiry is derived from a durable queue that must be backfilled, and a write that began before enable cannot join the new generation. PostgreSQL's `CREATE INDEX CONCURRENTLY` covers live writes with no transition window. | No — needs a durable background backfill that admits pre-enable writes. | +| Writes on a TTL-enabled table cost an extra catalog read, one request-path LWT, a detached exact-release LWT, and a logged batch | The claim protocol is on the write path; the exact release is bounded background work. PostgreSQL writes touch nothing TTL-related. | Partly — the claim's marginal value is now small enough that removing it from the ordinary write path is a live proposal. | +| Writes for a table must be served from one datacenter | All claims and lifecycle LWTs use `LOCAL_QUORUM`/`LOCAL_SERIAL`. | No — global serial consistency would cost cross-region Paxos on every write. | +| The claim and exact delete fence on `item_data` string equality | There is no version column to condition on. | Yes, as a follow-up: add a monotonic version column. | +| Transaction *recovery* reconciles insert-only | The ledger does not persist the pre-commit image. | Yes, as a follow-up: persist it. | + +The summary: with this change, Cassandra TTL matches PostgreSQL on every shared behaviour, is stricter on two, and differs on a set of items that all trace back to the absence of cross-partition atomic conditional writes. The only *functional* capability PostgreSQL has and Cassandra does not is TTL alongside asynchronously propagated GSIs. + +### Known gaps + +Deliberately out of scope for this change, in rough priority order: + +* **Expiration throughput does not scale horizontally.** The sweep lease is per table even though the queue is already sharded 64 ways by key and those shards are disjoint partitions. Leasing per `(table, shard)` would allow up to 64 concurrent workers per table with no change to the claim protocol, because every queue transition is already conditional on the exact work UUID. This is the highest-value follow-up, and it would take Cassandra past the PostgreSQL rate rather than merely matching it. +* **The backfill has no durable cursor.** A failure restarts the scan from the beginning, and the `UpdateTimeToLive` call blocks for its duration instead of reporting `ENABLING`. +* **TTL alongside asynchronously propagated GSIs**, per the table above. +* **`item_data` equality as the fence.** A monotonic version column would remove the dependency on a stable JSON encoding and stop shipping whole items as LWT condition values. +* **Transaction recovery reconciles insert-only**, so a transaction that crashes between COMMIT and reconciliation can leave the item's previous queue entry behind until it comes due. It is inert — the worker revalidates the item before deleting anything — but it is queue garbage. +* **Catalog reads are uncached.** TTL configuration is read on every write, on top of the index read the write path already performs. +* **Recovery-path test coverage.** The transitions this change introduced — draining a retired generation, retiring a drained bucket registration, an expired worker claim — are reasoned about but not yet covered by tests. diff --git a/docs/adr/README.md b/docs/adr/README.md index 568ce0f2..ff627acc 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -36,3 +36,4 @@ decision, write a new ADR. | [0004](0004-vector-search-exact-scan.md) | Vector search is an exact scan over one row per vector | Accepted | | [0005](0005-index-build-lifecycle-ownership.md) | Index-build lifecycle stays in the backend until a second backend needs it | Accepted | | [0006](0006-pgvector-storage-and-scoring.md) | Vector storage on PostgreSQL uses pgvector's type, and the engine decides what it cannot compute | Accepted | +| [0010](0010-cassandra-ttl-expiration-queue.md) | Durable sharded expiration queue for Cassandra TTL | Accepted | \ No newline at end of file diff --git a/docs/differences-from-dynamodb.md b/docs/differences-from-dynamodb.md index da6a00f7..25d4bbae 100755 --- a/docs/differences-from-dynamodb.md +++ b/docs/differences-from-dynamodb.md @@ -50,8 +50,19 @@ adaptation when switching between ExtendDB and the real service. | Area | DynamoDB | ExtendDB | |------|----------|------| | TTL attribute name | Any UTF-8 string (1–255 bytes) | Restricted to `[a-zA-Z0-9._-]+` (1–255 bytes). Names with spaces, quotes, or other special characters are rejected. This eliminates SQL injection risk in the TTL expression index. | -| TTL deletion | Background process, items deleted within 48 hours of expiry | Background worker with indexed sweep, configurable target via `ttl_deletion_target_seconds` (default: 300s) | +| TTL deletion | Background process, items deleted within 48 hours of expiry | Background worker with an indexed sweep. All backends use the same fixed 60-second interval and 100-item batch; neither is configurable. | | TTL stream records | REMOVE events with `userIdentity: {type: "Service", principalId: "dynamodb.amazonaws.com"}` | Supported — TTL deletions generate REMOVE stream records with the same `userIdentity` | +| TTL timestamps far in the past | Ignored if more than five years before the current time | No cutoff. Any positive epoch-second value is queued and expired, however old. | +| TTL attribute value validation | Non-conforming values are ignored | Cassandra matches: only a positive integral `N` is expired, and missing, non-numeric, fractional, zero, and negative values are ignored. | +| `UpdateTimeToLive` response timing | Returns immediately; the change takes effect asynchronously (up to about an hour) | All backends await readiness before returning, so the call can be slow on a large table and can exceed a client timeout. PostgreSQL awaits a concurrent index build; Cassandra awaits a full-table backfill of the expiration queue, which has no durable cursor and restarts on the next worker cycle if it fails. | +| `DescribeTimeToLive` transitional states | `ENABLING` and `DISABLING` are reported while a change settles | Only `ENABLED` and `DISABLED`. A table reports `ENABLED` as soon as the attribute is set, including while its queue is still being backfilled and nothing will expire yet. | +| Concurrent same-key writes on a TTL-enabled table | Unaffected by TTL | Serialized through a base-row claim. Contention is retried internally against a re-read image, so writes stay last-writer-wins; only sustained contention surfaces `TransactionConflictException`. | +| TTL-enabled write cost | Unaffected by TTL | An extra catalog read (overlapped with the index read) and a claim LWT, with the base write becoming a logged batch. The release is folded into the batch, with an exact conditional release performed off the request path. Non-TTL tables keep the existing fast paths. | +| Cassandra TTL with asynchronous GSIs | Supported | Not currently supported. The Cassandra backend rejects TTL enable when any GSI has a nonzero effective propagation delay, and rejects creating such a GSI on a TTL-enabled table; base tables, LSIs, and synchronous GSIs are supported. See [ADR-0010](adr/0010-cassandra-ttl-expiration-queue.md). | +| Enabling Cassandra TTL on a live table | No application write quiescence required | Writes that began while TTL was disabled must be quiesced until enable and synchronous backfill complete. Prefer enabling TTL before opening a new table to traffic. Once enabled, writes use the durable exact-claim path. | +| TTL expiration throughput | Managed by the service | About 100 items per table per minute on every backend — a fixed 100-item batch per 60-second cycle. This does not increase with fleet size on any backend: PostgreSQL hosts sweep without a lease but each runs the same `ORDER BY ttl LIMIT 100` query and therefore contend for the same rows, while Cassandra takes an exclusive per-table sweep lease and does the same work once. A table expiring faster than this accumulates backlog. | +| Cassandra TTL deployment bounds | Managed by the service | Writes for a table must be served from one Cassandra datacenter — claims and lifecycle LWTs use `LOCAL_QUORUM`/`LOCAL_SERIAL` and are linearizable only within a datacenter. Cassandra coordinators and ExtendDB hosts must remain time-synchronized because request batches use explicit timestamps, and request deadlines must stay well below the 120-second request claim lifetime. A write whose queue mutation loses an internal race is repaired by the background outbox; an ambiguous queue destroy retains a non-dischargeable marker that is rechecked every worker pass. | +| Cassandra TTL lifecycle contention | Not observable | Enabling or disabling TTL, and creating an index that would invalidate a live sweep, take a short lease and retry briefly. Sustained collision reports `ResourceInUseException` with a retry hint. | | TTL modification cooldown | Enforces a cooldown period between enable/disable changes ("Time to live has been modified multiple times within a fixed interval") | No cooldown — TTL can be enabled and disabled immediately. Intentional divergence for faster local development. | ## Tagging From e289bda1059484d0128670a4bad26c979c86a47d Mon Sep 17 00:00:00 2001 From: Joel Shepherd Date: Wed, 9 Sep 2026 22:17:38 +0000 Subject: [PATCH 15/48] fix(cassandra): fix transaction concurrency bugs and add GSI backfill Transaction fixes: - fetch_item_for_transaction: also read prepared_txn_id; if a foreign transaction owns the row, block via wait_for_commit_and_read instead of returning the PREPARE-state placeholder (which caused condition expressions to evaluate against stale key-only data and ALL_OLD to be None) - Add skip_txn_id parameter to fetch_item_for_transaction so commit/rollback paths don't deadlock waiting on their own PREPARE row - Add prepare-phase retry loop for Put/Update: on TransactionConflict, wait for the winning transaction to commit then re-evaluate the condition and re-apply the update expression - commit_single_operation: sync LSI/GSI index rows after Put/Update commit via a separate non-LWT batch (Cassandra cannot batch LWT with cross-partition writes) - initial_ledger_ops: store raw AttributeValue string for sk_val instead of calling parse_sk, so type errors surface as TransactionCanceledException rather than InternalServerError - ddl: treat CREATING/DELETING table status as TableNotFound GSI backfill: - Add index_propagation_holds table and propagation_hold module; workers skip gsi_pending rows for held tables - Implement backfill_gsi: token-order full-table scan that populates a newly created GSI data table under a propagation hold - Wire backfill into update_table_impl GSI create path Other: - query.rs: fix binary_upper_bound for all-0xFF prefixes (return None and emit a lower-bound-only range instead of a bogus upper bound) - put_get_item.rs: validate index key types before write work - Cargo.lock: sync dependency versions --- Cargo.lock | 999 +++++++++--------- .../data/V001__initial_data_schema.cql | 10 + crates/storage-cassandra/src/data/ddl.rs | 149 +++ .../src/data/put_get_item.rs | 17 + crates/storage-cassandra/src/data/query.rs | 37 +- .../src/data/transactions.rs | 291 ++++- crates/storage-cassandra/src/lib.rs | 1 + .../storage-cassandra/src/propagation_hold.rs | 77 ++ crates/storage-cassandra/src/update_table.rs | 34 +- crates/storage-cassandra/src/workers.rs | 6 + tests/test_transaction_operations.py | 20 + 11 files changed, 1094 insertions(+), 547 deletions(-) create mode 100644 crates/storage-cassandra/src/propagation_hold.rs diff --git a/Cargo.lock b/Cargo.lock index 99280461..51a2f14f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -58,9 +58,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.5" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] @@ -132,15 +132,15 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.104" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "arc-swap" -version = "1.9.2" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" dependencies = [ "rustversion", ] @@ -175,7 +175,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", "synstructure", ] @@ -187,14 +187,14 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] name = "async-compression" -version = "0.4.43" +version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" dependencies = [ "compression-codecs", "compression-core", @@ -215,13 +215,13 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.92" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 2.0.117", ] [[package]] @@ -256,9 +256,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.18.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -267,15 +267,14 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.44.0" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" dependencies = [ "cc", "cmake", "dunce", "fs_extra", - "pkg-config", ] [[package]] @@ -339,7 +338,7 @@ checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -435,9 +434,9 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.13.1" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" dependencies = [ "serde_core", ] @@ -497,7 +496,7 @@ dependencies = [ "indexmap", "js-sys", "once_cell", - "rand 0.9.5", + "rand 0.9.4", "serde", "serde_bytes", "serde_json", @@ -528,7 +527,7 @@ checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.5", ] [[package]] @@ -539,9 +538,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.12.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cassandra-protocol" @@ -569,9 +568,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.4" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", "jobserver", @@ -612,7 +611,7 @@ dependencies = [ "itertools 0.11.0", "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -623,12 +622,12 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "chacha20" -version = "0.10.2" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", - "cpufeatures 0.3.1", + "cpufeatures 0.3.0", "rand_core 0.10.1", ] @@ -657,9 +656,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.6" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -667,9 +666,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.6" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -679,14 +678,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.4" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck", "proc-macro2", "quote", - "syn 3.0.4", + "syn 2.0.117", ] [[package]] @@ -718,9 +717,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "combine" -version = "4.6.8" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" dependencies = [ "bytes", "memchr", @@ -839,9 +838,9 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.1" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" dependencies = [ "libc", ] @@ -863,9 +862,9 @@ checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] name = "crc32fast" -version = "1.5.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ "cfg-if", ] @@ -878,9 +877,9 @@ checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" [[package]] name = "crossbeam-channel" -version = "0.5.16" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" dependencies = [ "crossbeam-utils", ] @@ -896,18 +895,18 @@ dependencies = [ [[package]] name = "crossbeam-queue" -version = "0.3.13" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.22" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crunchy" @@ -982,7 +981,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -993,7 +992,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -1012,9 +1011,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.11.1" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] name = "der" @@ -1047,6 +1046,7 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ + "powerfmt", "serde_core", ] @@ -1069,7 +1069,7 @@ checksum = "d65d7ce8132b7c0e54497a4d9a55a1c2a0912a0d786cf894472ba818fba45762" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -1080,7 +1080,7 @@ checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -1102,7 +1102,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.119", + "syn 2.0.117", "unicode-xid", ] @@ -1132,13 +1132,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.7" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 2.0.117", ] [[package]] @@ -1164,9 +1164,9 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "either" -version = "1.18.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" dependencies = [ "serde", ] @@ -1207,16 +1207,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "etcetera" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" -dependencies = [ - "cfg-if", - "windows-sys 0.61.2", -] - [[package]] name = "event-listener" version = "5.4.2" @@ -1270,7 +1260,7 @@ dependencies = [ "rustls", "serde", "serde_json", - "sqlx 0.8.6", + "sqlx", "time", "tokio", "toml", @@ -1376,7 +1366,7 @@ dependencies = [ "hyper", "libc", "metrics", - "rand 0.9.5", + "rand 0.9.4", "rustls", "serde", "serde_json", @@ -1402,7 +1392,7 @@ dependencies = [ "extenddb-auth", "extenddb-core", "futures", - "rand 0.9.5", + "rand 0.9.4", "serde", "serde_json", "thiserror", @@ -1430,7 +1420,7 @@ dependencies = [ "extenddb-core", "extenddb-storage", "futures", - "rand 0.9.5", + "rand 0.9.4", "serde", "serde_json", "time", @@ -1459,7 +1449,7 @@ dependencies = [ "futures", "mongodb", "proptest", - "rand 0.9.5", + "rand 0.9.4", "serde", "serde_json", "thiserror", @@ -1486,10 +1476,10 @@ dependencies = [ "extenddb-storage", "futures", "pgvector", - "rand 0.9.5", + "rand 0.9.4", "serde", "serde_json", - "sqlx 0.8.6", + "sqlx", "time", "tokio", "toml", @@ -1513,10 +1503,10 @@ dependencies = [ "extenddb-core", "extenddb-storage", "futures", - "rand 0.9.5", + "rand 0.9.4", "serde", "serde_json", - "sqlx 0.8.6", + "sqlx", "time", "tokio", "toml", @@ -1533,9 +1523,9 @@ checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "find-msvc-tools" -version = "0.1.11" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flate2" @@ -1576,12 +1566,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1593,9 +1577,9 @@ dependencies = [ [[package]] name = "fs-err" -version = "3.3.1" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a" +checksum = "73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0" dependencies = [ "autocfg", "tokio", @@ -1615,9 +1599,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -1630,9 +1614,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -1640,15 +1624,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -1668,38 +1652,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-macro" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 2.0.117", ] [[package]] name = "futures-sink" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -1760,14 +1744,16 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.3" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", "rand_core 0.10.1", + "wasip2", + "wasip3", ] [[package]] @@ -1782,9 +1768,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.19" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" dependencies = [ "atomic-waker", "bytes", @@ -1817,18 +1803,7 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash 0.1.5", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.2.0", + "foldhash", ] [[package]] @@ -1855,15 +1830,6 @@ dependencies = [ "hashbrown 0.15.5", ] -[[package]] -name = "hashlink" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" -dependencies = [ - "hashbrown 0.16.1", -] - [[package]] name = "heck" version = "0.5.0" @@ -1955,15 +1921,6 @@ dependencies = [ "hmac 0.12.1", ] -[[package]] -name = "hkdf" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" -dependencies = [ - "hmac 0.13.0", -] - [[package]] name = "hmac" version = "0.12.1" @@ -1993,9 +1950,9 @@ dependencies = [ [[package]] name = "http" -version = "1.5.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", "itoa", @@ -2003,9 +1960,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.1.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", "http", @@ -2013,9 +1970,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.5" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", @@ -2047,9 +2004,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.11.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", @@ -2107,9 +2064,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", @@ -2121,9 +2078,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -2134,9 +2091,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -2148,17 +2105,16 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ - "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -2169,15 +2125,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.3.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -2188,6 +2144,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -2223,6 +2185,8 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown 0.17.1", + "serde", + "serde_core", ] [[package]] @@ -2319,7 +2283,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -2338,27 +2302,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] name = "jobserver" -version = "0.1.35" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ - "getrandom 0.4.3", + "getrandom 0.3.4", "libc", ] [[package]] name = "js-sys" -version = "0.3.104" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ "cfg-if", "futures-util", + "once_cell", "wasm-bindgen", ] @@ -2382,11 +2347,17 @@ dependencies = [ "spin", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" -version = "0.2.189" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libm" @@ -2396,14 +2367,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.21" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7955dfc218a8afb29dfeffd540e3a6e96baeb94fe7138228dd7cc6937fbbf96" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ "bitflags", "libc", "plain", - "redox_syscall 0.9.3", + "redox_syscall 0.7.5", ] [[package]] @@ -2425,9 +2396,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.3" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "lock_api" @@ -2440,9 +2411,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.34" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lz4_flex" @@ -2462,7 +2433,7 @@ dependencies = [ "macro_magic_core", "macro_magic_macros", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -2476,7 +2447,7 @@ dependencies = [ "macro_magic_core_macros", "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -2487,7 +2458,7 @@ checksum = "b02abfe41815b5bd98dbd4260173db2c116dda171dc0fe7838cb206333b83308" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -2498,7 +2469,7 @@ checksum = "73ea28ee64b88876bf45277ed9a5817c1817df061a74f2b988971a12570e5869" dependencies = [ "macro_magic_core", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -2538,9 +2509,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.3" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "metrics" @@ -2576,9 +2547,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.2" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi", @@ -2587,9 +2558,9 @@ dependencies = [ [[package]] name = "moka" -version = "0.12.16" +version = "0.12.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" dependencies = [ "async-lock", "crossbeam-channel", @@ -2625,9 +2596,9 @@ checksum = "851fac73f7fe22f6a3ab87f720ce509cae7c9fd08e7dd27866cc232dee07ccf4" [[package]] name = "mongodb" -version = "3.8.2" +version = "3.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d220eb9ba80bad420e1f9efc4e895fede7418f8779a8e38a3b750e57ff397720" +checksum = "b814038f367d212f55de0a630cb35102a9b8ca23785a86955d62c0087c93846d" dependencies = [ "base64 0.22.1", "bitflags", @@ -2648,7 +2619,7 @@ dependencies = [ "mongodb-internal-macros", "pbkdf2", "percent-encoding", - "rand 0.9.5", + "rand 0.9.4", "rustc_version_runtime", "rustls", "serde", @@ -2666,19 +2637,19 @@ dependencies = [ "tokio-util", "typed-builder", "uuid", - "webpki-roots 1.0.9", + "webpki-roots 1.0.7", ] [[package]] name = "mongodb-internal-macros" -version = "3.8.2" +version = "3.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e38ff3c46c59c2f4d9b26a86e6980dc23583e9d4b45aa579cef6584642093128" +checksum = "f736d2fbc56e0011a341fbb9172bd822fda75c5f93b82fae1c7aab1e2613c810" dependencies = [ "macro_magic", "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -2708,9 +2679,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.8" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", "num-traits", @@ -2727,7 +2698,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.8", + "rand 0.8.6", "smallvec", "zeroize", ] @@ -2740,19 +2711,20 @@ checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" -version = "0.1.47" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" dependencies = [ "num-traits", ] [[package]] name = "num-iter" -version = "0.1.46" +version = "0.1.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" dependencies = [ + "autocfg", "num-integer", "num-traits", ] @@ -2879,9 +2851,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.9.0" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" dependencies = [ "memchr", "ucd-trie", @@ -2889,9 +2861,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.9.0" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" dependencies = [ "pest", "pest_generator", @@ -2899,24 +2871,25 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.9.0" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] name = "pest_meta" -version = "2.9.0" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" dependencies = [ "pest", + "sha2 0.10.9", ] [[package]] @@ -2925,7 +2898,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3673cba5b9a124916096a423b806a9f29620972c6c97b08db5f2053e9428b481" dependencies = [ - "sqlx 0.9.0", + "sqlx", ] [[package]] @@ -2957,9 +2930,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.34" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "plain" @@ -2981,15 +2954,15 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.15.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "potential_utf" -version = "0.1.6" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -3020,11 +2993,21 @@ dependencies = [ "num-traits", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + [[package]] name = "proc-macro2" -version = "1.0.107" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -3039,7 +3022,7 @@ dependencies = [ "bit-vec 0.8.0", "bitflags", "num-traits", - "rand 0.9.5", + "rand 0.9.4", "rand_chacha 0.9.0", "rand_xorshift", "regex-syntax", @@ -3056,9 +3039,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quote" -version = "1.0.47" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -3083,9 +3066,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.8.8" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -3094,9 +3077,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.5" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -3109,7 +3092,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", - "getrandom 0.4.3", + "getrandom 0.4.2", "rand_core 0.10.1", ] @@ -3168,18 +3151,18 @@ dependencies = [ [[package]] name = "rapidhash" -version = "4.5.1" +version = "4.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e" +checksum = "b5e48930979c155e2f33aa36ab3119b5ee81332beb6482199a8ecd6029b80b59" dependencies = [ "rustversion", ] [[package]] name = "rcgen" -version = "0.14.9" +version = "0.14.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4" +checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" dependencies = [ "aws-lc-rs", "pem", @@ -3200,18 +3183,18 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.9.3" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d678d17679829e73d371e96880897e98fee2ded7acc0a50bdf8af2affa4b2fe5" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" dependencies = [ "bitflags", ] [[package]] name = "regex-automata" -version = "0.4.18" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -3220,9 +3203,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.11" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "resolv-conf" @@ -3329,9 +3312,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.43" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "aws-lc-rs", "log", @@ -3345,18 +3328,18 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.15.1" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "zeroize", ] [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", @@ -3366,9 +3349,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.23" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "rusty-fork" @@ -3411,9 +3394,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.229" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", "serde_derive", @@ -3431,29 +3414,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.229" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.229" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 2.0.117", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "indexmap", "itoa", @@ -3497,9 +3480,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.22.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" dependencies = [ "serde_core", "serde_with_macros", @@ -3507,21 +3490,21 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.22.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] name = "sha1" -version = "0.10.7" +version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -3535,7 +3518,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures 0.3.1", + "cpufeatures 0.3.0", "digest 0.11.3", ] @@ -3557,7 +3540,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures 0.3.1", + "cpufeatures 0.3.0", "digest 0.11.3", ] @@ -3572,9 +3555,9 @@ dependencies = [ [[package]] name = "shlex" -version = "2.0.1" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" @@ -3598,9 +3581,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.10" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "simd_cesu8" @@ -3626,9 +3609,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.2" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" dependencies = [ "serde", ] @@ -3641,9 +3624,9 @@ checksum = "199905e6153d6405f9728fe44daace35f8f837bbf830bb6e85fbd5828709a886" [[package]] name = "socket2" -version = "0.6.5" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", "windows-sys 0.61.2", @@ -3651,9 +3634,9 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.9" +version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" dependencies = [ "lock_api", ] @@ -3674,24 +3657,13 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" dependencies = [ - "sqlx-core 0.8.6", - "sqlx-macros 0.8.6", + "sqlx-core", + "sqlx-macros", "sqlx-mysql", - "sqlx-postgres 0.8.6", + "sqlx-postgres", "sqlx-sqlite", ] -[[package]] -name = "sqlx" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" -dependencies = [ - "sqlx-core 0.9.0", - "sqlx-macros 0.9.0", - "sqlx-postgres 0.9.0", -] - [[package]] name = "sqlx-core" version = "0.8.6" @@ -3731,38 +3703,6 @@ dependencies = [ "webpki-roots 0.26.11", ] -[[package]] -name = "sqlx-core" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" -dependencies = [ - "base64 0.22.1", - "bytes", - "cfg-if", - "crc", - "crossbeam-queue", - "either", - "event-listener", - "futures-core", - "futures-intrusive", - "futures-io", - "futures-util", - "hashbrown 0.16.1", - "hashlink 0.11.1", - "indexmap", - "log", - "memchr", - "percent-encoding", - "serde", - "serde_json", - "sha2 0.10.9", - "smallvec", - "thiserror", - "tracing", - "url", -] - [[package]] name = "sqlx-macros" version = "0.8.6" @@ -3771,22 +3711,9 @@ checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" dependencies = [ "proc-macro2", "quote", - "sqlx-core 0.8.6", - "sqlx-macros-core 0.8.6", - "syn 2.0.119", -] - -[[package]] -name = "sqlx-macros" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" -dependencies = [ - "proc-macro2", - "quote", - "sqlx-core 0.9.0", - "sqlx-macros-core 0.9.0", - "syn 2.0.119", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.117", ] [[package]] @@ -3805,37 +3732,15 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "sqlx-core 0.8.6", + "sqlx-core", "sqlx-mysql", - "sqlx-postgres 0.8.6", + "sqlx-postgres", "sqlx-sqlite", - "syn 2.0.119", + "syn 2.0.117", "tokio", "url", ] -[[package]] -name = "sqlx-macros-core" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" -dependencies = [ - "cfg-if", - "dotenvy", - "either", - "heck", - "hex", - "proc-macro2", - "quote", - "serde", - "serde_json", - "sha2 0.10.9", - "sqlx-core 0.9.0", - "sqlx-postgres 0.9.0", - "syn 2.0.119", - "url", -] - [[package]] name = "sqlx-mysql" version = "0.8.6" @@ -3858,7 +3763,7 @@ dependencies = [ "futures-util", "generic-array", "hex", - "hkdf 0.12.4", + "hkdf", "hmac 0.12.1", "itoa", "log", @@ -3866,19 +3771,19 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand 0.8.8", + "rand 0.8.6", "rsa", "serde", - "sha1 0.10.7", + "sha1 0.10.6", "sha2 0.10.9", "smallvec", - "sqlx-core 0.8.6", + "sqlx-core", "stringprep", "thiserror", "time", "tracing", "uuid", - "whoami 1.6.1", + "whoami", ] [[package]] @@ -3894,12 +3799,12 @@ dependencies = [ "byteorder", "crc", "dotenvy", - "etcetera 0.8.0", + "etcetera", "futures-channel", "futures-core", "futures-util", "hex", - "hkdf 0.12.4", + "hkdf", "hmac 0.12.1", "home", "itoa", @@ -3908,53 +3813,18 @@ dependencies = [ "memchr", "num-bigint", "once_cell", - "rand 0.8.8", + "rand 0.8.6", "serde", "serde_json", "sha2 0.10.9", "smallvec", - "sqlx-core 0.8.6", + "sqlx-core", "stringprep", "thiserror", "time", "tracing", "uuid", - "whoami 1.6.1", -] - -[[package]] -name = "sqlx-postgres" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" -dependencies = [ - "atoi", - "base64 0.22.1", - "bitflags", - "byteorder", - "crc", - "dotenvy", - "etcetera 0.11.0", - "futures-channel", - "futures-core", - "futures-util", - "hex", - "hkdf 0.13.0", - "hmac 0.13.0", - "itoa", - "log", - "md-5 0.11.0", - "memchr", - "rand 0.10.2", - "serde", - "serde_json", - "sha2 0.11.0", - "smallvec", - "sqlx-core 0.9.0", - "stringprep", - "thiserror", - "tracing", - "whoami 2.1.3", + "whoami", ] [[package]] @@ -3975,7 +3845,7 @@ dependencies = [ "percent-encoding", "serde", "serde_urlencoded", - "sqlx-core 0.8.6", + "sqlx-core", "thiserror", "time", "tracing", @@ -4025,9 +3895,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.119" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -4036,9 +3906,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.4" +version = "3.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" dependencies = [ "proc-macro2", "quote", @@ -4059,7 +3929,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -4119,7 +3989,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -4127,40 +3997,41 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.20" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.20" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 2.0.117", ] [[package]] name = "thread_local" -version = "1.1.10" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" dependencies = [ "cfg-if", ] [[package]] name = "time" -version = "0.3.55" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", + "itoa", "num-conv", "powerfmt", "serde_core", @@ -4170,15 +4041,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.9" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.32" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -4195,9 +4066,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.4" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -4205,9 +4076,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.12.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -4220,9 +4091,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.53.1" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -4237,13 +4108,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.2" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 2.0.117", ] [[package]] @@ -4258,9 +4129,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.19" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" dependencies = [ "futures-core", "pin-project-lite", @@ -4269,16 +4140,15 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.19" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", "futures-io", "futures-sink", "futures-util", - "libc", "pin-project-lite", "tokio", ] @@ -4391,7 +4261,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -4469,14 +4339,14 @@ checksum = "0e48cea23f68d1f78eb7bc092881b6bb88d3d6b5b7e6234f6f9c911da1ffb221" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] name = "typenum" -version = "1.20.1" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "ucd-trie" @@ -4519,9 +4389,9 @@ checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] name = "unicode-segmentation" -version = "1.13.3" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-xid" @@ -4583,11 +4453,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.26.0" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ - "getrandom 0.4.3", + "getrandom 0.4.2", "js-sys", "serde_core", "wasm-bindgen", @@ -4638,11 +4508,20 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.4+wasi-0.2.12" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] @@ -4653,9 +4532,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.127" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -4666,9 +4545,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.127" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4676,40 +4555,74 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.127" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.127" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "webpki-roots" version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.9", + "webpki-roots 1.0.7", ] [[package]] name = "webpki-roots" -version = "1.0.9" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" dependencies = [ "rustls-pki-types", ] @@ -4724,12 +4637,6 @@ dependencies = [ "wasite", ] -[[package]] -name = "whoami" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" - [[package]] name = "widestring" version = "1.2.1" @@ -4766,7 +4673,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -4777,7 +4684,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -4972,17 +4879,105 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "writeable" -version = "0.6.4" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "wyz" @@ -5034,9 +5029,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.3" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -5051,28 +5046,28 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.56" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.56" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] @@ -5092,35 +5087,35 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", "synstructure", ] [[package]] name = "zeroize" -version = "1.9.0" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.5.0" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 2.0.117", ] [[package]] name = "zerotrie" -version = "0.2.5" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -5129,9 +5124,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.8" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -5140,17 +5135,17 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.6" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 2.0.117", ] [[package]] name = "zmij" -version = "1.0.23" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/crates/storage-cassandra/migrations/data/V001__initial_data_schema.cql b/crates/storage-cassandra/migrations/data/V001__initial_data_schema.cql index 15c6598f..3b93cf95 100644 --- a/crates/storage-cassandra/migrations/data/V001__initial_data_schema.cql +++ b/crates/storage-cassandra/migrations/data/V001__initial_data_schema.cql @@ -45,6 +45,16 @@ CREATE TABLE IF NOT EXISTS transaction_ledger ( -- Index for efficient age-based scanning during recovery CREATE INDEX IF NOT EXISTS idx_txn_started_at ON transaction_ledger (started_at); +-- Index propagation hold table. +-- A row here means the named index (and by extension the whole table) is +-- undergoing a backfill. Workers skip gsi_pending rows for held tables until +-- the hold is released. +CREATE TABLE IF NOT EXISTS index_propagation_holds ( + table_id text, + index_id text, + PRIMARY KEY ((table_id), index_id) +); + -- Persistent GSI propagation queue. -- Rows are inserted inside the same logged batch as the base write, so they -- survive a crash. Workers drain their partition in (ready_at, id) order diff --git a/crates/storage-cassandra/src/data/ddl.rs b/crates/storage-cassandra/src/data/ddl.rs index dbb19b95..555832e0 100644 --- a/crates/storage-cassandra/src/data/ddl.rs +++ b/crates/storage-cassandra/src/data/ddl.rs @@ -116,6 +116,9 @@ impl CassandraEngine { let stream_spec_text: Option = row.get_by_name("stream_specification").ok().flatten(); + if status == "CREATING" || status == "DELETING" { + return Err(StorageError::TableNotFound(table_name.to_owned())); + } if status != "ACTIVE" { return Err(StorageError::TableNotActive(table_name.to_owned())); } @@ -412,6 +415,152 @@ impl CassandraEngine { Ok(()) } + + /// Backfill an existing base table into a newly created GSI data table. + /// + /// Scans the base table in token order in batches, projecting and inserting + /// each qualifying item into the index table. Called synchronously from + /// `update_table_impl` while a propagation hold is active, so workers will + /// not apply queued writes to this index until the hold is released. + #[allow(clippy::too_many_arguments)] + pub(crate) async fn backfill_gsi( + &self, + account_keyspace: &str, + table_id: &str, + index_id: &str, + index_key_schema: &[extenddb_core::types::KeySchemaElement], + attr_defs: &[extenddb_core::types::AttributeDefinition], + base_key_schema: &[extenddb_core::types::KeySchemaElement], + base_attr_defs: &[extenddb_core::types::AttributeDefinition], + projection: &extenddb_core::types::Projection, + ) -> Result<(), StorageError> { + use crate::data::index::{insert_index_row_multi, item_has_index_keys, project_item_for_index}; + use cdrs_tokio::consistency::Consistency; + use cdrs_tokio::query::BatchQueryBuilder; + + const PAGE_SIZE: i64 = 500; + + let base_table = data_table_name(table_id); + let idx_table = index_table_name(index_id); + let idx_sks = all_sort_key_info(index_key_schema, attr_defs); + let base_sks = all_sort_key_info(base_key_schema, base_attr_defs); + + // Token-based full-table scan matching the pattern used by scan_impl. + // First page: no lower bound. Subsequent pages: token(pk) > last_token. + let first_page_query = format!( + "SELECT pk, item_data FROM {account_keyspace}.{base_table} \ + LIMIT {PAGE_SIZE} ALLOW FILTERING" + ); + let next_page_query = format!( + "SELECT pk, item_data FROM {account_keyspace}.{base_table} \ + WHERE token(pk) > ? LIMIT {PAGE_SIZE} ALLOW FILTERING" + ); + + let mut last_token: Option = None; + + loop { + let rows = if let Some(tok) = last_token { + crate::cassandra_util::query_rows::( + &self.session, + &next_page_query, + cdrs_tokio::query_values!(tok), + "backfill_gsi", + ) + .await? + } else { + crate::cassandra_util::query_rows::( + &self.session, + &first_page_query, + cdrs_tokio::query_values!(), + "backfill_gsi", + ) + .await? + }; + + if rows.is_empty() { + break; + } + + let mut batch = BatchQueryBuilder::new().with_consistency(Consistency::LocalQuorum); + let mut batch_has_rows = false; + + for row in &rows { + let item_json: String = + crate::cassandra_util::get_column(row, "item_data", "backfill_gsi")?; + let item = super::json_to_item(item_json)?; + + if !item_has_index_keys(&item, index_key_schema) { + continue; + } + + let projected = + project_item_for_index(&item, index_key_schema, base_key_schema, projection); + + insert_index_row_multi( + &mut batch, + account_keyspace, + &idx_table, + &item, + &projected, + index_key_schema, + base_key_schema, + &idx_sks, + &base_sks, + )?; + batch_has_rows = true; + } + + if batch_has_rows { + self.session + .batch( + batch + .build() + .map_err(|e| StorageError::Internal(e.to_string()))?, + ) + .await + .map_err(|e| StorageError::Internal(format!("backfill_gsi batch: {e}")))?; + } + + // Advance the token cursor using the last pk in this page. + // If we got fewer rows than PAGE_SIZE we've reached the end. + #[allow(clippy::cast_possible_truncation)] + if (rows.len() as i64) < PAGE_SIZE { + break; + } + + // Get the token of the last pk to use as the next page cursor. + if let Some(last_row) = rows.last() { + let pk: String = + crate::cassandra_util::get_column(last_row, "pk", "backfill_gsi")?; + let token_query = format!( + "SELECT token(pk) AS tok FROM {account_keyspace}.{base_table} WHERE pk = ?" + ); + let tok_rows = crate::cassandra_util::query_rows::( + &self.session, + &token_query, + cdrs_tokio::query_values!(pk.as_str()), + "backfill_gsi_token", + ) + .await?; + if let Some(tok_row) = tok_rows.first() { + let tok: i64 = + crate::cassandra_util::get_column(tok_row, "tok", "backfill_gsi_token")?; + // Guard against token collisions: if the token hasn't + // advanced, there are no more distinct partitions to scan. + if Some(tok) == last_token { + break; + } + last_token = Some(tok); + } else { + break; + } + } else { + break; + } + } + + Ok(()) + } } #[cfg(test)] diff --git a/crates/storage-cassandra/src/data/put_get_item.rs b/crates/storage-cassandra/src/data/put_get_item.rs index 7616b2b1..ce6afea8 100644 --- a/crates/storage-cassandra/src/data/put_get_item.rs +++ b/crates/storage-cassandra/src/data/put_get_item.rs @@ -81,6 +81,23 @@ impl CassandraEngine { ), self.ttl_config_for_table(&key_info.account_id, &key_info.table_name), )?; + + // Validate index key types and emptiness before any write work. + if !indexes.is_empty() { + let index_refs: Vec> = indexes + .iter() + .map(|idx| extenddb_core::validation::IndexKeyRef { + index_name: &idx.index_name, + key_schema: &idx.key_schema, + }) + .collect(); + extenddb_core::validation::validate_index_keys( + &item, + &index_refs, + &key_info.attribute_definitions, + ) + .map_err(|e| StorageError::Validation(e.to_string()))?; + } let sys_delay = if indexes.is_empty() { 0 } else { diff --git a/crates/storage-cassandra/src/data/query.rs b/crates/storage-cassandra/src/data/query.rs index 78f86d9b..49c42c91 100644 --- a/crates/storage-cassandra/src/data/query.rs +++ b/crates/storage-cassandra/src/data/query.rs @@ -55,24 +55,18 @@ fn string_upper_bound(prefix: &str) -> String { } /// Compute upper bound for begins_with on binary data. -/// Increments the last byte, extending if needed for overflow. -fn binary_upper_bound(prefix: &[u8]) -> Vec { +/// Returns `None` when the prefix is empty or all-0xFF (no finite upper bound). +fn binary_upper_bound(prefix: &[u8]) -> Option> { let mut upper = prefix.to_vec(); - - // Try to increment the last byte for i in (0..upper.len()).rev() { - if upper[i] < 255 { + if upper[i] < 0xFF { upper[i] += 1; - return upper; + upper.truncate(i + 1); + return Some(upper); } - // This byte is 255, set to 0 and continue to next byte - upper[i] = 0; } - - // All bytes were 255 - prepend a 1 byte - let mut result = vec![1]; - result.extend_from_slice(&upper); - result + // Empty prefix or all bytes are 0xFF — no upper bound. + None } /// Helper to determine base table sort key info for index queries. @@ -191,16 +185,14 @@ impl CassandraEngine { Some((sk_col, vec![low_sk, high_sk])) } SortKeyCondition::BeginsWith { prefix, .. } => { - query.push_str(&format!(" AND {sk_col} >= ? AND {sk_col} < ?")); - // Resolve prefix let prefix_av = resolve_expr_to_av(prefix, _maps)?; let prefix_sk = parse_sk(&prefix_av, sk_type)?; // Compute upper bound based on type - let upper_sk = match &prefix_sk { - SortKeyValue::S(s) => SortKeyValue::S(string_upper_bound(s)), - SortKeyValue::B(b) => SortKeyValue::B(binary_upper_bound(b)), + let upper_sk_opt = match &prefix_sk { + SortKeyValue::S(s) => Some(SortKeyValue::S(string_upper_bound(s))), + SortKeyValue::B(b) => binary_upper_bound(b).map(SortKeyValue::B), SortKeyValue::N(_) => { return Err(StorageError::Validation( "BeginsWith is not supported for numeric sort keys".to_owned(), @@ -208,7 +200,14 @@ impl CassandraEngine { } }; - Some((sk_col, vec![prefix_sk, upper_sk])) + if let Some(upper_sk) = upper_sk_opt { + query.push_str(&format!(" AND {sk_col} >= ? AND {sk_col} < ?")); + Some((sk_col, vec![prefix_sk, upper_sk])) + } else { + // No finite upper bound (empty or all-0xFF prefix) — lower bound only. + query.push_str(&format!(" AND {sk_col} >= ?")); + Some((sk_col, vec![prefix_sk])) + } } } } else { diff --git a/crates/storage-cassandra/src/data/transactions.rs b/crates/storage-cassandra/src/data/transactions.rs index e097c085..74793cc0 100644 --- a/crates/storage-cassandra/src/data/transactions.rs +++ b/crates/storage-cassandra/src/data/transactions.rs @@ -376,8 +376,8 @@ impl CassandraEngine { .map_err(|e| CancellationReason::validation_error(e.to_string()))?; // Read existing item - let existing = self - .fetch_item_for_transaction(key_info, item) + let mut existing = self + .fetch_item_for_transaction(key_info, item, None) .await .map_err(|e| CancellationReason::validation_error(e.to_string()))?; @@ -404,9 +404,41 @@ impl CassandraEngine { .await?; } - // Execute PREPARE - self.prepare_item(key_info, item, txn_id, txn_timestamp, existing.is_none()) - .await?; + // Execute PREPARE, with create-race retry loop. + let mut attempt = 0u32; + loop { + attempt += 1; + match self + .prepare_item(key_info, item, txn_id, txn_timestamp, existing.is_none()) + .await + { + Ok(()) => break, + Err(r) if r.code == "TransactionConflict" && attempt < 5 => { + // Lost the INSERT IF NOT EXISTS race (new item) or another + // transaction grabbed the row between our read and prepare. + // Wait for that transaction to commit, then re-evaluate. + let committed = self.wait_for_commit_and_read(key_info, item).await?; + match committed { + Some(winner) => { + eval_condition( + *condition, + &winner, + maps, + *return_values_on_ccf, + Some(&winner), + )?; + // Condition passed against winner — overwrite. + existing = Some(winner); + } + None => { + // Winner rolled back; retry the insert. + existing = None; + } + } + } + Err(r) => return Err(r), + } + } Ok(None) } TransactWriteOp::Delete { @@ -427,7 +459,7 @@ impl CassandraEngine { // Read existing item let existing = self - .fetch_item_for_transaction(key_info, key) + .fetch_item_for_transaction(key_info, key, None) .await .map_err(|e| CancellationReason::validation_error(e.to_string()))?; @@ -464,8 +496,8 @@ impl CassandraEngine { .map_err(|e| CancellationReason::validation_error(e.to_string()))?; // Read existing item - let existing = self - .fetch_item_for_transaction(key_info, key) + let mut existing = self + .fetch_item_for_transaction(key_info, key, None) .await .map_err(|e| CancellationReason::validation_error(e.to_string()))?; @@ -501,9 +533,53 @@ impl CassandraEngine { .await?; } - // Execute PREPARE - self.prepare_item(key_info, key, txn_id, txn_timestamp, existing.is_none()) - .await?; + // Execute PREPARE, with create-race retry loop. + let mut attempt = 0u32; + loop { + attempt += 1; + match self + .prepare_item(key_info, key, txn_id, txn_timestamp, existing.is_none()) + .await + { + Ok(()) => break, + Err(r) if r.code == "TransactionConflict" && attempt < 5 => { + let committed = self.wait_for_commit_and_read(key_info, key).await?; + match committed { + Some(winner) => { + eval_condition( + *condition, + &winner, + maps, + *return_values_on_ccf, + Some(&winner), + )?; + // Re-apply expression on top of winner's item. + item = winner.clone(); + expression::apply_update_validated( + actions, &mut item, maps, &[], &[], + ) + .map_err(|e| { + CancellationReason::validation_error(e.to_string()) + })?; + existing = Some(winner); + } + None => { + // Winner rolled back; retry the insert. + existing = None; + item = (*key).clone(); + expression::apply_update_validated( + actions, &mut item, maps, &[], &[], + ) + .map_err(|e| { + CancellationReason::validation_error(e.to_string()) + })?; + } + } + } + Err(r) => return Err(r), + } + } + // Return the computed final item so the caller can update the ledger blob let item_json = serde_json::to_string(&item) .map_err(|e| CancellationReason::validation_error(e.to_string()))?; @@ -526,7 +602,7 @@ impl CassandraEngine { // Read existing item let existing = self - .fetch_item_for_transaction(key_info, key) + .fetch_item_for_transaction(key_info, key, None) .await .map_err(|e| CancellationReason::validation_error(e.to_string()))?; @@ -696,23 +772,26 @@ impl CassandraEngine { } }; let pk = composite_pk_to_text(key, &key_info.key_schema)?; + // Store sk_col/sk_val for recovery without type-checking the value. + // A type mismatch is caught later by validate_key_only in + // prepare_single_operation; failing here would surface as + // StorageError::Internal instead of TransactionCanceledException. let (sk_col, sk_val) = if let Some((sk_name, sk_type)) = sk_info(&key_info.key_schema, &key_info.attribute_definitions) { let sk_value = key .get(sk_name) .ok_or_else(|| StorageError::Internal("missing sort key".to_owned()))?; - let sk = parse_sk(sk_value, sk_type)?; let col = sk_column(sk_type).to_owned(); - // Store as the text representation used in Cassandra queries. - // sk_col ("sk_s"/"sk_n"/"sk_b") encodes the type; no separate type tag needed. - let val = match &sk { - SortKeyValue::S(s) => s.clone(), - SortKeyValue::N(n) => n.to_string(), - SortKeyValue::B(b) => { + let val = match sk_value { + AttributeValue::S(s) => s.clone(), + AttributeValue::N(n) => n.clone(), + AttributeValue::B(b) => { use base64::Engine as _; base64::engine::general_purpose::STANDARD.encode(b) } + // Any other type will be caught by validate_key_only. + _ => String::new(), }; (Some(col), Some(val)) } else { @@ -827,10 +906,14 @@ impl CassandraEngine { } /// Fetch an item for transaction (reads item_data and prepared_txn_id). + /// + /// If `skip_txn_id` is `Some(id)`, rows prepared by that transaction are + /// read directly (the caller owns that PREPARE and must not wait on itself). async fn fetch_item_for_transaction( &self, key_info: &TableKeyInfo, key: &Item, + skip_txn_id: Option, ) -> Result, StorageError> { let keyspace = self.account_keyspace(&key_info.account_id); let table = data_table_name(&key_info.table_id); @@ -841,7 +924,7 @@ impl CassandraEngine { &self.session, &keyspace, &table, - "item_data", + "item_data, prepared_txn_id", pk_text.as_str(), sk.as_ref(), sk_col.as_deref(), @@ -851,6 +934,19 @@ impl CassandraEngine { return Ok(None); }; + // If a *different* transaction has this row in PREPARE state, wait for + // it to commit or roll back. Returning the PREPARE-state item + // (key-only placeholder) would cause condition expressions like + // attribute_not_exists(pk) to fail against stale data with no ALL_OLD. + let prepared_txn_id: Option = + row.get_by_name("prepared_txn_id").ok().flatten(); + if prepared_txn_id.is_some() && prepared_txn_id != skip_txn_id { + return self + .wait_for_commit_and_read(key_info, key) + .await + .map_err(|e| StorageError::Internal(e.message.unwrap_or_default())); + } + let item_data: Option = row.get_by_name("item_data").ok().flatten(); let Some(item_data) = item_data else { return Ok(None); @@ -860,6 +956,82 @@ impl CassandraEngine { )) } + /// Wait for a concurrently-prepared item to commit or roll back, then return + /// its committed state. + /// + /// After losing an `INSERT IF NOT EXISTS` LWT race, the winning transaction + /// may still be in PREPARE state. This method polls with a single SELECT of + /// `item_data, prepared_txn_id` until `prepared_txn_id` is NULL, then returns: + /// - `Ok(Some(item))` — winner committed; use as the re-read existing item. + /// - `Ok(None)` — winner rolled back and deleted the row; caller should retry + /// the insert. + /// + /// Returns `Err` only on infrastructure failure. + async fn wait_for_commit_and_read( + &self, + key_info: &TableKeyInfo, + key: &Item, + ) -> Result, CancellationReason> { + use std::time::Duration; + const MAX_POLLS: u32 = 20; + const POLL_SLEEP_MS: u64 = 50; + + let keyspace = self.account_keyspace(&key_info.account_id); + let table = data_table_name(&key_info.table_id); + let pk_text = composite_pk_to_text(key, &key_info.key_schema) + .map_err(|e| CancellationReason::validation_error(e.to_string()))?; + let (sk, sk_col) = resolve_sk(key_info, key) + .map_err(|e| CancellationReason::validation_error(e.to_string()))?; + + for _ in 0..MAX_POLLS { + let row = select_by_pk( + &self.session, + &keyspace, + &table, + "item_data, prepared_txn_id, last_committed_txn_timestamp", + pk_text.as_str(), + sk.as_ref(), + sk_col.as_deref(), + ) + .await + .map_err(|e| CancellationReason::validation_error(e.to_string()))?; + + let Some(row) = row else { + // Row gone — winner rolled back and deleted it. + return Ok(None); + }; + + let prepared_txn_id: Option = + row.get_by_name("prepared_txn_id").ok().flatten(); + let last_committed: Option = row + .get_by_name("last_committed_txn_timestamp") + .ok() + .flatten(); + + // Only treat the row as committed when prepared_txn_id is NULL + // AND last_committed_txn_timestamp is set. This guards against a + // stale replica that has cleared prepared_txn_id but not yet + // propagated the updated item_data from commit_put_or_update. + if prepared_txn_id.is_none() && last_committed.is_some() { + let item_data: String = + get_column::(&row, "item_data", "wait_for_commit_and_read") + .map_err(|e| CancellationReason::validation_error(e.to_string()))?; + let item: Item = serde_json::from_str(&item_data) + .map_err(|e| CancellationReason::validation_error(e.to_string()))?; + return Ok(Some(item)); + } + + tokio::time::sleep(Duration::from_millis(POLL_SLEEP_MS)).await; + } + + // Timed out waiting — treat as conflict. + Err(CancellationReason { + code: "TransactionConflict".to_owned(), + message: Some("Transaction is ongoing for the item".to_owned()), + item: None, + }) + } + /// Check partition_max_delete_timestamp for new items. async fn check_partition_max_delete_timestamp( &self, @@ -1002,7 +1174,8 @@ impl CassandraEngine { match op { TransactWriteOp::Put { key_info, item, .. } => { self.commit_put_or_update(key_info, item, txn_id_bytes.clone(), txn_timestamp) - .await + .await?; + self.commit_sync_indexes(key_info, None, item).await } TransactWriteOp::Update { key_info, @@ -1012,13 +1185,14 @@ impl CassandraEngine { .. } => { // Re-fetch and re-apply update (idempotent) - let existing = self.fetch_item_for_transaction(key_info, key).await?; - let mut final_item = existing.unwrap_or_else(|| (*key).clone()); + let existing = self.fetch_item_for_transaction(key_info, key, Some(txn_id)).await?; + let mut final_item = existing.clone().unwrap_or_else(|| (*key).clone()); expression::apply_update_validated(actions, &mut final_item, maps, &[], &[]) .map_err(|e| StorageError::Internal(e.to_string()))?; self.commit_put_or_update(key_info, &final_item, txn_id_bytes, txn_timestamp) - .await + .await?; + self.commit_sync_indexes(key_info, existing.as_ref(), &final_item).await } TransactWriteOp::Delete { key_info, key, .. } => { let keyspace = self.account_keyspace(&key_info.account_id); @@ -1166,6 +1340,73 @@ impl CassandraEngine { Ok(()) } + /// Sync LSI/GSI index rows after a successful commit_put_or_update. + /// + /// Runs as a separate non-LWT batch after the base-row LWT commit, since + /// Cassandra does not allow LWT statements to be batched with writes to + /// other partitions. The brief inconsistency window is acceptable for the + /// same reason async GSIs accept it; transaction recovery re-runs this on + /// crash. + async fn commit_sync_indexes( + &self, + key_info: &TableKeyInfo, + old_item: Option<&Item>, + new_item: &Item, + ) -> Result<(), StorageError> { + use cdrs_tokio::query::BatchQueryBuilder; + use cdrs_tokio::consistency::Consistency; + + let catalog_keyspace = self.catalog_keyspace(); + let data_keyspace = self.account_keyspace(&key_info.account_id); + + let indexes = super::index::fetch_indexes_for_table( + &key_info.table_id, + &self.session, + &catalog_keyspace, + ) + .await?; + + if indexes.is_empty() { + return Ok(()); + } + + let sys_delay = self.gsi_default_delay_ms.load(std::sync::atomic::Ordering::Relaxed); + let mut batch = BatchQueryBuilder::new().with_consistency(Consistency::LocalQuorum); + super::index::sync_indexes( + &mut batch, + &data_keyspace, + &key_info.key_schema, + &key_info.attribute_definitions, + &indexes, + old_item, + Some(new_item), + sys_delay, + )?; + + let async_enqueued = super::index::enqueue_async_indexes( + &self.session, + &mut batch, + &data_keyspace, + key_info, + &indexes, + old_item, + Some(new_item), + sys_delay, + ) + .await?; + + self.session + .batch(batch.build().map_err(|e| StorageError::Internal(e.to_string()))?) + .await + .map_err(|e| StorageError::Internal(format!("commit_sync_indexes batch: {e}")))?; + + if async_enqueued > 0 { + self.gsi_queue.notify_workers(); + } + + Ok(()) + } + /// ROLLBACK a single operation: clean up prepared state. /// /// For items created during PREPARE (created_to_prepare=true): DELETE the item @@ -1202,7 +1443,7 @@ impl CassandraEngine { // We need to check if this item was created during PREPARE (created_to_prepare=true) // or if it was an existing item. Fetch the item to check. - let existing = self.fetch_item_for_transaction(key_info, key).await?; + let existing = self.fetch_item_for_transaction(key_info, key, Some(txn_id)).await?; // If item doesn't exist, it's already been cleaned up (idempotent) if existing.is_none() { diff --git a/crates/storage-cassandra/src/lib.rs b/crates/storage-cassandra/src/lib.rs index 50df1425..57c57467 100644 --- a/crates/storage-cassandra/src/lib.rs +++ b/crates/storage-cassandra/src/lib.rs @@ -24,6 +24,7 @@ mod management_store; mod metadata_engine; pub mod migrations; pub mod operations; +pub(crate) mod propagation_hold; mod stream_engine; pub mod stream_util; pub mod table_engine; diff --git a/crates/storage-cassandra/src/propagation_hold.rs b/crates/storage-cassandra/src/propagation_hold.rs new file mode 100644 index 00000000..9bc42526 --- /dev/null +++ b/crates/storage-cassandra/src/propagation_hold.rs @@ -0,0 +1,77 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Table-level index propagation holds. +//! +//! A hold prevents GSI workers from applying queued writes to any index on a +//! table while a backfill is in progress. Workers check for a hold before +//! processing each `gsi_pending` row and skip rows whose table is held. + +use std::sync::Arc; + +use extenddb_storage::error::StorageError; + +use crate::cassandra_util::CassandraSession; + +/// Insert a propagation hold for `(table_id, index_id)`. +/// +/// Must be called before the index data table is created so that no worker +/// can apply a queued write to the index before the backfill completes. +pub(crate) async fn take_propagation_hold( + session: &Arc, + account_keyspace: &str, + table_id: &str, + index_id: &str, +) -> Result<(), StorageError> { + let cql = format!( + "INSERT INTO {account_keyspace}.index_propagation_holds (table_id, index_id) VALUES (?, ?)" + ); + crate::cassandra_util::execute( + session, + &cql, + cdrs_tokio::query_values!(table_id, index_id), + "take_propagation_hold", + ) + .await +} + +/// Remove the propagation hold for `(table_id, index_id)`. +/// +/// Must be called after the backfill completes (or is abandoned) so that +/// workers resume applying queued writes. +pub(crate) async fn release_propagation_hold( + session: &Arc, + account_keyspace: &str, + table_id: &str, + index_id: &str, +) -> Result<(), StorageError> { + let cql = format!( + "DELETE FROM {account_keyspace}.index_propagation_holds WHERE table_id = ? AND index_id = ?" + ); + crate::cassandra_util::execute( + session, + &cql, + cdrs_tokio::query_values!(table_id, index_id), + "release_propagation_hold", + ) + .await +} + +/// Returns `true` if any propagation hold exists for `table_id`. +pub(crate) async fn is_held( + session: &Arc, + account_keyspace: &str, + table_id: &str, +) -> Result { + let cql = format!( + "SELECT index_id FROM {account_keyspace}.index_propagation_holds WHERE table_id = ? LIMIT 1" + ); + let rows = crate::cassandra_util::query_rows::( + session, + &cql, + cdrs_tokio::query_values!(table_id), + "is_held", + ) + .await?; + Ok(!rows.is_empty()) +} diff --git a/crates/storage-cassandra/src/update_table.rs b/crates/storage-cassandra/src/update_table.rs index 322c2afe..c749a3f5 100644 --- a/crates/storage-cassandra/src/update_table.rs +++ b/crates/storage-cassandra/src/update_table.rs @@ -499,7 +499,6 @@ impl CassandraEngine { if let Some(create) = &update.create { let (index_id, _) = &gsi_creates[create_idx]; create_idx += 1; - // TODO: backfill existing items into the new GSI. self.create_index_data_table( &account_ks, index_id, @@ -509,6 +508,39 @@ impl CassandraEngine { base_attr_defs, ) .await?; + crate::propagation_hold::take_propagation_hold( + &self.session_arc(), + &account_ks, + &table_id, + index_id, + ) + .await?; + let backfill_result = self + .backfill_gsi( + &account_ks, + &table_id, + index_id, + &create.key_schema, + effective_attr_defs, + &base_key_schema, + &base_attr_defs, + &create.projection, + ) + .await; + if let Err(e) = crate::propagation_hold::release_propagation_hold( + &self.session_arc(), + &account_ks, + &table_id, + index_id, + ) + .await + { + tracing::error!( + "failed to release propagation hold for index {index_id} \ + on table {table_id}: {e}" + ); + } + backfill_result?; } if update.delete.is_some() { let index_id = &gsi_deletes[delete_idx]; diff --git a/crates/storage-cassandra/src/workers.rs b/crates/storage-cassandra/src/workers.rs index 3561bd85..4ff2db05 100644 --- a/crates/storage-cassandra/src/workers.rs +++ b/crates/storage-cassandra/src/workers.rs @@ -302,6 +302,12 @@ async fn gsi_process_batch( let id: uuid::Uuid = crate::cassandra_util::get_column(&row, "id", "gsi_worker")?; let table_id: String = crate::cassandra_util::get_column(&row, "table_id", "gsi_worker")?; + + // Skip rows for tables that are currently being backfilled. + if crate::propagation_hold::is_held(&engine.session, keyspace, &table_id).await? { + continue; + } + let old_json: Option = row.get_by_name("old_item").ok().flatten(); let new_json: Option = row.get_by_name("new_item").ok().flatten(); let ctx_json: String = diff --git a/tests/test_transaction_operations.py b/tests/test_transaction_operations.py index 2be358a1..d00380e3 100755 --- a/tests/test_transaction_operations.py +++ b/tests/test_transaction_operations.py @@ -326,6 +326,26 @@ def test_transact_write_conditional_put_fail(dynamodb_client, hash_table): resp = dynamodb_client.get_item(TableName=hash_table, Key={"pk": {"S": "cp-2"}}) assert resp["Item"]["v"]["S"] == "old" +def test_transact_write_conditional_put_fail_diag(dynamodb_client, hash_table): + dynamodb_client.put_item(TableName=hash_table, Item={"pk": {"S": "cp-2"}, "v": {"S": "old"}}) + # Verify the item is readable back via get_item + resp = dynamodb_client.get_item(TableName=hash_table, Key={"pk": {"S": "cp-2"}}) + print(f"\nget_item after put: {resp.get('Item')}") + # Now try the transaction + try: + dynamodb_client.transact_write_items( + TransactItems=[{"Put": { + "TableName": hash_table, + "Item": {"pk": {"S": "cp-2"}, "v": {"S": "new"}}, + "ConditionExpression": "attribute_not_exists(pk)", + }}] + ) + print("NO EXCEPTION - transaction succeeded (wrong)") + # Check what's in the item now + resp2 = dynamodb_client.get_item(TableName=hash_table, Key={"pk": {"S": "cp-2"}}) + print(f"item after transaction: {resp2.get('Item')}") + except Exception as e: + print(f"EXCEPTION: {type(e).__name__}: {e}") # --------------------------------------------------------------------------- # TransactWriteItems — size limit and condition edge cases From 303cd0362fde2b30f38c834f37c5a24b101c6a55 Mon Sep 17 00:00:00 2001 From: Joel Shepherd Date: Wed, 9 Sep 2026 22:28:46 +0000 Subject: [PATCH 16/48] chore(storage-cassandra): cargo fmt --- crates/storage-cassandra/src/data/ddl.rs | 7 +-- .../src/data/transactions.rs | 47 ++++++++++++++----- 2 files changed, 38 insertions(+), 16 deletions(-) diff --git a/crates/storage-cassandra/src/data/ddl.rs b/crates/storage-cassandra/src/data/ddl.rs index 555832e0..4998c238 100644 --- a/crates/storage-cassandra/src/data/ddl.rs +++ b/crates/storage-cassandra/src/data/ddl.rs @@ -434,7 +434,9 @@ impl CassandraEngine { base_attr_defs: &[extenddb_core::types::AttributeDefinition], projection: &extenddb_core::types::Projection, ) -> Result<(), StorageError> { - use crate::data::index::{insert_index_row_multi, item_has_index_keys, project_item_for_index}; + use crate::data::index::{ + insert_index_row_multi, item_has_index_keys, project_item_for_index, + }; use cdrs_tokio::consistency::Consistency; use cdrs_tokio::query::BatchQueryBuilder; @@ -530,8 +532,7 @@ impl CassandraEngine { // Get the token of the last pk to use as the next page cursor. if let Some(last_row) = rows.last() { - let pk: String = - crate::cassandra_util::get_column(last_row, "pk", "backfill_gsi")?; + let pk: String = crate::cassandra_util::get_column(last_row, "pk", "backfill_gsi")?; let token_query = format!( "SELECT token(pk) AS tok FROM {account_keyspace}.{base_table} WHERE pk = ?" ); diff --git a/crates/storage-cassandra/src/data/transactions.rs b/crates/storage-cassandra/src/data/transactions.rs index 74793cc0..bf3ff60f 100644 --- a/crates/storage-cassandra/src/data/transactions.rs +++ b/crates/storage-cassandra/src/data/transactions.rs @@ -556,7 +556,11 @@ impl CassandraEngine { // Re-apply expression on top of winner's item. item = winner.clone(); expression::apply_update_validated( - actions, &mut item, maps, &[], &[], + actions, + &mut item, + maps, + &[], + &[], ) .map_err(|e| { CancellationReason::validation_error(e.to_string()) @@ -568,7 +572,11 @@ impl CassandraEngine { existing = None; item = (*key).clone(); expression::apply_update_validated( - actions, &mut item, maps, &[], &[], + actions, + &mut item, + maps, + &[], + &[], ) .map_err(|e| { CancellationReason::validation_error(e.to_string()) @@ -938,8 +946,7 @@ impl CassandraEngine { // it to commit or roll back. Returning the PREPARE-state item // (key-only placeholder) would cause condition expressions like // attribute_not_exists(pk) to fail against stale data with no ALL_OLD. - let prepared_txn_id: Option = - row.get_by_name("prepared_txn_id").ok().flatten(); + let prepared_txn_id: Option = row.get_by_name("prepared_txn_id").ok().flatten(); if prepared_txn_id.is_some() && prepared_txn_id != skip_txn_id { return self .wait_for_commit_and_read(key_info, key) @@ -1013,9 +1020,12 @@ impl CassandraEngine { // stale replica that has cleared prepared_txn_id but not yet // propagated the updated item_data from commit_put_or_update. if prepared_txn_id.is_none() && last_committed.is_some() { - let item_data: String = - get_column::(&row, "item_data", "wait_for_commit_and_read") - .map_err(|e| CancellationReason::validation_error(e.to_string()))?; + let item_data: String = get_column::( + &row, + "item_data", + "wait_for_commit_and_read", + ) + .map_err(|e| CancellationReason::validation_error(e.to_string()))?; let item: Item = serde_json::from_str(&item_data) .map_err(|e| CancellationReason::validation_error(e.to_string()))?; return Ok(Some(item)); @@ -1185,14 +1195,17 @@ impl CassandraEngine { .. } => { // Re-fetch and re-apply update (idempotent) - let existing = self.fetch_item_for_transaction(key_info, key, Some(txn_id)).await?; + let existing = self + .fetch_item_for_transaction(key_info, key, Some(txn_id)) + .await?; let mut final_item = existing.clone().unwrap_or_else(|| (*key).clone()); expression::apply_update_validated(actions, &mut final_item, maps, &[], &[]) .map_err(|e| StorageError::Internal(e.to_string()))?; self.commit_put_or_update(key_info, &final_item, txn_id_bytes, txn_timestamp) .await?; - self.commit_sync_indexes(key_info, existing.as_ref(), &final_item).await + self.commit_sync_indexes(key_info, existing.as_ref(), &final_item) + .await } TransactWriteOp::Delete { key_info, key, .. } => { let keyspace = self.account_keyspace(&key_info.account_id); @@ -1353,8 +1366,8 @@ impl CassandraEngine { old_item: Option<&Item>, new_item: &Item, ) -> Result<(), StorageError> { - use cdrs_tokio::query::BatchQueryBuilder; use cdrs_tokio::consistency::Consistency; + use cdrs_tokio::query::BatchQueryBuilder; let catalog_keyspace = self.catalog_keyspace(); let data_keyspace = self.account_keyspace(&key_info.account_id); @@ -1370,7 +1383,9 @@ impl CassandraEngine { return Ok(()); } - let sys_delay = self.gsi_default_delay_ms.load(std::sync::atomic::Ordering::Relaxed); + let sys_delay = self + .gsi_default_delay_ms + .load(std::sync::atomic::Ordering::Relaxed); let mut batch = BatchQueryBuilder::new().with_consistency(Consistency::LocalQuorum); super::index::sync_indexes( &mut batch, @@ -1396,7 +1411,11 @@ impl CassandraEngine { .await?; self.session - .batch(batch.build().map_err(|e| StorageError::Internal(e.to_string()))?) + .batch( + batch + .build() + .map_err(|e| StorageError::Internal(e.to_string()))?, + ) .await .map_err(|e| StorageError::Internal(format!("commit_sync_indexes batch: {e}")))?; @@ -1443,7 +1462,9 @@ impl CassandraEngine { // We need to check if this item was created during PREPARE (created_to_prepare=true) // or if it was an existing item. Fetch the item to check. - let existing = self.fetch_item_for_transaction(key_info, key, Some(txn_id)).await?; + let existing = self + .fetch_item_for_transaction(key_info, key, Some(txn_id)) + .await?; // If item doesn't exist, it's already been cleaned up (idempotent) if existing.is_none() { From 529036865f74a106023a92369d2f2502564b2668 Mon Sep 17 00:00:00 2001 From: Joel Shepherd Date: Thu, 10 Sep 2026 17:47:43 +0000 Subject: [PATCH 17/48] chore(storage-cassandra): cargo fmt and clippy --- crates/storage-cassandra/src/admin_store.rs | 2 +- .../storage-cassandra/src/cassandra_util.rs | 2 + crates/storage-cassandra/src/catalog_store.rs | 6 +- crates/storage-cassandra/src/config.rs | 16 +- .../storage-cassandra/src/data/condition.rs | 2 +- .../storage-cassandra/src/data/data_engine.rs | 2 +- crates/storage-cassandra/src/data/ddl.rs | 9 +- .../storage-cassandra/src/data/delete_item.rs | 16 +- crates/storage-cassandra/src/data/index.rs | 8 +- crates/storage-cassandra/src/data/mod.rs | 6 +- .../src/data/put_get_item.rs | 54 +++-- crates/storage-cassandra/src/data/query.rs | 184 +++++++++--------- .../src/data/query_helpers.rs | 4 +- .../src/data/transaction_ledger.rs | 4 +- .../src/data/transactions.rs | 122 +++++------- crates/storage-cassandra/src/data/ttl.rs | 11 +- .../storage-cassandra/src/data/update_item.rs | 6 +- crates/storage-cassandra/src/engine.rs | 8 +- crates/storage-cassandra/src/gsi_queue.rs | 1 + crates/storage-cassandra/src/lib.rs | 26 +-- .../storage-cassandra/src/metadata_engine.rs | 2 +- crates/storage-cassandra/src/migrations.rs | 4 +- crates/storage-cassandra/src/stream_engine.rs | 11 +- crates/storage-cassandra/src/stream_util.rs | 10 +- crates/storage-cassandra/src/ttl_worker.rs | 17 +- crates/storage-cassandra/src/update_table.rs | 30 ++- crates/storage-cassandra/src/workers.rs | 3 +- 27 files changed, 273 insertions(+), 293 deletions(-) diff --git a/crates/storage-cassandra/src/admin_store.rs b/crates/storage-cassandra/src/admin_store.rs index d820ed84..bf32e345 100755 --- a/crates/storage-cassandra/src/admin_store.rs +++ b/crates/storage-cassandra/src/admin_store.rs @@ -189,7 +189,7 @@ impl extenddb_storage::management_store::AdminStore for CassandraCatalogStore { } } -/// Verify a bcrypt password on a blocking thread (same logic as server::password). +/// Verify a bcrypt password on a blocking thread (same logic as `server::password`). async fn verify_bcrypt(password: String, hash: String) -> bool { tokio::task::spawn_blocking(move || bcrypt::verify(password, &hash).unwrap_or(false)) .await diff --git a/crates/storage-cassandra/src/cassandra_util.rs b/crates/storage-cassandra/src/cassandra_util.rs index 93b0bedb..1e1d10c5 100644 --- a/crates/storage-cassandra/src/cassandra_util.rs +++ b/crates/storage-cassandra/src/cassandra_util.rs @@ -300,6 +300,7 @@ where /// Convert millisecond timestamp to seconds as `f64` (for `creation_date_time` fields). #[allow(clippy::cast_precision_loss)] +#[must_use] pub fn millis_to_seconds_f64(timestamp_millis: i64) -> f64 { timestamp_millis as f64 / 1_000.0 } @@ -309,6 +310,7 @@ pub fn millis_to_seconds_f64(timestamp_millis: i64) -> f64 { /// `SystemTime::as_millis()` returns `u128`; this cast is safe for all /// timestamps within the range of `i64` (until year 292,277,026). #[allow(clippy::cast_possible_truncation)] +#[must_use] pub fn now_millis() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) diff --git a/crates/storage-cassandra/src/catalog_store.rs b/crates/storage-cassandra/src/catalog_store.rs index 0e1057a7..79ccd626 100644 --- a/crates/storage-cassandra/src/catalog_store.rs +++ b/crates/storage-cassandra/src/catalog_store.rs @@ -40,6 +40,7 @@ pub struct CassandraCatalogStore { impl CassandraCatalogStore { /// Create a new catalog store wrapping the given session. + #[must_use] pub fn new( session: Arc, keyspace_prefix: String, @@ -56,6 +57,7 @@ impl CassandraCatalogStore { } /// Create a new catalog store with a pre-loaded encryption key. + #[must_use] pub fn with_encryption_key( session: Arc, keyspace_prefix: String, @@ -73,11 +75,13 @@ impl CassandraCatalogStore { } /// Borrow the underlying session (escape hatch for callers not yet migrated). + #[must_use] pub fn session(&self) -> &Arc { &self.session } /// Get the cached encryption key. Returns `None` if not loaded at startup. + #[must_use] pub fn encryption_key(&self) -> Option<&Arc> { self.encryption_key.as_ref() } @@ -93,7 +97,7 @@ impl CassandraCatalogStore { } /// Ensure an account keyspace exists (idempotent). - /// Creates the keyspace with NetworkTopologyStrategy if it doesn't exist. + /// Creates the keyspace with `NetworkTopologyStrategy` if it doesn't exist. pub(crate) async fn ensure_account_keyspace(&self, account_id: &str) -> OpResult<()> { let keyspace_name = self.account_keyspace(account_id); diff --git a/crates/storage-cassandra/src/config.rs b/crates/storage-cassandra/src/config.rs index 97b97854..d631fd7b 100644 --- a/crates/storage-cassandra/src/config.rs +++ b/crates/storage-cassandra/src/config.rs @@ -25,7 +25,7 @@ pub struct CassandraStorageConfig { #[serde(default = "default_replication_factor")] pub replication_factor: u32, - /// Datacenter name for NetworkTopologyStrategy (default: "datacenter1") + /// Datacenter name for `NetworkTopologyStrategy` (default: "datacenter1") #[serde(default = "default_datacenter")] pub datacenter: String, @@ -33,8 +33,8 @@ pub struct CassandraStorageConfig { #[serde(default = "default_max_connections")] pub max_connections: u32, - /// Cached connection string (JDBC-style: host1,host2/keyspace_prefix) - /// This is computed after deserialization and cached for connection_config() + /// Cached connection string (JDBC-style: `host1,host2/keyspace_prefix`) + /// This is computed after deserialization and cached for `connection_config()` #[serde(skip)] pub cached_connection_string: Option, @@ -61,7 +61,7 @@ fn default_max_connections() -> u32 { } impl CassandraStorageConfig { - /// Build the connection string in JDBC-style format: host1,host2/keyspace_prefix + /// Build the connection string in JDBC-style format: `host1,host2/keyspace_prefix` fn build_connection_string(&self) -> String { let hosts = self.contact_points.join(","); format!("{}/{}", hosts, self.keyspace_prefix) @@ -76,14 +76,15 @@ impl CassandraStorageConfig { } /// Rebuild the cached connection string. - /// Use this after modifying contact_points or keyspace_prefix. + /// Use this after modifying `contact_points` or `keyspace_prefix`. pub fn rebuild_cached_connection_string(&mut self) { self.cached_connection_string = Some(self.build_connection_string()); } /// Parse a JDBC-style connection string into contact points and keyspace prefix. - /// Format: "host1:port1,host2:port2/keyspace_prefix" - /// Returns (contact_points, keyspace_prefix) + /// Format: "`host1:port1,host2:port2/keyspace_prefix`" + /// Returns (`contact_points`, `keyspace_prefix`) + #[must_use] pub fn parse_connection_string(conn_str: &str) -> (Vec, String) { if let Some((hosts, keyspace)) = conn_str.split_once('/') { let contact_points = hosts.split(',').map(|s| s.trim().to_string()).collect(); @@ -96,6 +97,7 @@ impl CassandraStorageConfig { } /// Create default config programmatically + #[must_use] pub fn new(contact_points: Vec) -> Self { let mut config = Self { contact_points, diff --git a/crates/storage-cassandra/src/data/condition.rs b/crates/storage-cassandra/src/data/condition.rs index 49f180d4..8e7d3518 100644 --- a/crates/storage-cassandra/src/data/condition.rs +++ b/crates/storage-cassandra/src/data/condition.rs @@ -13,7 +13,7 @@ use std::collections::BTreeMap; /// Returns `Ok(())` if the condition passes or is `None`. /// Returns `Err(StorageError::ConditionFailed)` if the condition fails. /// -/// For non-existent items, pass an empty BTreeMap as the item. +/// For non-existent items, pass an empty `BTreeMap` as the item. pub(crate) fn check_condition( condition: Option<&Expr>, item: &BTreeMap, diff --git a/crates/storage-cassandra/src/data/data_engine.rs b/crates/storage-cassandra/src/data/data_engine.rs index f1ba41e9..3d56981c 100644 --- a/crates/storage-cassandra/src/data/data_engine.rs +++ b/crates/storage-cassandra/src/data/data_engine.rs @@ -1,7 +1,7 @@ // Copyright 2026 ExtendDB contributors // SPDX-License-Identifier: Apache-2.0 -//! DataEngine trait implementation for Cassandra. +//! `DataEngine` trait implementation for Cassandra. //! TODO: Implement data operations. use extenddb_core::expression::{Expr, ExpressionMaps, KeyCondition, UpdateAction}; diff --git a/crates/storage-cassandra/src/data/ddl.rs b/crates/storage-cassandra/src/data/ddl.rs index 4998c238..b3c1216f 100644 --- a/crates/storage-cassandra/src/data/ddl.rs +++ b/crates/storage-cassandra/src/data/ddl.rs @@ -19,6 +19,7 @@ pub(crate) fn data_table_name(table_id: &str) -> String { } /// CQL table name for a GSI/LSI data table. +#[must_use] pub fn index_table_name(index_id: &str) -> String { format!("index_{}", index_id.replace('-', "_")) } @@ -313,10 +314,10 @@ impl CassandraEngine { /// Create a GSI/LSI data table in Cassandra. /// - /// GSI tables use (pk, sk_*, base_pk, base_sk_*) structure where: - /// - pk is the partition key (index PK) - /// - sk_* are clustering keys for ordering (index SK) - /// - base_pk, base_sk_* are clustering keys for uniqueness + /// GSI tables use (`pk`, `sk_*`, `base_pk`, `base_sk_*`) structure where: + /// - `pk` is the partition key (index PK) + /// - `sk_*` are clustering keys for ordering (index SK) + /// - `base_pk`, `base_sk`_* are clustering keys for uniqueness /// /// This differs from PostgreSQL where base keys come before index SK /// in the PRIMARY KEY constraint. Cassandra needs index SK as clustering diff --git a/crates/storage-cassandra/src/data/delete_item.rs b/crates/storage-cassandra/src/data/delete_item.rs index 45e89a59..45f14ef1 100644 --- a/crates/storage-cassandra/src/data/delete_item.rs +++ b/crates/storage-cassandra/src/data/delete_item.rs @@ -23,7 +23,7 @@ use crate::stream_util::stream_record_statement; /// its claim; `mutation_timestamp` is pinned immediately after the claim is /// taken, so a resumed request's cells lose to anything a later owner commits /// via Paxos (its item cells can still beat the older stored image, which is -/// why EFFECTS_APPLYING recovery fences on the owner rather than the image). +/// why `EFFECTS_APPLYING` recovery fences on the owner rather than the image). pub(crate) const TTL_REQUEST_CLAIM_SECONDS: u32 = 120; /// Lifetime of the base-row claim the expiration worker holds across its @@ -355,7 +355,9 @@ impl CassandraEngine { return Err(error); } - let async_enqueued = if !indexes.is_empty() { + let async_enqueued = if indexes.is_empty() { + 0 + } else { match super::index::enqueue_async_indexes( &self.session, &mut batch, @@ -375,8 +377,6 @@ impl CassandraEngine { return Err(error); } } - } else { - 0 }; if let Some(config) = ttl_config.as_ref() @@ -565,7 +565,9 @@ impl CassandraEngine { return Err(error); } - let async_enqueued = if !indexes.is_empty() { + let async_enqueued = if indexes.is_empty() { + 0 + } else { match super::index::enqueue_async_indexes( &self.session, &mut batch, @@ -585,8 +587,6 @@ impl CassandraEngine { return Err(error); } } - } else { - 0 }; if let Some(config) = ttl_config.as_ref() @@ -676,7 +676,7 @@ impl CassandraEngine { .await? .response_body() .ok() - .and_then(|body| body.into_rows()) + .and_then(cdrs_tokio::frame::message_response::ResponseBody::into_rows) .and_then(|rows| rows.into_iter().next()) } else { let query = diff --git a/crates/storage-cassandra/src/data/index.rs b/crates/storage-cassandra/src/data/index.rs index 782a6a13..301c2c92 100644 --- a/crates/storage-cassandra/src/data/index.rs +++ b/crates/storage-cassandra/src/data/index.rs @@ -122,9 +122,9 @@ async fn fetch_indexes_for_table_at( .collect() } -/// Fetch a single index by table_id and index_name (hot path for query routing). +/// Fetch a single index by `table_id` and `index_name` (hot path for query routing). /// -/// Uses the PRIMARY KEY ((table_id), index_name) for efficient single-row lookup. +/// Uses the `PRIMARY KEY ((table_id), index_name)` for efficient single-row lookup. pub async fn fetch_index_by_name( table_id: &str, index_name: &str, @@ -560,7 +560,7 @@ pub(crate) fn insert_index_row_multi( Ok(()) } -/// Convert a `SortKeyValue` to a cdrs_tokio bound `Value`. +/// Convert a `SortKeyValue` to a `cdrs_tokio` bound `Value`. pub(crate) fn sk_to_value(sk: &SortKeyValue) -> cdrs_tokio::types::value::Value { match sk { SortKeyValue::S(s) => s.as_str().into(), @@ -575,7 +575,7 @@ pub(crate) fn sk_to_value(sk: &SortKeyValue) -> cdrs_tokio::types::value::Value /// /// Called by: /// - `process_control_plane_transitions` when table is DELETING → deleted -/// - `update_table` when GSI is deleted via UpdateTable API +/// - `update_table` when GSI is deleted via `UpdateTable` API /// /// Returns list of index IDs that were deleted (for caller logging/tracking). pub(crate) async fn delete_indexes_for_table( diff --git a/crates/storage-cassandra/src/data/mod.rs b/crates/storage-cassandra/src/data/mod.rs index 94f3bf81..f421996e 100644 --- a/crates/storage-cassandra/src/data/mod.rs +++ b/crates/storage-cassandra/src/data/mod.rs @@ -57,7 +57,7 @@ pub(crate) fn json_to_item(text: String) -> Result { serde_json::from_str(&text).map_err(|e| StorageError::Internal(e.to_string())) } -/// Convert a DynamoDB numeric value (`BigDecimal`) into the cdrs-tokio +/// Convert a DynamoDB numeric value (`BigDecimal`) into the `cdrs_tokio` /// `Decimal` wire type. /// /// DynamoDB's `N` type is an arbitrary-precision decimal. Cassandra's `decimal` @@ -74,7 +74,7 @@ pub(crate) fn bigdecimal_to_cql_decimal( cdrs_tokio::types::decimal::Decimal::new(unscaled, scale_i32) } -/// Bind a DynamoDB numeric value (`BigDecimal`) as a Cassandra `decimal` bound +/// Bind a `DynamoDB` numeric value (`BigDecimal`) as a Cassandra `decimal` bound /// parameter `Value`. /// /// This replaces the previous workaround that bound `N` values as strings, @@ -112,7 +112,7 @@ pub(crate) async fn query_with_pk_sk( } .map_err(|e| StorageError::Internal(format!("Query failed: {e}"))) } -/// Execute a query with pk, sort key, and item_data, returning the result. +/// Execute a query with pk, sort key, and `item_data`, returning the result. /// /// Helper for INSERT/UPDATE operations. pub(crate) async fn query_with_pk_sk_item( diff --git a/crates/storage-cassandra/src/data/put_get_item.rs b/crates/storage-cassandra/src/data/put_get_item.rs index ce6afea8..359000f7 100644 --- a/crates/storage-cassandra/src/data/put_get_item.rs +++ b/crates/storage-cassandra/src/data/put_get_item.rs @@ -210,10 +210,9 @@ impl CassandraEngine { if indexes.is_empty() && stream_stmt.is_none() && ttl_config.is_none() { // Fast path: no batch needed. let insert_query = format!( - "INSERT INTO {}.{} \ - (pk, {}, item_data) \ - VALUES (?, ?, ?)", - data_keyspace, ddb_table, sk_col + "INSERT INTO {data_keyspace}.{ddb_table} \ + (pk, {sk_col}, item_data) \ + VALUES (?, ?, ?)" ); query_with_pk_sk_item( &self.session, @@ -231,17 +230,15 @@ impl CassandraEngine { // cells do — no separate release round trip is needed on success. let insert_cql = if ttl_config.is_some() { format!( - "INSERT INTO {}.{} \ - (pk, {}, item_data, prepared_txn_id, prepared_txn_timestamp) \ - VALUES (?, ?, ?, null, null)", - data_keyspace, ddb_table, sk_col + "INSERT INTO {data_keyspace}.{ddb_table} \ + (pk, {sk_col}, item_data, prepared_txn_id, prepared_txn_timestamp) \ + VALUES (?, ?, ?, null, null)" ) } else { format!( - "INSERT INTO {}.{} \ - (pk, {}, item_data) \ - VALUES (?, ?, ?)", - data_keyspace, ddb_table, sk_col + "INSERT INTO {data_keyspace}.{ddb_table} \ + (pk, {sk_col}, item_data) \ + VALUES (?, ?, ?)" ) }; let insert_qv = cdrs_tokio::query::QueryValues::SimpleValues(vec![ @@ -266,7 +263,9 @@ impl CassandraEngine { )?; } - let async_enqueued = if !indexes.is_empty() { + let async_enqueued = if indexes.is_empty() { + 0 + } else { super::index::enqueue_async_indexes( &self.session, &mut batch, @@ -278,8 +277,6 @@ impl CassandraEngine { sys_delay, ) .await? - } else { - 0 }; if let Some(config) = ttl_config.as_ref() { @@ -428,10 +425,9 @@ impl CassandraEngine { if indexes.is_empty() && stream_stmt.is_none() && ttl_config.is_none() { // Fast path: no batch needed. let insert_query = format!( - "INSERT INTO {}.{} \ + "INSERT INTO {data_keyspace}.{ddb_table} \ (pk, item_data) \ - VALUES (?, ?)", - data_keyspace, ddb_table + VALUES (?, ?)" ); self.session .query_with_values( @@ -446,17 +442,15 @@ impl CassandraEngine { // (see the sort-key path for the timestamp reasoning). let insert_cql = if ttl_config.is_some() { format!( - "INSERT INTO {}.{} \ + "INSERT INTO {data_keyspace}.{ddb_table} \ (pk, item_data, prepared_txn_id, prepared_txn_timestamp) \ - VALUES (?, ?, null, null)", - data_keyspace, ddb_table + VALUES (?, ?, null, null)" ) } else { format!( - "INSERT INTO {}.{} \ + "INSERT INTO {data_keyspace}.{ddb_table} \ (pk, item_data) \ - VALUES (?, ?)", - data_keyspace, ddb_table + VALUES (?, ?)" ) }; let insert_qv = cdrs_tokio::query::QueryValues::SimpleValues(vec![ @@ -480,7 +474,9 @@ impl CassandraEngine { )?; } - let async_enqueued = if !indexes.is_empty() { + let async_enqueued = if indexes.is_empty() { + 0 + } else { super::index::enqueue_async_indexes( &self.session, &mut batch, @@ -492,8 +488,6 @@ impl CassandraEngine { sys_delay, ) .await? - } else { - 0 }; if let Some(config) = ttl_config.as_ref() { @@ -690,7 +684,9 @@ impl CassandraEngine { )?; } - let async_enqueued = if !indexes.is_empty() { + let async_enqueued = if indexes.is_empty() { + 0 + } else { super::index::enqueue_async_indexes( &self.session, &mut batch, @@ -702,8 +698,6 @@ impl CassandraEngine { sys_delay, ) .await? - } else { - 0 }; if let Some(stmt) = stream_stmt { diff --git a/crates/storage-cassandra/src/data/query.rs b/crates/storage-cassandra/src/data/query.rs index 49c42c91..b3e59587 100644 --- a/crates/storage-cassandra/src/data/query.rs +++ b/crates/storage-cassandra/src/data/query.rs @@ -8,7 +8,7 @@ use extenddb_core::types::{Item, ScalarAttributeType, TableKeyInfo}; use extenddb_storage::error::StorageError; use extenddb_storage::util::{SortKeyValue, parse_sk, pk_to_text, sk_column, sk_info}; -/// Extension trait for SortKeyValue to get scalar type. +/// Extension trait for `SortKeyValue` to get scalar type. trait SortKeyValueExt { fn scalar_type(&self) -> ScalarAttributeType; } @@ -34,7 +34,7 @@ use crate::cassandra_util; /// Extra pagination bind values for index queries. /// -/// Cassandra variant of PostgreSQL's PaginationBinds. Unlike PostgreSQL which uses +/// Cassandra variant of PostgreSQL's `PaginationBinds`. Unlike PostgreSQL which uses /// OR clauses, Cassandra requires splitting into two queries when paginating through /// index results with base table key tie-breakers. enum PaginationBinds { @@ -48,13 +48,13 @@ enum PaginationBinds { BasePkAndSk { pk_text: String, sk: SortKeyValue }, } -/// Compute upper bound for begins_with on strings. +/// Compute upper bound for `begins_with` on strings. /// Appends the maximum Unicode codepoint to create an exclusive upper bound. fn string_upper_bound(prefix: &str) -> String { format!("{prefix}\u{10FFFF}") } -/// Compute upper bound for begins_with on binary data. +/// Compute upper bound for `begins_with` on binary data. /// Returns `None` when the prefix is empty or all-0xFF (no finite upper bound). fn binary_upper_bound(prefix: &[u8]) -> Option> { let mut upper = prefix.to_vec(); @@ -71,7 +71,7 @@ fn binary_upper_bound(prefix: &[u8]) -> Option> { /// Helper to determine base table sort key info for index queries. /// -/// Matches PostgreSQL pattern where base_sk_info is derived from base_key_schema. +/// Matches PostgreSQL pattern where `base_sk_info` is derived from `base_key_schema`. /// Used for ORDER BY (sub-sort when index SKs equal) and pagination (compound keys). fn base_sk_info( key_info: &TableKeyInfo, @@ -86,13 +86,13 @@ fn base_sk_info( } impl CassandraEngine { - /// Implementation of DataEngine::query. + /// Implementation of `DataEngine::query`. #[allow(clippy::too_many_arguments)] pub(crate) async fn query_impl( &self, key_info: &TableKeyInfo, key_condition: &KeyCondition, - _maps: &ExpressionMaps, + expression_maps: &ExpressionMaps, forward: bool, limit: Option, exclusive_start_key: Option<&Item>, @@ -123,7 +123,7 @@ impl CassandraEngine { }; // Step 1: Resolve partition key value — composite for multi-part HASH keys. - let pk_av = resolve_expr_to_av(&key_condition.pk_value, _maps)?; + let pk_av = resolve_expr_to_av(&key_condition.pk_value, expression_maps)?; let pk_text = if key_condition.extra_pk_conditions.is_empty() { pk_to_text(&pk_av)?.into_owned() } else { @@ -132,7 +132,7 @@ impl CassandraEngine { // the same encoding; we must match it exactly. let mut parts = vec![pk_to_text(&pk_av)?.into_owned()]; for (_, extra_expr) in &key_condition.extra_pk_conditions { - let av = resolve_expr_to_av(extra_expr, _maps)?; + let av = resolve_expr_to_av(extra_expr, expression_maps)?; parts.push(pk_to_text(&av)?.into_owned()); } extenddb_storage::util::encode_netstring_composite(&parts) @@ -170,7 +170,7 @@ impl CassandraEngine { query.push_str(&format!(" AND {sk_col} {op_str} ?")); // Resolve and parse SK value - let sk_av = resolve_expr_to_av(value, _maps)?; + let sk_av = resolve_expr_to_av(value, expression_maps)?; let sk_val = parse_sk(&sk_av, sk_type)?; Some((sk_col, vec![sk_val])) } @@ -178,15 +178,15 @@ impl CassandraEngine { query.push_str(&format!(" AND {sk_col} >= ? AND {sk_col} <= ?")); // Resolve and parse both bounds - let low_av = resolve_expr_to_av(low, _maps)?; - let high_av = resolve_expr_to_av(high, _maps)?; + let low_av = resolve_expr_to_av(low, expression_maps)?; + let high_av = resolve_expr_to_av(high, expression_maps)?; let low_sk = parse_sk(&low_av, sk_type)?; let high_sk = parse_sk(&high_av, sk_type)?; Some((sk_col, vec![low_sk, high_sk])) } SortKeyCondition::BeginsWith { prefix, .. } => { // Resolve prefix - let prefix_av = resolve_expr_to_av(prefix, _maps)?; + let prefix_av = resolve_expr_to_av(prefix, expression_maps)?; let prefix_sk = parse_sk(&prefix_av, sk_type)?; // Compute upper bound based on type @@ -483,11 +483,8 @@ impl CassandraEngine { ( PaginationBinds::BasePkOnly { pk_text: base_pk_text, - }, - None, - ) - | ( - PaginationBinds::BasePkAndSk { + } + | PaginationBinds::BasePkAndSk { pk_text: base_pk_text, .. }, @@ -543,84 +540,85 @@ impl CassandraEngine { } // Query 2: next SK values (only if we haven't reached limit yet) - if all_rows.len() < fetch_limit && sk_info_opt.is_some() { - if let Some(start_sk) = start_sk { - let remaining = fetch_limit - all_rows.len(); - let query2 = if let Some((_, sk_type)) = sk_info_opt { - let sk_col = sk_column(sk_type); - let cmp = if forward { ">" } else { "<" }; - let order_clause = if let Some((_, base_sk_type)) = &base_sk_info_val { - let base_sk_col = format!("base_{}", sk_column(*base_sk_type)); - let dir = if forward { "ASC" } else { "DESC" }; - format!( - " ORDER BY {sk_col} {dir}, base_pk {dir}, {base_sk_col} {dir} LIMIT {remaining}" - ) - } else { - format!( - " ORDER BY {} {}, base_pk {} LIMIT {}", - sk_col, - if forward { "ASC" } else { "DESC" }, - if forward { "ASC" } else { "DESC" }, - remaining - ) - }; - format!("{query} AND {sk_col} {cmp} ?{order_clause}") + if all_rows.len() < fetch_limit + && sk_info_opt.is_some() + && let Some(start_sk) = start_sk + { + let remaining = fetch_limit - all_rows.len(); + let query2 = if let Some((_, sk_type)) = sk_info_opt { + let sk_col = sk_column(sk_type); + let cmp = if forward { ">" } else { "<" }; + let order_clause = if let Some((_, base_sk_type)) = &base_sk_info_val { + let base_sk_col = format!("base_{}", sk_column(*base_sk_type)); + let dir = if forward { "ASC" } else { "DESC" }; + format!( + " ORDER BY {sk_col} {dir}, base_pk {dir}, {base_sk_col} {dir} LIMIT {remaining}" + ) } else { - // Hash-only index - let order_clause = if let Some((_, base_sk_type)) = &base_sk_info_val { - let base_sk_col = format!("base_{}", sk_column(*base_sk_type)); - format!( - " ORDER BY base_pk {}, {} {} LIMIT {}", - if forward { "ASC" } else { "DESC" }, - base_sk_col, - if forward { "ASC" } else { "DESC" }, - remaining - ) - } else { - format!( - " ORDER BY base_pk {} LIMIT {}", - if forward { "ASC" } else { "DESC" }, - remaining - ) - }; - format!("{query} AND base_pk > ?{order_clause}") + format!( + " ORDER BY {} {}, base_pk {} LIMIT {}", + sk_col, + if forward { "ASC" } else { "DESC" }, + if forward { "ASC" } else { "DESC" }, + remaining + ) }; - - let rows2 = match &pagination_binds { - PaginationBinds::BaseSkOnly { .. } - | PaginationBinds::BasePkOnly { .. } - | PaginationBinds::BasePkAndSk { .. } - if sk_info_opt.is_some() => - { - query_with_pk_sk( - &self.session_arc(), - &query2, - &pk_text, - start_sk, - "next_sk", - ) - .await? - } - PaginationBinds::BasePkOnly { - pk_text: base_pk_text, - } - | PaginationBinds::BasePkAndSk { - pk_text: base_pk_text, - .. - } => { - cassandra_util::query_rows( - &self.session_arc(), - &query2, - cdrs_tokio::query_values!(pk_text.as_str(), base_pk_text.as_str()), - "next_sk", - ) - .await? - } - _ => Vec::new(), + format!("{query} AND {sk_col} {cmp} ?{order_clause}") + } else { + // Hash-only index + let order_clause = if let Some((_, base_sk_type)) = &base_sk_info_val { + let base_sk_col = format!("base_{}", sk_column(*base_sk_type)); + format!( + " ORDER BY base_pk {}, {} {} LIMIT {}", + if forward { "ASC" } else { "DESC" }, + base_sk_col, + if forward { "ASC" } else { "DESC" }, + remaining + ) + } else { + format!( + " ORDER BY base_pk {} LIMIT {}", + if forward { "ASC" } else { "DESC" }, + remaining + ) }; - all_rows.extend(rows2); - } // end if let Some(start_sk) - } + format!("{query} AND base_pk > ?{order_clause}") + }; + + let rows2 = match &pagination_binds { + PaginationBinds::BaseSkOnly { .. } + | PaginationBinds::BasePkOnly { .. } + | PaginationBinds::BasePkAndSk { .. } + if sk_info_opt.is_some() => + { + query_with_pk_sk( + &self.session_arc(), + &query2, + &pk_text, + start_sk, + "next_sk", + ) + .await? + } + PaginationBinds::BasePkOnly { + pk_text: base_pk_text, + } + | PaginationBinds::BasePkAndSk { + pk_text: base_pk_text, + .. + } => { + cassandra_util::query_rows( + &self.session_arc(), + &query2, + cdrs_tokio::query_values!(pk_text.as_str(), base_pk_text.as_str()), + "next_sk", + ) + .await? + } + _ => Vec::new(), + }; + all_rows.extend(rows2); + } // end if let Some(start_sk) all_rows } else { diff --git a/crates/storage-cassandra/src/data/query_helpers.rs b/crates/storage-cassandra/src/data/query_helpers.rs index 243058b3..2d55b83f 100644 --- a/crates/storage-cassandra/src/data/query_helpers.rs +++ b/crates/storage-cassandra/src/data/query_helpers.rs @@ -97,7 +97,7 @@ pub(super) async fn query_with_pk_sk_sk( } /// Execute query with index PK, index SK, base PK, and base SK parameters. -/// Used for LSI pagination: WHERE pk=? AND sk_*=? AND base_pk=? AND base_sk_*>? +/// Used for LSI pagination: `WHERE pk=? AND sk_*=? AND base_pk=? AND base_sk_*>?` pub(super) async fn query_with_pk_sk_pk_sk( session: &Arc, query: &str, @@ -209,7 +209,7 @@ pub(super) async fn query_with_pk_sk_pk_sk( } /// Execute query with index PK, index SK, and base PK parameters. -/// Used for GSI pagination: WHERE pk=? AND sk_*=? AND base_pk>? +/// Used for GSI pagination: `WHERE pk=? AND sk_*=? AND base_pk>?` pub(super) async fn query_with_pk_sk_pk( session: &Arc, query: &str, diff --git a/crates/storage-cassandra/src/data/transaction_ledger.rs b/crates/storage-cassandra/src/data/transaction_ledger.rs index 7ae624ba..6b541866 100644 --- a/crates/storage-cassandra/src/data/transaction_ledger.rs +++ b/crates/storage-cassandra/src/data/transaction_ledger.rs @@ -20,6 +20,7 @@ pub enum TransactionState { } impl TransactionState { + #[must_use] pub fn as_str(&self) -> &'static str { match self { Self::Preparing => "PREPARING", @@ -28,6 +29,7 @@ impl TransactionState { } } + #[must_use] pub fn from_str(s: &str) -> Option { match s { "PREPARING" => Some(Self::Preparing), @@ -59,7 +61,7 @@ pub struct LedgerOp { pub table_id: String, /// Composite partition key text (as stored in Cassandra `pk` column) pub pk: String, - /// Sort key column name ("sk_s", "sk_n", "sk_b"), if table has a sort key + /// Sort key column name ("`sk_s`", "`sk_n`", "`sk_b`"), if table has a sort key pub sk_col: Option, /// Sort key value serialized as a JSON string, if table has a sort key pub sk_val: Option, diff --git a/crates/storage-cassandra/src/data/transactions.rs b/crates/storage-cassandra/src/data/transactions.rs index bf3ff60f..389c139a 100644 --- a/crates/storage-cassandra/src/data/transactions.rs +++ b/crates/storage-cassandra/src/data/transactions.rs @@ -3,7 +3,9 @@ //! DynamoDB transaction implementations for Cassandra backend. +use cdrs_tokio::consistency::Consistency; use cdrs_tokio::frame::Envelope; +use cdrs_tokio::query::BatchQueryBuilder; use cdrs_tokio::types::IntoRustByName; use cdrs_tokio::types::value::Bytes; use extenddb_core::expression::{self, ExpressionMaps}; @@ -15,6 +17,7 @@ use extenddb_storage::TransactGetOp; use extenddb_storage::TransactWriteOp; use extenddb_storage::error::StorageError; use extenddb_storage::util::{SortKeyValue, composite_pk_to_text, parse_sk, sk_column, sk_info}; +use std::time::Duration; use uuid::Uuid; use crate::CassandraEngine; @@ -39,8 +42,8 @@ impl CassandraEngine { /// Implementation of `DataEngine::transact_get_items`. /// /// Uses a two-phase read protocol to ensure serializability: - /// 1. Read all items with timestamps and prepared_txn_id - /// 2. Verify timestamps and prepared_txn_id haven't changed + /// 1. Read all items with timestamps and `prepared_txn_id` + /// 2. Verify timestamps and `prepared_txn_id` haven't changed pub(crate) async fn transact_get_items_impl( &self, ops: &[TransactGetOp<'_>], @@ -134,7 +137,7 @@ impl CassandraEngine { /// Read an item with full metadata for Phase 1. /// - /// Returns: Option<(Item, last_committed_txn_timestamp, prepared_txn_id)> + /// Returns: Option<(Item, `last_committed_txn_timestamp`, `prepared_txn_id`)> async fn read_item_with_metadata( &self, op: &TransactGetOp<'_>, @@ -212,7 +215,6 @@ impl CassandraEngine { /// Implements a two-phase commit protocol using Lightweight Transactions (LWT): /// 1. PREPARE: Mark all items with transaction ID using LWT /// 2. COMMIT/ROLLBACK: Apply or revert changes atomically - /// Implementation of TransactWriteItems. pub(crate) async fn transact_write_items_impl( &self, ops: &[TransactWriteOp<'_>], @@ -544,44 +546,37 @@ impl CassandraEngine { Ok(()) => break, Err(r) if r.code == "TransactionConflict" && attempt < 5 => { let committed = self.wait_for_commit_and_read(key_info, key).await?; - match committed { - Some(winner) => { - eval_condition( - *condition, - &winner, - maps, - *return_values_on_ccf, - Some(&winner), - )?; - // Re-apply expression on top of winner's item. - item = winner.clone(); - expression::apply_update_validated( - actions, - &mut item, - maps, - &[], - &[], - ) - .map_err(|e| { - CancellationReason::validation_error(e.to_string()) - })?; - existing = Some(winner); - } - None => { - // Winner rolled back; retry the insert. - existing = None; - item = (*key).clone(); - expression::apply_update_validated( - actions, - &mut item, - maps, - &[], - &[], - ) - .map_err(|e| { - CancellationReason::validation_error(e.to_string()) - })?; - } + if let Some(winner) = committed { + eval_condition( + *condition, + &winner, + maps, + *return_values_on_ccf, + Some(&winner), + )?; + // Re-apply expression on top of winner's item. + item = winner.clone(); + expression::apply_update_validated( + actions, + &mut item, + maps, + &[], + &[], + ) + .map_err(|e| CancellationReason::validation_error(e.to_string()))?; + existing = Some(winner); + } else { + // Winner rolled back; retry the insert. + existing = None; + item = (*key).clone(); + expression::apply_update_validated( + actions, + &mut item, + maps, + &[], + &[], + ) + .map_err(|e| CancellationReason::validation_error(e.to_string()))?; } } Err(r) => return Err(r), @@ -725,7 +720,7 @@ impl CassandraEngine { } } -/// Evaluate a condition expression, returning a CancellationReason on failure. +/// Evaluate a condition expression, returning a `CancellationReason` on failure. fn eval_condition( condition: Option<&extenddb_core::expression::Expr>, item: &std::collections::BTreeMap, @@ -751,7 +746,7 @@ fn eval_condition( Ok(()) } -/// Extract account_id from a TransactWriteOp. +/// Extract `account_id` from a `TransactWriteOp`. fn transact_write_op_account_id<'a>(op: &'a TransactWriteOp<'_>) -> &'a str { match op { TransactWriteOp::Put { key_info, .. } @@ -762,7 +757,7 @@ fn transact_write_op_account_id<'a>(op: &'a TransactWriteOp<'_>) -> &'a str { } impl CassandraEngine { - /// Serialize TransactWriteOps to JSON for ledger storage. + /// Serialize `TransactWriteOps` to JSON for ledger storage. /// Build initial `LedgerOp`s from the request ops. /// /// Written to the ledger before PREPARE starts. Contains pk/sk so a crash @@ -882,7 +877,6 @@ impl CassandraEngine { // If the LWT was not applied, a concurrent request won the race. // Read back what was stored and return the appropriate error. - use cdrs_tokio::types::IntoRustByName; let applied: bool = result .response_body() .ok() @@ -913,7 +907,7 @@ impl CassandraEngine { Ok(()) } - /// Fetch an item for transaction (reads item_data and prepared_txn_id). + /// Fetch an item for transaction (reads `item_data` and `prepared_txn_id`). /// /// If `skip_txn_id` is `Some(id)`, rows prepared by that transaction are /// read directly (the caller owns that PREPARE and must not wait on itself). @@ -979,7 +973,6 @@ impl CassandraEngine { key_info: &TableKeyInfo, key: &Item, ) -> Result, CancellationReason> { - use std::time::Duration; const MAX_POLLS: u32 = 20; const POLL_SLEEP_MS: u64 = 50; @@ -1042,7 +1035,7 @@ impl CassandraEngine { }) } - /// Check partition_max_delete_timestamp for new items. + /// Check `partition_max_delete_timestamp` for new items. async fn check_partition_max_delete_timestamp( &self, key_info: &TableKeyInfo, @@ -1093,8 +1086,8 @@ impl CassandraEngine { /// PREPARE an item: mark with transaction ID using LWT. /// - /// For existing items: UPDATE with IF prepared_txn_id = null - /// For new items: INSERT with IF NOT EXISTS + created_to_prepare=true + /// For existing items: UPDATE with IF `prepared_txn_id` = null + /// For new items: INSERT with IF NOT EXISTS + `created_to_prepare=true` async fn prepare_item( &self, key_info: &TableKeyInfo, @@ -1103,8 +1096,6 @@ impl CassandraEngine { txn_timestamp: i64, is_new_item: bool, ) -> Result<(), CancellationReason> { - use cdrs_tokio::types::value::Bytes; - let keyspace = self.account_keyspace(&key_info.account_id); let ddb_table = super::ddl::data_table_name(&key_info.table_id); let pk_text = composite_pk_to_text(key, &key_info.key_schema) @@ -1169,16 +1160,14 @@ impl CassandraEngine { /// COMMIT a single operation: apply changes. /// - /// For Put/Update: Write final item_data, clear prepared_txn_id, set last_committed_txn_timestamp - /// For Delete: Update partition_max_delete_timestamp, then delete the item + /// For Put/Update: Write final `item_data`, clear `prepared_txn_id`, set `last_committed_txn_timestamp` + /// For Delete: Update `partition_max_delete_timestamp`, then delete the item async fn commit_single_operation( &self, op: &TransactWriteOp<'_>, txn_id: Uuid, txn_timestamp: i64, ) -> Result<(), StorageError> { - use cdrs_tokio::types::value::Bytes; - let txn_id_bytes = Bytes::new(txn_id.as_bytes().to_vec()); match op { @@ -1234,7 +1223,7 @@ impl CassandraEngine { })?; // If that failed (column already has a value), try updating only if our timestamp is higher - if !check_lwt_applied(&result, "partition_max update").is_ok() { + if check_lwt_applied(&result, "partition_max update").is_err() { let update_max_ts_query = format!( "UPDATE {keyspace}.{ddb_table} SET partition_max_delete_timestamp = ? WHERE pk = ? \ IF partition_max_delete_timestamp < ?" @@ -1353,7 +1342,7 @@ impl CassandraEngine { Ok(()) } - /// Sync LSI/GSI index rows after a successful commit_put_or_update. + /// Sync LSI/GSI index rows after a successful `commit_put_or_update`. /// /// Runs as a separate non-LWT batch after the base-row LWT commit, since /// Cassandra does not allow LWT statements to be batched with writes to @@ -1366,9 +1355,6 @@ impl CassandraEngine { old_item: Option<&Item>, new_item: &Item, ) -> Result<(), StorageError> { - use cdrs_tokio::consistency::Consistency; - use cdrs_tokio::query::BatchQueryBuilder; - let catalog_keyspace = self.catalog_keyspace(); let data_keyspace = self.account_keyspace(&key_info.account_id); @@ -1428,15 +1414,13 @@ impl CassandraEngine { /// ROLLBACK a single operation: clean up prepared state. /// - /// For items created during PREPARE (created_to_prepare=true): DELETE the item - /// For existing items: Clear prepared_txn_id to restore unprepared state + /// For items created during PREPARE (`created_to_prepare=true)`: DELETE the item + /// For existing items: Clear `prepared_txn_id` to restore unprepared state async fn rollback_single_operation( &self, op: &TransactWriteOp<'_>, txn_id: Uuid, ) -> Result<(), StorageError> { - use cdrs_tokio::types::value::Bytes; - // ConditionCheck doesn't prepare anything, so nothing to rollback if matches!(op, TransactWriteOp::ConditionCheck { .. }) { return Ok(()); @@ -1817,7 +1801,7 @@ impl CassandraEngine { } } -/// Resolve sort key value and column name from key_info + item. +/// Resolve sort key value and column name from `key_info` + item. fn resolve_sk( key_info: &TableKeyInfo, key: &Item, @@ -1834,7 +1818,7 @@ fn resolve_sk( } } -/// Resolve sort key value and column name from a TransactGetOp. +/// Resolve sort key value and column name from a `TransactGetOp`. fn resolve_sk_get( op: &TransactGetOp<'_>, ) -> Result<(Option, Option), StorageError> { @@ -1873,7 +1857,7 @@ fn ledger_sk( /// Reconstruct a `SortKeyValue` from the text representation stored in `LedgerOp`. /// -/// `sk_col` encodes the type ("sk_s" → S, "sk_n" → N, "sk_b" → B). +/// `sk_col` encodes the type ("`sk_s`" → S, "`sk_n`" → N, "`sk_b`" → B). fn ledger_sk_to_sort_key(sk_col: &str, sk_val: &str) -> Result { match sk_col { "sk_s" => Ok(SortKeyValue::S(sk_val.to_owned())), diff --git a/crates/storage-cassandra/src/data/ttl.rs b/crates/storage-cassandra/src/data/ttl.rs index 0081d64f..5d0cb464 100644 --- a/crates/storage-cassandra/src/data/ttl.rs +++ b/crates/storage-cassandra/src/data/ttl.rs @@ -9,6 +9,7 @@ //! the normal item mutation path. use cdrs_tokio::query::BatchQueryBuilder; +use cdrs_tokio::types::IntoRustByName; use extenddb_core::types::{AttributeValue, Item, TableKeyInfo}; use extenddb_storage::error::StorageError; @@ -497,7 +498,6 @@ pub(crate) async fn insert_ttl_entry( ) .await?; if let Some(row) = existing.first() { - use cdrs_tokio::types::IntoRustByName; let state: Option = row.get_by_name("state").ok().flatten(); if TtlWorkState::parse(state.as_deref())? == TtlWorkState::Pending { ensure_ttl_bucket_registration(engine, account_keyspace, table_id, generation, entry) @@ -541,7 +541,7 @@ pub(crate) async fn insert_ttl_entry( "TTL reconcile LWT returned no result".to_owned(), )); }; - use cdrs_tokio::types::IntoRustByName; + let applied: bool = row .get_r_by_name("[applied]") .map_err(|error| StorageError::Internal(format!("Parse TTL reconcile result: {error}")))?; @@ -872,8 +872,6 @@ pub(crate) async fn abort_claimed_ttl_work( } fn work_lwt_applied(result: &cdrs_tokio::frame::Envelope) -> Result { - use cdrs_tokio::types::IntoRustByName; - let rows = result .response_body() .map_err(|error| StorageError::Internal(format!("Parse TTL work LWT: {error}")))? @@ -943,8 +941,6 @@ pub(crate) async fn load_due_ttl_work( now: i64, limit: usize, ) -> Result, StorageError> { - use cdrs_tokio::types::IntoRustByName; - if limit == 0 { return Ok(Vec::new()); } @@ -1065,8 +1061,6 @@ pub(crate) async fn load_generation_work( generation: uuid::Uuid, limit: usize, ) -> Result, StorageError> { - use cdrs_tokio::types::IntoRustByName; - let generation_bytes = cdrs_tokio::types::value::Bytes::new(generation.as_bytes().to_vec()); let mut work = Vec::new(); for (bucket, shard) in @@ -1191,7 +1185,6 @@ pub(crate) async fn clear_ttl_generation( .await?; let mut partition_drained = true; for row in rows { - use cdrs_tokio::types::IntoRustByName; let state: Option = row.get_by_name("state").ok().flatten(); if TtlWorkState::parse(state.as_deref())? != TtlWorkState::Pending { partition_drained = false; diff --git a/crates/storage-cassandra/src/data/update_item.rs b/crates/storage-cassandra/src/data/update_item.rs index c9b29670..91d939f1 100644 --- a/crates/storage-cassandra/src/data/update_item.rs +++ b/crates/storage-cassandra/src/data/update_item.rs @@ -464,7 +464,9 @@ impl CassandraEngine { )?; } - let async_enqueued = if !indexes.is_empty() { + let async_enqueued = if indexes.is_empty() { + 0 + } else { super::index::enqueue_async_indexes( &self.session, &mut batch, @@ -476,8 +478,6 @@ impl CassandraEngine { sys_delay, ) .await? - } else { - 0 }; if let (Some(_), Some(attribute)) = (ttl_claim, ttl_attribute) { diff --git a/crates/storage-cassandra/src/engine.rs b/crates/storage-cassandra/src/engine.rs index 13e934f1..612ce9d1 100644 --- a/crates/storage-cassandra/src/engine.rs +++ b/crates/storage-cassandra/src/engine.rs @@ -38,7 +38,7 @@ pub struct CassandraEngine { /// Replication factor for new keyspaces replication_factor: u32, - /// Datacenter name for NetworkTopologyStrategy + /// Datacenter name for `NetworkTopologyStrategy` datacenter: String, /// Wakes the control plane poller when a table enters CREATING or DELETING state @@ -163,26 +163,30 @@ impl CassandraEngine { } /// Get a reference to the Cassandra session. + #[must_use] pub fn session(&self) -> &CassandraSession { &self.session } /// Get an Arc clone of the Cassandra session. + #[must_use] pub fn session_arc(&self) -> Arc { Arc::clone(&self.session) } /// Get the catalog keyspace name. + #[must_use] pub fn catalog_keyspace(&self) -> String { format!("{}_catalog", self.keyspace_prefix) } /// Get the account keyspace name for a given account ID. + #[must_use] pub fn account_keyspace(&self, account_id: &str) -> String { format!("{}_account_{}", self.keyspace_prefix, account_id) } - /// Create a keyspace with NetworkTopologyStrategy. + /// Create a keyspace with `NetworkTopologyStrategy`. pub async fn create_keyspace(&self, keyspace_name: &str) -> Result<(), StorageError> { let cql = format!( "CREATE KEYSPACE IF NOT EXISTS {} WITH replication = {{'class': 'NetworkTopologyStrategy', '{}': {}}}", diff --git a/crates/storage-cassandra/src/gsi_queue.rs b/crates/storage-cassandra/src/gsi_queue.rs index a53ce761..b07d86c7 100644 --- a/crates/storage-cassandra/src/gsi_queue.rs +++ b/crates/storage-cassandra/src/gsi_queue.rs @@ -65,6 +65,7 @@ pub struct GsiQueue { } impl GsiQueue { + #[must_use] pub fn new() -> Arc { Arc::new(Self { notify: Arc::new(Notify::new()), diff --git a/crates/storage-cassandra/src/lib.rs b/crates/storage-cassandra/src/lib.rs index 57c57467..a04f34ee 100644 --- a/crates/storage-cassandra/src/lib.rs +++ b/crates/storage-cassandra/src/lib.rs @@ -39,6 +39,10 @@ pub use catalog_store::CassandraCatalogStore; pub use config::CassandraStorageConfig; pub use engine::{CassandraEngine, CassandraSession}; +use cdrs_tokio::authenticators::StaticPasswordAuthenticatorProvider; +use cdrs_tokio::cluster::NodeTcpConfigBuilder; +use cdrs_tokio::cluster::session::{SessionBuilder, TcpSessionBuilder}; +use cdrs_tokio::load_balancing::RoundRobinLoadBalancingStrategy; use cdrs_tokio::types::IntoRustByName; use extenddb_storage::hooks::{ServerRuntimeHooks, WorkerContext}; use extenddb_storage::server_components::{BackendError, ServerComponents}; @@ -66,7 +70,7 @@ impl ServerRuntimeHooks for CassandraRuntimeHooks { let catalog_store = ctx.catalog_store.clone(); let control_plane = tokio::spawn(async move { workers::poll_control_plane_transitions(storage_for_poller, cp_notify, catalog_store) - .await + .await; }); let engine_for_recovery = self.engine.clone(); @@ -76,7 +80,7 @@ impl ServerRuntimeHooks for CassandraRuntimeHooks { std::time::Duration::from_secs(60), std::time::Duration::from_secs(30), ) - .await + .await; }); // Read the initial GSI delay immediately so the atomic is correct from @@ -95,10 +99,10 @@ impl ServerRuntimeHooks for CassandraRuntimeHooks { .ok() .flatten(), }; - if let Some(val) = initial_delay { - if let Ok(ms) = val.parse::() { - gsi_delay.store(ms, std::sync::atomic::Ordering::Relaxed); - } + if let Some(val) = initial_delay + && let Ok(ms) = val.parse::() + { + gsi_delay.store(ms, std::sync::atomic::Ordering::Relaxed); } let catalog_store_for_gsi = ctx.catalog_store.clone(); let gsi_delay_poller = @@ -114,12 +118,12 @@ impl ServerRuntimeHooks for CassandraRuntimeHooks { let ttl_metrics = ctx.metrics.clone(); let ttl_shutdown = ctx.shutdown.clone(); let ttl_cleanup = tokio::spawn(async move { - ttl_worker::ttl_cleanup_worker(ttl_engine, ttl_metrics, ttl_shutdown).await + ttl_worker::ttl_cleanup_worker(ttl_engine, ttl_metrics, ttl_shutdown).await; }); let ttl_repair_engine = self.engine.clone(); let ttl_repair_shutdown = ctx.shutdown.clone(); let ttl_repair = tokio::spawn(async move { - ttl_worker::ttl_repair_worker(ttl_repair_engine, ttl_repair_shutdown).await + ttl_worker::ttl_repair_worker(ttl_repair_engine, ttl_repair_shutdown).await; }); vec![ @@ -147,6 +151,7 @@ impl ServerRuntimeHooks for CassandraRuntimeHooks { /// ```ignore /// extenddb_storage::set_backend(extenddb_storage_cassandra::backend())?; /// ``` +#[must_use] pub fn backend() -> extenddb_storage::Backend { extenddb_storage::Backend { name: "cassandra", @@ -207,11 +212,6 @@ async fn make_catalog_store_from_connection_string( return Err("No contact points provided".to_string()); } - use cdrs_tokio::authenticators::StaticPasswordAuthenticatorProvider; - use cdrs_tokio::cluster::NodeTcpConfigBuilder; - use cdrs_tokio::cluster::session::{SessionBuilder, TcpSessionBuilder}; - use cdrs_tokio::load_balancing::RoundRobinLoadBalancingStrategy; - let mut node_builder = NodeTcpConfigBuilder::new(); for contact_point in &contact_points { node_builder = node_builder.with_contact_point(contact_point.clone().into()); diff --git a/crates/storage-cassandra/src/metadata_engine.rs b/crates/storage-cassandra/src/metadata_engine.rs index 195c8721..2510e195 100755 --- a/crates/storage-cassandra/src/metadata_engine.rs +++ b/crates/storage-cassandra/src/metadata_engine.rs @@ -762,7 +762,7 @@ impl MetadataEngine for CassandraEngine { let row = result .response_body() .ok() - .and_then(|body| body.into_rows()) + .and_then(cdrs_tokio::frame::message_response::ResponseBody::into_rows) .and_then(|rows| rows.into_iter().next()); let Some(row) = row else { break }; applied = row.get_r_by_name("[applied]").unwrap_or(false); diff --git a/crates/storage-cassandra/src/migrations.rs b/crates/storage-cassandra/src/migrations.rs index fee1dd19..9b8957b4 100644 --- a/crates/storage-cassandra/src/migrations.rs +++ b/crates/storage-cassandra/src/migrations.rs @@ -12,13 +12,13 @@ //! - Description: Text after double underscore (e.g., `initial_schema`) //! //! Flyway is a popular database migration tool that uses this naming convention. -//! See: https://flywaydb.org/documentation/concepts/migrations#naming +//! See: //! //! ## Migration Tracking //! //! Applied migrations are tracked in the `schema_history` table: //! - `version` (int): Extracted from filename (V001 → 1) -//! - `description` (text): Extracted from filename (V001__initial_schema.cql → initial_schema) +//! - `description` (text): Extracted from filename (`V001__initial_schema.cql` → `initial_schema`) //! - `applied_at` (timestamp): When the migration was applied //! //! Migrations are applied in order by version number. Already-applied migrations diff --git a/crates/storage-cassandra/src/stream_engine.rs b/crates/storage-cassandra/src/stream_engine.rs index 88077488..27520696 100755 --- a/crates/storage-cassandra/src/stream_engine.rs +++ b/crates/storage-cassandra/src/stream_engine.rs @@ -3,6 +3,7 @@ //! `StreamEngine` trait implementation for `CassandraEngine`. +use cdrs_tokio::types::IntoRustByName; use extenddb_core::types::{ SequenceNumberRange, Shard, StreamDescription, StreamRecord, StreamStatus, StreamSummary, StreamViewType, @@ -19,7 +20,7 @@ impl CassandraEngine { self.account_keyspace(account_id) } - /// Resolve the account keyspace for a shard by looking up the table_id + /// Resolve the account keyspace for a shard by looking up the `table_id` /// (embedded in the shard ID) via the secondary index on `tables.table_id`. /// /// Shard ID format: `shardId-{table_id}-{index:012}` @@ -43,7 +44,6 @@ impl CassandraEngine { .await .map_err(|e| StorageError::Internal(e.to_string()))?; - use cdrs_tokio::types::IntoRustByName; let account_id: String = result .response_body() .map_err(|e| StorageError::Internal(e.to_string()))? @@ -85,7 +85,6 @@ impl CassandraEngine { .await .map_err(|e| StorageError::Internal(e.to_string()))?; - use cdrs_tokio::types::IntoRustByName; let table_account_id: Option = result .response_body() .map_err(|e| StorageError::Internal(e.to_string()))? @@ -105,7 +104,7 @@ impl CassandraEngine { impl StreamEngine for CassandraEngine { /// Not used for atomic writes — see ADR-0008. Stream records are injected - /// directly into LOGGED BATCHes via `stream_record_statement` in each write path. + /// directly into LOGGED `BATCHes` via `stream_record_statement` in each write path. fn write_stream_record( &self, _account_id: &str, @@ -172,7 +171,6 @@ impl StreamEngine for CassandraEngine { } .map_err(|e| StorageError::Internal(e.to_string()))?; - use cdrs_tokio::types::IntoRustByName; let rows = result .response_body() .map_err(|e| StorageError::Internal(e.to_string()))? @@ -229,7 +227,6 @@ impl StreamEngine for CassandraEngine { .await .map_err(|e| StorageError::Internal(e.to_string()))?; - use cdrs_tokio::types::IntoRustByName; let rows = result .response_body() .map_err(|e| StorageError::Internal(e.to_string()))? @@ -512,7 +509,6 @@ impl StreamEngine for CassandraEngine { .await .map_err(|e| StorageError::Internal(e.to_string()))?; - use cdrs_tokio::types::IntoRustByName; let rows = result .response_body() .map_err(|e| StorageError::Internal(e.to_string()))? @@ -577,7 +573,6 @@ impl StreamEngine for CassandraEngine { .await .map_err(|e| StorageError::Internal(e.to_string()))?; - use cdrs_tokio::types::IntoRustByName; let rows = result .response_body() .map_err(|e| StorageError::Internal(e.to_string()))? diff --git a/crates/storage-cassandra/src/stream_util.rs b/crates/storage-cassandra/src/stream_util.rs index f31f9962..a24af64b 100644 --- a/crates/storage-cassandra/src/stream_util.rs +++ b/crates/storage-cassandra/src/stream_util.rs @@ -32,7 +32,8 @@ pub struct HybridClock { } impl HybridClock { - /// Create a new HybridClock with the given node ID. + /// Create a new `HybridClock` with the given node ID. + #[must_use] pub fn new(node_id: u16) -> Self { Self { last_timestamp_ms: 0, @@ -46,6 +47,7 @@ impl HybridClock { /// Instance identifiers (e.g. hostname + listening address) ensure that multiple ExtendDB /// instances, whether on the same or different hosts, get distinct node IDs, in turn allowing /// tie-breaking for conflicting clock values. + #[must_use] pub fn derive_node_id(instance_id: &str) -> u16 { let hash = crc32fast::hash(instance_id.as_bytes()); u16::try_from(hash % 9999 + 1).unwrap_or(1) @@ -86,10 +88,10 @@ impl HybridClock { } } -/// Thread-safe HybridClock handle. +/// Thread-safe `HybridClock` handle. pub type SharedHlc = Arc>; -/// Create a new SharedHlc, deriving node ID from ExtendDB's instance ID. +/// Create a new `SharedHlc`, deriving node ID from `ExtendDB`'s instance ID. pub fn new_shared_hlc(instance_id: &str) -> SharedHlc { let node_id = HybridClock::derive_node_id(instance_id); tracing::info!(node_id, instance_id, "HybridClock initialized"); @@ -101,6 +103,7 @@ pub fn new_shared_hlc(instance_id: &str) -> SharedHlc { /// Uses CRC32 hash modulo shard count, matching the PostgreSQL reference /// implementation. Uses `table_id` (UUID) rather than `table_name` to /// prevent shard ID collisions after table deletion and recreation. +#[must_use] pub fn assign_shard_id(partition_key: &str, table_id: &str) -> String { let hash = crc32fast::hash(partition_key.as_bytes()); let idx = (hash as usize) % SHARDS_PER_STREAM as usize; @@ -171,6 +174,7 @@ pub fn stream_record_statement( /// TTL expiration persists its identity before applying effects so that a retry /// rewrites the same record instead of publishing a second visible `REMOVE`. #[allow(clippy::too_many_arguments)] +#[must_use] pub fn stream_record_statement_with_identity( account_keyspace: &str, table_id: &str, diff --git a/crates/storage-cassandra/src/ttl_worker.rs b/crates/storage-cassandra/src/ttl_worker.rs index c65d15a9..1edfbecb 100644 --- a/crates/storage-cassandra/src/ttl_worker.rs +++ b/crates/storage-cassandra/src/ttl_worker.rs @@ -132,12 +132,11 @@ pub async fn reconcile_pending_older_than( // row is parsed: a row with a readable id but unreadable // payload must still be paged past, or it stalls the cursor on // itself forever. - let id: uuid::Uuid = match row.get_r_by_name("id") { - Ok(id) => id, - Err(_) => { - tracing::warn!("TTL worker: outbox row with unreadable id skipped"); - continue; - } + let id: uuid::Uuid = if let Ok(id) = row.get_r_by_name("id") { + id + } else { + tracing::warn!("TTL worker: outbox row with unreadable id skipped"); + continue; }; page_cursor = Some(id); let parsed = (|| -> Result<_, StorageError> { @@ -297,7 +296,7 @@ pub async fn reconcile_inflight_repairs_once( let cursor = storage .ttl_repair_scan_cursors .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) + .unwrap_or_else(std::sync::PoisonError::into_inner) .get(&cursor_key) .copied() .unwrap_or(uuid::Uuid::nil()); @@ -367,7 +366,7 @@ pub async fn reconcile_inflight_repairs_once( storage .ttl_repair_scan_cursors .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) + .unwrap_or_else(std::sync::PoisonError::into_inner) .insert(cursor_key, next_cursor); } if !rows.is_empty() { @@ -751,7 +750,7 @@ async fn process_ttl_work_row( sequence_number: storage .hlc .lock() - .unwrap_or_else(|error| error.into_inner()) + .unwrap_or_else(std::sync::PoisonError::into_inner) .generate(), created_at_ms: chrono::Utc::now().timestamp_millis(), region: storage.region.clone(), diff --git a/crates/storage-cassandra/src/update_table.rs b/crates/storage-cassandra/src/update_table.rs index c749a3f5..d24ec250 100644 --- a/crates/storage-cassandra/src/update_table.rs +++ b/crates/storage-cassandra/src/update_table.rs @@ -324,18 +324,14 @@ impl CassandraEngine { &existing, "key_schema", "update_table gsi delete ks", - ) { - if let Ok(del_ks) = serde_json::from_str::< - Vec, - >(&ks_text) - { - if let Some(pos) = surviving_index_key_schemas - .iter() - .position(|s| *s == del_ks) - { - surviving_index_key_schemas.remove(pos); - } - } + ) && let Ok(del_ks) = serde_json::from_str::< + Vec, + >(&ks_text) + && let Some(pos) = surviving_index_key_schemas + .iter() + .position(|s| *s == del_ks) + { + surviving_index_key_schemas.remove(pos); } batch = batch.add_query( @@ -511,26 +507,26 @@ impl CassandraEngine { crate::propagation_hold::take_propagation_hold( &self.session_arc(), &account_ks, - &table_id, + table_id, index_id, ) .await?; let backfill_result = self .backfill_gsi( &account_ks, - &table_id, + table_id, index_id, &create.key_schema, effective_attr_defs, - &base_key_schema, - &base_attr_defs, + base_key_schema, + base_attr_defs, &create.projection, ) .await; if let Err(e) = crate::propagation_hold::release_propagation_hold( &self.session_arc(), &account_ks, - &table_id, + table_id, index_id, ) .await diff --git a/crates/storage-cassandra/src/workers.rs b/crates/storage-cassandra/src/workers.rs index 4ff2db05..e3a41502 100644 --- a/crates/storage-cassandra/src/workers.rs +++ b/crates/storage-cassandra/src/workers.rs @@ -13,7 +13,7 @@ use extenddb_storage::management_store::SettingsStore; use crate::CassandraEngine; /// Poll `index_propagation_delay_ms` from settings every 30 seconds and update -/// the in-memory atomic used by put_item/update_item/delete_item. +/// the in-memory atomic used by `put_item/update_item/delete_item`. pub(crate) async fn poll_gsi_delay( store: Arc, gsi_delay: Arc, @@ -199,6 +199,7 @@ impl Drop for GsiWorkerGuard { /// Spawn GSI propagation workers — one per partition. /// /// Returns a `GsiWorkerGuard`; workers stop when it is dropped. +#[must_use] pub fn spawn_gsi_workers(engine: Arc) -> GsiWorkerGuard { let shutdown = Arc::new(std::sync::atomic::AtomicBool::new(false)); for worker_id in 0..crate::gsi_queue::NUM_WORKERS { From 73babeee20503e625713f7a4805a8a327eef7f12 Mon Sep 17 00:00:00 2001 From: Joel Shepherd Date: Thu, 10 Sep 2026 21:46:44 +0000 Subject: [PATCH 18/48] docs: adding draft RFC for the Cassandra backend along with a barnyard of ADRs for the Cassandra implementation --- .../0011-cassandra-foreign-key-emulation.md | 53 ++ ...assandra-transaction-atomicity-patterns.md | 56 +++ docs/adr/0013-cassandra-keyspace-awareness.md | 64 +++ docs/adr/0014-cassandra-upsert-semantics.md | 59 +++ ...cassandra-account-keyspace-provisioning.md | 70 +++ ...andra-index-table-primary-key-structure.md | 72 +++ ...17-cassandra-transaction-implementation.md | 473 ++++++++++++++++++ .../0018-cassandra-stream-implementation.md | 467 +++++++++++++++++ .../0019-cassandra-logical-backup-restore.md | 51 ++ docs/adr/0020-cassandra-occ-update-item.md | 55 ++ docs/adr/README.md | 21 +- docs/rfcs/draft-cassandra-backend.md | 264 ++++++++++ 12 files changed, 1704 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0011-cassandra-foreign-key-emulation.md create mode 100644 docs/adr/0012-cassandra-transaction-atomicity-patterns.md create mode 100644 docs/adr/0013-cassandra-keyspace-awareness.md create mode 100644 docs/adr/0014-cassandra-upsert-semantics.md create mode 100644 docs/adr/0015-cassandra-account-keyspace-provisioning.md create mode 100644 docs/adr/0016-cassandra-index-table-primary-key-structure.md create mode 100644 docs/adr/0017-cassandra-transaction-implementation.md create mode 100644 docs/adr/0018-cassandra-stream-implementation.md create mode 100644 docs/adr/0019-cassandra-logical-backup-restore.md create mode 100644 docs/adr/0020-cassandra-occ-update-item.md create mode 100644 docs/rfcs/draft-cassandra-backend.md diff --git a/docs/adr/0011-cassandra-foreign-key-emulation.md b/docs/adr/0011-cassandra-foreign-key-emulation.md new file mode 100644 index 00000000..4f076f5e --- /dev/null +++ b/docs/adr/0011-cassandra-foreign-key-emulation.md @@ -0,0 +1,53 @@ +# ADR-0001: Foreign Key Constraint Emulation + +- Status: Accepted +- Date: 2026-06-04 +- Deciders: ExtendDB Cassandra plugin contributors + +## Context + +The PostgreSQL storage implementation relies on database-enforced foreign key constraints to maintain referential integrity in the IAM catalog. For example, creating an IAM user requires a valid account_id, and the PostgreSQL schema declares a foreign key from `iam_users.account_id` to `accounts.account_id`. The database automatically rejects any insert that violates this constraint. + +Apache Cassandra does not support foreign key constraints. The database will accept any write to any table regardless of whether referenced parent entities exist. This creates a risk of orphaned records and data inconsistencies if not handled explicitly in application code. + +## Options Considered + +1. **Application-layer pre-checks** — Before inserting a child entity, execute a SELECT query to verify the parent entity exists. Return `OpError::NotFound` if the parent is missing, matching PostgreSQL FK violation behavior. + +2. **Eventual consistency checks** — Allow writes to proceed without validation, then run periodic background jobs to detect and repair orphaned records. + +3. **Denormalization** — Embed parent entity data in child records to eliminate the need for referential integrity checks. + +## Decision + +Application-layer pre-checks for all child entity creation operations. + +## Rationale + +- Matches PostgreSQL behavior exactly: Operations fail fast with `OpError::NotFound` when the parent entity is missing, providing consistent error semantics across backends. +- IAM operations are infrequent and admin-driven. The extra SELECT overhead (typically <10ms in a local datacenter) is negligible compared to the operation's overall latency budget. +- Eventual consistency checks would allow invalid states to persist temporarily, violating ExtendDB's expectation that IAM operations are immediately consistent. +- Denormalization would break the normalized schema design shared with PostgreSQL and complicate updates when parent entities change. +- Idiomatic Cassandra practice: Pre-checks are the standard pattern for emulating constraints in Cassandra applications (see DataStax documentation on modeling best practices). + +## Consequences + +- Every child entity creation (users, access keys, policy attachments) includes a pre-check SELECT before the main write. +- Helper methods `account_exists()` and `user_exists()` centralize this pattern in `CassandraCatalogStore`. +- Small race condition window: A parent entity could be deleted between the pre-check and the child insert. This is acceptable because: + - IAM deletions are rare + - The race window is milliseconds + - Worst case is an orphaned record, which can be cleaned up via account deletion +- Code duplication: Each foreign key relationship requires explicit pre-check logic, unlike PostgreSQL where the database handles this declaratively. +- Performance impact is minimal: Pre-checks use indexed queries on primary keys, which Cassandra serves from a single replica with sub-10ms latency. + +--- + +## License + +Copyright 2026 ExtendDB contributors. Licensed under the Apache License, Version 2.0. +See [LICENSE](../../LICENSE) for the full text. + +This software is provided "as is" without warranty of any kind. ExtendDB is not +affiliated with, endorsed by, or sponsored by Amazon Web Services. "DynamoDB" is +a trademark of Amazon.com, Inc. diff --git a/docs/adr/0012-cassandra-transaction-atomicity-patterns.md b/docs/adr/0012-cassandra-transaction-atomicity-patterns.md new file mode 100644 index 00000000..9a06f084 --- /dev/null +++ b/docs/adr/0012-cassandra-transaction-atomicity-patterns.md @@ -0,0 +1,56 @@ +# ADR-0002: Transaction and Atomicity Patterns + +- Status: Accepted +- Date: 2026-06-04 +- Deciders: ExtendDB Cassandra plugin contributors + +## Context + +PostgreSQL implements multi-statement ACID transactions via `BEGIN`/`COMMIT` blocks. The PostgreSQL storage layer uses this for operations like `create_user`, which must atomically insert both an `iam_users` row and a `iam_policies` row (for the self-service policy). If either insert fails, the entire transaction rolls back. + +Cassandra does not support multi-statement transactions. Logged batches provide atomicity only when all statements target the same partition key. Lightweight transactions (LWT) with `IF NOT EXISTS` can detect duplicates but cannot be combined with batches. This creates a challenge for operations requiring both duplicate detection and atomicity. + +## Options Considered + +1. **Check-then-batch pattern** — Execute a SELECT to check for duplicates, then use a logged BATCH for the atomic writes. Accept a small race condition window between check and write. + +2. **Sequential LWTs** — Execute each insert with `IF NOT EXISTS` sequentially, rolling back manually on failure. Not truly atomic. + +3. **Compensation pattern** — Allow writes to proceed without duplicate checks, then clean up on failure. Leaves temporary inconsistent state. + +4. **Single LWT** — Combine both operations into a single denormalized record. Breaks schema compatibility with PostgreSQL. + +## Decision + +Check-then-batch pattern for operations requiring both duplicate detection and atomicity. + +## Rationale + +- IAM operations naturally partition by `account_id`: Both `iam_users` and `iam_policies` use `account_id` as their partition key. This enables true atomic batches in Cassandra. +- Race condition window is acceptable: The window between SELECT and BATCH is milliseconds. For infrequent IAM operations, the probability of collision is negligible. +- Sequential LWTs are not atomic: If the second LWT fails, the first write has already committed. Manual compensation requires additional complexity and still leaves a window of inconsistency. +- Compensation pattern introduces eventual consistency: Temporary invalid states violate ExtendDB's IAM consistency expectations. +- Single LWT with denormalization breaks PostgreSQL schema compatibility and complicates queries. +- Idiomatic Cassandra: Check-then-batch is the recommended pattern for operations requiring both validation and atomicity when strict ACID guarantees are not required. + +## Consequences + +- All multi-entity IAM operations follow this pattern: + 1. Pre-check foreign keys (via ADR-0001) + 2. SELECT to check for duplicate primary keys + 3. Logged BATCH to perform atomic writes +- Small race window: Another concurrent operation could insert the same entity between steps 2 and 3. Mitigation: IAM operations are admin-driven and infrequent. Application-layer retry with exponential backoff handles the rare collision. +- Simpler code than compensation: No cleanup logic needed. Either the batch succeeds atomically or it fails atomically. +- Consistency model: Operations are immediately consistent once the batch commits. No eventual consistency delay. +- Cannot use LWT inside batches: Cassandra limitation. Batches are atomic but not isolated, and LWT requires isolation. This is an acceptable tradeoff given IAM operation frequency. + +--- + +## License + +Copyright 2026 ExtendDB contributors. Licensed under the Apache License, Version 2.0. +See [LICENSE](../../LICENSE) for the full text. + +This software is provided "as is" without warranty of any kind. ExtendDB is not +affiliated with, endorsed by, or sponsored by Amazon Web Services. "DynamoDB" is +a trademark of Amazon.com, Inc. diff --git a/docs/adr/0013-cassandra-keyspace-awareness.md b/docs/adr/0013-cassandra-keyspace-awareness.md new file mode 100644 index 00000000..10163654 --- /dev/null +++ b/docs/adr/0013-cassandra-keyspace-awareness.md @@ -0,0 +1,64 @@ +# ADR-0003: Dynamic Keyspace Construction + +- Status: Accepted +- Date: 2026-06-04 +- Deciders: ExtendDB Cassandra plugin contributors + +## Context + +PostgreSQL and Cassandra handle schema namespacing differently: + +- **PostgreSQL**: Database selection happens at connection time via the connection pool URL. SQL queries reference tables without database prefix (`SELECT * FROM accounts`). The application configures which database to use externally to the SQL language. + +- **Cassandra**: Keyspaces are part of the CQL language. Every query must explicitly specify the keyspace (`SELECT * FROM extenddb_catalog.accounts`). There is no implicit "current keyspace" selection in the connection. + +Early implementations hardcoded keyspace names like `extenddb_catalog` directly in query strings. This breaks configurability: test environments, multi-tenant deployments, and custom installations cannot override the keyspace prefix. + +## Options Considered + +1. **Dynamic keyspace construction** — Store `keyspace_prefix` in config, construct keyspace names dynamically via helper methods like `catalog_keyspace()`. Pass keyspace prefix through initialization chain. + +2. **USE statement per connection** — Execute `USE extenddb_catalog` on each session, then write queries without keyspace prefix. Mimics PostgreSQL behavior. + +3. **Query-time keyspace override** — Allow each query to specify keyspace as a parameter, defaulting to config value. + +## Decision + +Dynamic keyspace construction with JDBC-style connection strings. + +## Rationale + +- CQL `USE` statement is fragile: Session pooling and reconnection logic can lose the `USE` context. Cassandra best practice is to always qualify table names with keyspace. +- Explicit keyspace in every query prevents ambiguity: No hidden state. Code review can verify which keyspace is being queried. +- JDBC-style connection strings are familiar: `host1:9042,host2:9042/keyspace_prefix` matches developer expectations from other database drivers. +- Centralized helper methods: `catalog_keyspace()` and `data_keyspace()` ensure consistent keyspace name construction. Changes to naming conventions require updates in only one place. +- Testability: Test fixtures can instantiate storage with custom keyspace prefixes, enabling parallel test execution without keyspace collisions. +- PostgreSQL compatibility: Config structure remains similar. Both backends have a connection string and a schema/keyspace namespace concept. + +## Consequences + +- Config structure includes `keyspace_prefix` field (default: `"extenddb"`). +- Connection string format: `host1:9042,host2:9042/keyspace_prefix`. Parser splits on `/` to extract hosts and keyspace. +- All query strings use helper methods: + ```rust + let query = format!( + "SELECT * FROM {}.accounts WHERE account_id = ?", + self.catalog_keyspace() + ); + ``` +- Helper methods added to `CassandraCatalogStore`: + - `catalog_keyspace()` returns `"{prefix}_catalog"` + - `data_keyspace(account_id)` returns `"{prefix}_data_{account_id}"` +- Migration scripts must support variable keyspace prefixes: Use environment variable substitution or templating. +- No hardcoded keyspace names anywhere in code: Violations break configurability and are caught in code review. + +--- + +## License + +Copyright 2026 ExtendDB contributors. Licensed under the Apache License, Version 2.0. +See [LICENSE](../../LICENSE) for the full text. + +This software is provided "as is" without warranty of any kind. ExtendDB is not +affiliated with, endorsed by, or sponsored by Amazon Web Services. "DynamoDB" is +a trademark of Amazon.com, Inc. diff --git a/docs/adr/0014-cassandra-upsert-semantics.md b/docs/adr/0014-cassandra-upsert-semantics.md new file mode 100644 index 00000000..60236dba --- /dev/null +++ b/docs/adr/0014-cassandra-upsert-semantics.md @@ -0,0 +1,59 @@ +# ADR-0004: Natural UPSERT Semantics + +- Status: Accepted +- Date: 2026-06-04 +- Deciders: ExtendDB Cassandra plugin contributors + +## Context + +PostgreSQL requires explicit `ON CONFLICT ... DO UPDATE` syntax to achieve UPSERT (insert-or-update) behavior. Without this clause, inserting a duplicate primary key results in a unique constraint violation error. + +Cassandra has different semantics: A simple `INSERT` statement will automatically overwrite any existing row with the same primary key. This "last write wins" behavior is fundamental to Cassandra's conflict resolution model. + +Operations like `put_policy` must be idempotent: Calling the operation multiple times with the same policy name should result in the policy being stored, not an error. PostgreSQL achieves this with `ON CONFLICT`, but Cassandra can use a plain `INSERT`. + +## Options Considered + +1. **Natural INSERT behavior** — Use plain `INSERT` statements and rely on Cassandra's built-in overwrite semantics. Simpler code, same idempotent result. + +2. **Explicit UPDATE with conditional INSERT** — Check if row exists via SELECT, then execute UPDATE or INSERT accordingly. Mimics PostgreSQL logic exactly but adds unnecessary complexity. + +3. **Lightweight transaction with IF EXISTS** — Use `UPDATE ... IF EXISTS` to make the upsert explicit. Adds LWT overhead for no behavioral benefit. + +## Decision + +Use natural INSERT behavior for operations requiring UPSERT semantics. + +## Rationale + +- Cassandra's INSERT is already idempotent: Inserting the same primary key twice produces the same end state as inserting once. No need to emulate PostgreSQL's explicit `ON CONFLICT` syntax. +- Simpler code: Single `INSERT` statement instead of `SELECT` + conditional branching + `UPDATE`/`INSERT`. +- Better performance: Plain INSERT is faster than LWT. No consensus round-trip required. +- Idiomatic Cassandra: "Last write wins" is the expected conflict resolution model. Fighting it with conditional logic adds complexity for no gain. +- Same observable behavior: From the caller's perspective, `put_policy` is idempotent regardless of whether the backend uses `ON CONFLICT` or natural overwrite. + +## Consequences + +- Operations like `put_policy_impl` use plain INSERT: + ```rust + let query = format!( + "INSERT INTO {}.iam_policies (account_id, principal_type, principal_name, + policy_name, policy_document, created_at) VALUES (?, ?, ?, ?, ?, toTimestamp(now()))", + catalog_keyspace + ); + ``` +- No `IF NOT EXISTS` check needed: INSERT will succeed regardless of whether the policy already exists. +- Timestamp behavior: `created_at` is overwritten on every INSERT. This differs from PostgreSQL, where `created_at` is preserved on UPDATE. Mitigation: IAM catalog queries do not rely on `created_at` for critical logic. It's informational only. +- Simpler error handling: No need to check `[applied]` column or handle LWT failure cases. +- Divergence from PostgreSQL: Code structure is simpler in Cassandra plugin. This is acceptable because both backends provide the same idempotent guarantees to the caller. + +--- + +## License + +Copyright 2026 ExtendDB contributors. Licensed under the Apache License, Version 2.0. +See [LICENSE](../../LICENSE) for the full text. + +This software is provided "as is" without warranty of any kind. ExtendDB is not +affiliated with, endorsed by, or sponsored by Amazon Web Services. "DynamoDB" is +a trademark of Amazon.com, Inc. diff --git a/docs/adr/0015-cassandra-account-keyspace-provisioning.md b/docs/adr/0015-cassandra-account-keyspace-provisioning.md new file mode 100644 index 00000000..703434e0 --- /dev/null +++ b/docs/adr/0015-cassandra-account-keyspace-provisioning.md @@ -0,0 +1,70 @@ +# ADR-0005: Account Keyspace Provisioning Timing + +- Status: Accepted +- Date: 2026-06-05 +- Deciders: ExtendDB Cassandra plugin contributors + +## Context + +The Cassandra backend uses a keyspace-per-account isolation model ([cassandra-key-decisions.md](../../notes/cassandra-key-decisions.md)). Each ExtendDB account gets its own Cassandra keyspace (e.g., `extenddb_account_123456789012`) to provide operational benefits: storage isolation, noisy neighbor protection, per-account monitoring, independent backups, and configurable replication strategies. + +The PostgreSQL backend uses a single database with all accounts sharing the same schema. Account creation in PostgreSQL is a simple catalog INSERT with no storage initialization. This raises the question: when should Cassandra create the account-specific keyspace? + +Two natural timing points exist: +1. **During account creation** — Create keyspace in `ManagementStore::create_account()` +2. **On first table creation** — Lazy keyspace creation in `TableEngine::create_table()` + +Existing code in `create_table_impl` validates that the account keyspace exists and returns an error if it doesn't, requiring an explicit choice. + +## Options Considered + +1. **Keyspace creation during account creation** — `ManagementStore::create_account_impl()` creates both the catalog entry and the account keyspace in a single operation. + +2. **Lazy keyspace creation on first table** — `TableEngine::create_table_impl()` checks if the account keyspace exists and creates it if missing (idempotent). + +3. **Manual provisioning** — Require operators to explicitly create account keyspaces via separate API or CLI command before creating tables. + +## Decision + +Create account keyspace during account creation (`ManagementStore::create_account_impl`). + +## Rationale + +- The operational benefits of per-account keyspaces (monitoring, backups, replication strategy, storage tiering) require the keyspace to exist as part of complete account provisioning. An account without its keyspace is incomplete from an operational perspective. + +- Account creation is already an administrative control plane operation with relaxed latency requirements (seconds are acceptable). Keyspace creation overhead (schema agreement across cluster) fits naturally here. + +- Matches the architectural intent: The keyspace-per-account design was chosen specifically for operational isolation. Deferring keyspace creation until first table use would delay achieving this isolation. + +- Simplifies table operations: `create_table` can assume the account keyspace exists, keeping data plane operations fast and simple. The existing validation in `create_table_impl` catches operational misconfigurations early. + +- Consistent with "account provisioning" semantics: Creating an account in a multi-tenant system includes allocating the account's isolated storage namespace, not just catalog metadata. + +- Lazy creation would hide keyspace creation costs in the critical path of the first `CreateTable` API call, causing unexpected latency spikes for end users. + +## Consequences + +- `create_account_impl()` must call `ensure_keyspace()` after successfully inserting the account into the catalog. Keyspace creation failure requires rollback of the account catalog entry. + +- Account creation latency increases by the keyspace creation time (~100-500ms for local cluster, up to several seconds for geo-distributed clusters with schema agreement). + +- Empty accounts (created but never used) consume minimal resources: a keyspace with no tables has negligible overhead in Cassandra. + +- Keyspace creation is idempotent (`CREATE KEYSPACE IF NOT EXISTS`), making retry logic simple and safe. + +- Account deletion must include `DROP KEYSPACE` to fully clean up account resources. + +- Operators can configure per-account replication strategies at account creation time, enabling immediate operational controls. + +- Testing simplified: Test fixtures can create accounts and immediately verify keyspace existence without creating tables first. + +--- + +## License + +Copyright 2026 ExtendDB contributors. Licensed under the Apache License, Version 2.0. +See [LICENSE](../../LICENSE) for the full text. + +This software is provided "as is" without warranty of any kind. ExtendDB is not +affiliated with, endorsed by, or sponsored by Amazon Web Services. "DynamoDB" is +a trademark of Amazon.com, Inc. diff --git a/docs/adr/0016-cassandra-index-table-primary-key-structure.md b/docs/adr/0016-cassandra-index-table-primary-key-structure.md new file mode 100644 index 00000000..d0778f69 --- /dev/null +++ b/docs/adr/0016-cassandra-index-table-primary-key-structure.md @@ -0,0 +1,72 @@ +# ADR-0006: Index Data Table PRIMARY KEY Structure + +- Status: Accepted +- Date: 2026-06-16 +- Deciders: ExtendDB Cassandra plugin contributors + +## Context + +Global Secondary Indexes (GSI) and Local Secondary Indexes (LSI) require separate physical data tables to store indexed items with alternate keys. The PostgreSQL backend uses a PRIMARY KEY constraint of `(pk, base_pk, base_sk_*)` where all columns are part of a composite uniqueness constraint. Cassandra's PRIMARY KEY serves a fundamentally different purpose: it defines both the partition key (for data distribution) and clustering keys (for ordering within a partition). A direct translation of the PostgreSQL schema results in incorrect data distribution and query patterns. + +The index table stores: +- `pk` — index partition key (computed from index key attributes) +- `sk_*` — index sort keys (e.g., `sk_s`, `sk_n`, `sk_b` for different types) +- `base_pk` — base table partition key (for uniqueness) +- `base_sk_*` — base table sort keys (for uniqueness) +- `item_data` — projected item attributes (JSON) + +DynamoDB semantics require that: +1. Queries against an index use the index keys (not base table keys) +2. Index keys are not unique — multiple base items can have the same index keys +3. Each base item appears at most once in the index + +## Options Considered + +1. **PostgreSQL-style ordering: `PRIMARY KEY (pk, base_pk, base_sk_*)`** — Partition by index PK, cluster by base keys only, with index sort keys as regular columns. + +2. **Cassandra-optimized ordering: `PRIMARY KEY ((pk), sk_*, base_pk, base_sk_*)`** — Partition by index PK, cluster first by index sort keys, then by base keys. + +3. **Composite partition key: `PRIMARY KEY ((pk, base_pk), sk_*, base_sk_*)`** — Include base PK in partition key for uniqueness. + +## Decision + +Cassandra-optimized ordering: `PRIMARY KEY ((pk), sk_*, base_pk, base_sk_*)` + +## Rationale + +- **Enables efficient range queries**: DynamoDB GSI queries with `KeyConditionExpression` like `pk = 'value' AND sk BETWEEN 'a' AND 'z'` require that index sort keys be clustering keys. Cassandra's clustering keys provide ordered storage within a partition, making range scans efficient. + +- **Matches DynamoDB query semantics**: Index queries specify index keys (not base keys) in the WHERE clause. With index sort keys as clustering columns, Cassandra can use them directly in queries without requiring secondary indexes or ALLOW FILTERING. + +- **Preserves uniqueness**: Base table keys (`base_pk`, `base_sk_*`) as trailing clustering keys ensure that each base item appears at most once, even when multiple items have identical index keys. + +- **Correct data distribution**: Partition key `(pk)` distributes index data by index partition key, matching DynamoDB's behavior where items with the same GSI partition key are co-located. + +- **Option 1 fails for queries**: Placing index sort keys as regular columns prevents using them in WHERE clauses for ordering, forcing full partition scans or expensive secondary indexes. + +- **Option 3 breaks distribution**: Including `base_pk` in the partition key distributes data by `(pk, base_pk)` combination, destroying co-location of items with the same index key and making range queries impossible. + +## Consequences + +- **Code divergence from PostgreSQL**: The PRIMARY KEY structure differs between backends. DELETE statements in Cassandra must include ALL PRIMARY KEY columns (index keys + base keys), while PostgreSQL DELETE uses only base keys (relying on the uniqueness constraint). This requires Cassandra-specific implementations of `delete_index_row_multi` that include index key parameters. + +- **Query compatibility**: Index queries work naturally in Cassandra using `WHERE pk = ? AND sk_s > ?` patterns, matching DynamoDB's KeyConditionExpression semantics. + +- **Write path complexity**: Index maintenance functions (`sync_indexes`, `delete_index_row_multi`, `insert_index_row_multi`) must be aware of the key ordering difference. The Cassandra version needs both index keys and base keys for DELETE operations. + +- **Testing challenges**: Integration tests must verify PRIMARY KEY structure explicitly by querying Cassandra system schema tables, as incorrect key ordering produces runtime errors ("Some partition key parts are missing") rather than compile-time failures. + +- **Hash-only indexes**: For indexes with no sort key, PRIMARY KEY becomes `((pk), base_pk, base_sk_*)`. The double parentheses `((pk))` syntax is required to distinguish the partition key from clustering keys. + +- **Documentation burden**: The difference in PRIMARY KEY structure must be clearly documented in code comments and architecture documents to prevent confusion when comparing implementations. + +--- + +## License + +Copyright 2026 ExtendDB contributors. Licensed under the Apache License, Version 2.0. +See [LICENSE](../../LICENSE) for the full text. + +This software is provided "as is" without warranty of any kind. ExtendDB is not +affiliated with, endorsed by, or sponsored by Amazon Web Services. "DynamoDB" is +a trademark of Amazon.com, Inc. diff --git a/docs/adr/0017-cassandra-transaction-implementation.md b/docs/adr/0017-cassandra-transaction-implementation.md new file mode 100644 index 00000000..cf9bb378 --- /dev/null +++ b/docs/adr/0017-cassandra-transaction-implementation.md @@ -0,0 +1,473 @@ +# ADR-0007: DynamoDB Transaction Implementation on Cassandra + +- Status: Draft +- Date: 2026-06-24 +- Deciders: ExtendDB Cassandra plugin contributors + +## Context + +DynamoDB provides transactional operations via `TransactWriteItems` and `TransactGetItems` APIs that guarantee ACID properties: + +- **Atomicity**: All operations succeed or all fail +- **Consistency**: Strong consistency for reads, serializable isolation for writes +- **Isolation**: Serializable - transactions appear to execute in some serial order +- **Durability**: Committed transactions persist + +The PostgreSQL backend implements these using native database transactions with row-level locks (`SELECT ... FOR UPDATE`). Cassandra lacks multi-statement ACID transactions, requiring a different approach. + +### DynamoDB Transaction Semantics (Public API Behavior) + +From the [AWS DynamoDB documentation](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/transaction-apis.html): + +**Isolation guarantees:** +- `TransactWriteItems` provides **serializable isolation**: concurrent transactions appear to execute in some serial order +- Writes to an item involved in a transaction are **rejected** until the transaction completes (with `TransactionConflictException`) +- Reads to an item involved in a transaction return the **last committed value** (ignore in-progress transaction state) + +**Atomicity:** +- Either all operations in a transaction succeed, or all fail +- Partial completion is not possible + +**Idempotency:** +- Optional `ClientRequestToken` prevents duplicate execution of same transaction + +**Conflict behavior:** +- If two transactions attempt to modify the same item, one succeeds and the other fails with `TransactionCanceledException` +- Non-transactional writes (PutItem, UpdateItem, DeleteItem) to an item involved in a transaction fail with `TransactionConflictException` + +### Cassandra Constraints + +- **Logged BATCH**: Provides atomicity (all-or-nothing) but **not isolation** - concurrent operations can interleave +- **Lightweight Transactions (LWT)**: Provides compare-and-set with `IF` conditions, but **cannot be used inside BATCH statements** +- **No row-level locks**: Cannot prevent concurrent access to same item +- **No multi-statement transactions**: Each statement commits independently + +## Proposal + +Implement DynamoDB transactions on Cassandra using **item-level transaction markers with Lightweight Transactions (LWT) for conflict detection**, implementing a two-phase commit protocol similar to standard distributed transaction systems. + +### Schema Changes + +**1. Add transaction system attributes to all data tables:** + +```cql +ALTER TABLE {keyspace}.items_{table_id} ADD ( + partition_max_delete_timestamp bigint STATIC, -- partition-level: prevents old txns creating deleted items + prepared_txn_id uuid, -- NULL = not in transaction, UUID = in transaction + prepared_txn_timestamp bigint, -- when PREPARE started (for timeout detection) + last_committed_txn_timestamp bigint, -- ordering for serializability + created_to_prepare boolean -- true if item created by PREPARE (for ROLLBACK) +); +``` + +**STATIC column explanation:** `partition_max_delete_timestamp` is shared across all rows with the same partition key (`pk`), providing partition-level metadata that persists even after all rows are deleted. This prevents transactions with old timestamps from incorrectly creating items after they've been deleted at a later timestamp. + +**2. Create transaction ledger table (in catalog keyspace):** + +```cql +CREATE TABLE {catalog_keyspace}.transaction_ledger ( + txn_id uuid PRIMARY KEY, + state text, -- 'preparing', 'committing', 'rollback' + started_at bigint, -- timestamp for age-based detection + client_token text, -- optional ClientRequestToken for idempotency + request_fingerprint text, -- hash of request for idempotency validation + items_blob text -- JSON: [{keyspace, table_id, pk, sk, operation}, ...] +); + +-- Index for efficient age-based scanning during recovery +CREATE INDEX ON transaction_ledger (started_at); +``` + +**Rationale for single-row ledger design:** +- Single INSERT creates complete transaction record atomically (no partial-write issues) +- State updates are single-row operations (atomic state transitions) +- Ledger is read only during recovery (rare event) - parsing JSON blob has negligible performance impact +- Simpler than multi-row design: no static columns, no row counting, no sentinel records +- Cell size limit (~2MB) supports thousands of items (DynamoDB limits to 100 items per transaction) + +### Transaction Protocol + +#### TransactWriteItems + +**Phase 0: LEDGER CREATION** + +1. Generate unique `txn_id` (UUID) +2. Serialize all items to JSON blob +3. **Write transaction to ledger atomically**: + ```cql + INSERT INTO transaction_ledger (txn_id, state, started_at, + client_token, request_fingerprint, items_blob) + VALUES (?, 'preparing', ?, ?, ?, ?); + ``` + +**Phase 1: PREPARE** + +4. **Parse items from ledger** (only needed during recovery; normal path has items in memory) +5. For each item: + - **Read existing item** (if any) to evaluate conditions + - **Validate all conditions** in-memory + - **For non-existent items (PUT operations), check partition max delete timestamp**: + ```cql + SELECT partition_max_delete_timestamp FROM items WHERE pk = ? LIMIT 1; + ``` + If `partition_max_delete_timestamp` exists and `txn_timestamp <= partition_max_delete_timestamp`, reject transaction (would violate timestamp ordering—item was deleted at a later timestamp) +6. If all conditions pass, **execute PREPARE for each item**: + - If item exists: `UPDATE items SET prepared_txn_id = ?, prepared_txn_timestamp = ? WHERE pk = ? AND sk = ? IF prepared_txn_id IS NULL` + - If item doesn't exist (for Put): `INSERT INTO items (pk, sk, prepared_txn_id, prepared_txn_timestamp, created_to_prepare) VALUES (?, ?, ?, ?, true) IF NOT EXISTS` +7. **If any PREPARE fails** (item already in another transaction): update ledger state to 'rollback' and proceed to ROLLBACK + +**Phase 2: COMMIT** (if all prepares succeeded) + +8. **Update ledger state**: `UPDATE transaction_ledger SET state = 'committing' WHERE txn_id = ?` +9. For each item (from memory or parsed from ledger), execute in parallel: + - Put/Update: `UPDATE items SET item_data = ?, prepared_txn_id = NULL, last_committed_txn_timestamp = ? WHERE pk = ? AND sk = ? IF prepared_txn_id = ?` + - Delete: + ```cql + -- Update partition STATIC column + UPDATE items SET partition_max_delete_timestamp = ? + WHERE pk = ? + IF partition_max_delete_timestamp < ? OR partition_max_delete_timestamp IS NULL; + + -- Delete the item + DELETE FROM items WHERE pk = ? AND sk = ? IF prepared_txn_id = ?; + ``` +10. Wait for all COMMIT operations to complete +11. **Delete transaction from ledger**: `DELETE FROM transaction_ledger WHERE txn_id = ?` + +**Phase 2: ROLLBACK** (if any prepare failed) + +8. **Update ledger state** (if not already): `UPDATE transaction_ledger SET state = 'rollback' WHERE txn_id = ?` +9. For each item (from memory or parsed from ledger), execute in parallel: + - If `created_to_prepare = true`: `DELETE FROM items WHERE pk = ? AND sk = ? IF prepared_txn_id = ?` + - Else: `UPDATE items SET prepared_txn_id = NULL WHERE pk = ? AND sk = ? IF prepared_txn_id = ?` +10. Wait for all ROLLBACK operations to complete +11. **Delete transaction from ledger**: `DELETE FROM transaction_ledger WHERE txn_id = ?` + +#### TransactGetItems + +Execute using a **two-phase protocol** to ensure serializability: + +**Phase 1: Initial Reads** + +For each item in the read set: +```cql +SELECT item_data, last_committed_txn_timestamp, prepared_txn_id +FROM items WHERE pk = ? AND sk = ? +USING CONSISTENCY LOCAL_QUORUM; +``` + +- If **any** item has `prepared_txn_id != NULL`: reject transaction with `TransactionConflictException` (concurrent write transaction is preparing this item) +- Store item values and timestamps in memory + +**Phase 2: Verification Reads** + +For each item: +```cql +SELECT last_committed_txn_timestamp, prepared_txn_id +FROM items WHERE pk = ? AND sk = ? +USING CONSISTENCY LOCAL_QUORUM; +``` + +- Compare `last_committed_txn_timestamp` from Phase 1 vs Phase 2 +- If **any** timestamp changed: reject transaction (item was written between phases) +- If **any** `prepared_txn_id != NULL`: reject transaction (new write transaction started) +- If all verifications pass: return item values captured in Phase 1 + +**Rationale:** This two-phase protocol ensures serializability by detecting concurrent writes without updating items or acquiring locks. The `last_committed_txn_timestamp` serves as a sequence number to detect changes. Both phases return committed data, ignoring prepared state, matching DynamoDB's read-committed visibility. + +### Integration with Non-Transactional Operations + +**Write operations** (PutItem, UpdateItem) must check for prepared transactions: + +```cql +-- Example: PutItem +UPDATE items +SET item_data = ?, last_committed_txn_timestamp = ? +WHERE pk = ? AND sk = ? +IF prepared_txn_id IS NULL +``` + +**Delete operations** must update partition max delete timestamp and check for prepared transactions: + +```cql +-- Update partition STATIC column +UPDATE items +SET partition_max_delete_timestamp = ? +WHERE pk = ? +IF partition_max_delete_timestamp < ? OR partition_max_delete_timestamp IS NULL; + +-- Delete the item +DELETE FROM items +WHERE pk = ? AND sk = ? +IF prepared_txn_id IS NULL; +``` + +If `prepared_txn_id IS NULL` condition fails, return `TransactionConflictException` to client. + +**Read operations** (GetItem, Query, Scan) execute normally - prepared transactions are ignored, returning committed data. + +### Idempotency Token Handling + +Idempotency uses the existing `idempotency_tokens` table (same as non-transactional operations): + +1. **Before creating ledger entry**, check for existing token: + ```cql + SELECT token, fingerprint, created_at + FROM idempotency_tokens + WHERE token = ?; + ``` +2. If token exists with matching fingerprint: return success (transaction already completed) +3. If token exists with different fingerprint: return error (token reused incorrectly) +4. If token doesn't exist: **insert token** and proceed with ledger creation: + ```cql + INSERT INTO idempotency_tokens (token, fingerprint, created_at) + VALUES (?, ?, ?) IF NOT EXISTS; + ``` +5. Token is written **before** ledger entry, ensuring idempotency is established before any transaction state + +The ledger's `client_token` and `request_fingerprint` columns are retained for observability (which token was used for this transaction) but not queried for idempotency checks. + +### Fault Tolerance and Failed Transaction Recovery + +**Failed transactions** occur when ExtendDB process fails between PREPARE and COMMIT/ROLLBACK. Detection mechanisms: + +1. **Age-based detection**: Background worker scans ledger for transactions with `started_at` older than threshold (e.g., 60 seconds): + ```cql + SELECT txn_id, state, started_at, items_blob + FROM transaction_ledger + WHERE started_at < ? + ALLOW FILTERING; + ``` + +2. **Conflict-based detection**: Non-transactional writes encountering prepared transactions check `prepared_txn_timestamp`; if too old, trigger recovery + +**Recovery process:** + +For each failed transaction found: + +1. **Read transaction from ledger**: + ```cql + SELECT state, items_blob FROM transaction_ledger WHERE txn_id = ?; + ``` + +2. **Parse items from JSON blob** to get complete list + +3. **Continue based on state**: + - state = 'preparing' → execute ROLLBACK for all items + - state = 'committing' → execute COMMIT for all items (resume) + - state = 'rollback' → execute ROLLBACK for all items (resume) + +4. **Delete from ledger** once complete: + ```cql + DELETE FROM transaction_ledger WHERE txn_id = ?; + ``` + +The single-row ledger ensures recovery always has the complete, consistent list of items (no partial-write issues). + +### Streams Integration + +**Important**: Stream records should **only** be generated for successfully committed transactions. The following operations do NOT generate stream records: +- PREPARE phase operations (writing transaction markers) +- ROLLBACK phase operations (cleaning up failed transactions) +- Recovery operations (rolling back failed transactions) + +Only the COMMIT phase generates stream records, ensuring that streams reflect the logical transaction as a single atomic unit. + +## Background Workers + +The transaction implementation requires background workers (implemented in `src/workers.rs`): + +### 1. Transaction Recovery Worker + +**Responsibility:** Detect and recover failed transactions + +**Operation:** +- Periodically scans `transaction_ledger` for transactions older than timeout threshold (e.g., 60 seconds) +- For each failed transaction: + - Reads transaction state and items from ledger + - Executes ROLLBACK for transactions in 'preparing' state + - Resumes COMMIT for transactions in 'committing' state + - Resumes ROLLBACK for transactions in 'rollingback' state + - Deletes transaction from ledger once complete +- Recommended scan frequency: every 30 seconds + +**Error handling:** Failures during recovery are logged; transaction remains in ledger for next scan iteration + +### 2. Ledger Cleanup Worker (Optional) + +**Responsibility:** Clean up stale ledger entries that recovery worker couldn't process + +**Operation:** +- Scans for transactions older than extended threshold (e.g., 24 hours) +- Logs warnings for investigation +- Optionally moves to dead-letter table for manual review +- Prevents unbounded ledger growth + +**Recommended scan frequency:** every hour or daily, depending on operational requirements + +## Implementation Details + +Background workers are spawned during ExtendDB initialization and run for the lifetime of the process. Worker implementation follows existing patterns in `src/workers.rs` (control plane transition worker, etc.). + +## Rationale + +### Why This Approach Provides DynamoDB Semantics + +**Atomicity**: +- PREPARE phase ensures all items can be included in the transaction before any writes +- If any PREPARE fails, transaction rollbacks and all prepared items are cleaned up +- COMMIT/ROLLBACK phases execute all operations; failures trigger retry + +**Consistency**: +- `TransactGetItems` uses `LOCAL_QUORUM` reads +- `TransactWriteItems` uses `LOCAL_QUORUM` writes + +**Isolation (Serializable)**: +- Items with `prepared_txn_id != NULL` reject all concurrent writes (transactional or not) +- LWT ensures only one transaction can prepare an item at a time +- `last_committed_txn_timestamp` establishes transaction order +- Reads ignore prepared state, returning last committed data (matches DynamoDB) + +**Durability**: +- All writes use `LOCAL_QUORUM` for durability +- Idempotency tokens prevent duplicate execution + +### Why Not Cassandra Logged BATCH + +Logged BATCH provides atomicity but **not isolation**. Without isolation: +- Two transactions could prepare the same item concurrently +- Non-transactional writes could interleave with transaction writes +- Cannot guarantee serializable isolation + +### Why LWT Outside BATCH + +Cassandra limitation: LWT (`IF` conditions) cannot be used inside BATCH statements. However: +- PREPARE operations need LWT to detect conflicts atomically +- COMMIT/ROLLBACK operations can execute in parallel (not batched) since atomicity is already guaranteed by PREPARE phase +- Performance impact is acceptable: DynamoDB itself executes operations in parallel during COMMIT + +### Comparison to DynamoDB + +**Similarities**: +- Two-phase commit protocol with PREPARE/COMMIT/ROLLBACK phases +- Transaction ledger tracks in-progress transactions for recovery +- Item-level markers prevent concurrent modifications to same item +- Reads ignore in-progress transactions (return last committed value) +- Idempotency token support +- Age-based detection and recovery for failed ExtendDB processs + +**Differences**: +1. **Ledger structure**: Implementation uses single-row ledger with JSON blob for items list. DynamoDB's internal ledger structure is not publicly documented but likely differs. + +2. **COMMIT/ROLLBACK not batched**: Operations execute in parallel but are not batched into single atomic operation (Cassandra LWT limitation). This matches DynamoDB's parallel execution approach during COMMIT/ROLLBACK phases. + +3. **Recovery strategy**: Transactions found in 'preparing' state are always rolled back (conservative). This is a safe default when ExtendDB process state is unknown. + +4. **Process failure handling**: Failed transactions are detected by age-based scanning of the ledger rather than heartbeat mechanisms. + +## Consequences + +**Positive**: +- Achieves DynamoDB transaction semantics on Cassandra +- No external dependencies (no Redis, ZooKeeper, etc.) +- Leverages Cassandra's native LWT for conflict detection +- Compatible with existing non-transactional operations +- Fault-tolerant with orphan detection + +**Negative**: +- LWT operations are expensive (quorum reads + writes with Paxos round) +- Cannot batch COMMIT/ROLLBACK operations (performance impact vs. PostgreSQL) +- PREPARE phase requires one LWT per item (N items = N round trips) +- Orphan recovery requires scanning for items with same `prepared_txn_id` (can be optimized with secondary index) + +**Performance Characteristics**: +- PREPARE phase: `O(N)` LWT operations (N = number of items) +- COMMIT/ROLLBACK phase: `O(N)` parallel LWT operations +- Non-transactional writes: one additional LWT condition check (`IF prepared_txn_id IS NULL`) +- Reads: no performance impact (ignore prepared state) + +**Implementation Impact**: +- Modify all write operations (PutItem, UpdateItem, DeleteItem) to check `prepared_txn_id` +- Add background worker for orphan detection +- Implement transaction ExtendDB process logic (PREPARE/COMMIT/ROLLBACK state machine) +- Add metrics for transaction latency, rollback rate, failed transaction detection + +## Alternatives Considered + +### Option 1: Cassandra Logged BATCH Only + +Use logged BATCH for atomicity without LWT. + +**Rejected**: Cannot guarantee serializable isolation. Concurrent transactions can prepare the same item, violating DynamoDB semantics. + +### Option 2: External Lock Service (Redis, ZooKeeper) + +Use distributed locks to serialize access to items. + +**Rejected**: Adds infrastructure dependency, single point of failure, operational complexity. Defeats purpose of using Cassandra's distributed architecture. + +### Option 3: Optimistic Concurrency with Version Column + +Use existing `version` column for all operations, retry on conflict. + +**Rejected**: +- Cannot distinguish between "item changed by another transaction" vs. "transaction in progress" without transaction markers +- Retry logic becomes complex (how many retries? exponential backoff?) +- No clear rollback mechanism for failed transactions + +### Option 4: Document Limitation (No Isolation) + +Accept that Cassandra cannot provide serializable isolation. + +**Rejected**: Fundamentally breaks DynamoDB transaction contract. Transactions without isolation are not useful for most use cases (e.g., bank transfers, inventory management). + +### Option 5: Generic Transaction Coordination in ExtendDB Core + +Implement transaction coordination logic (two-phase commit, ledger, recovery) in the `storage` crate, exposing minimal primitives that backends must implement (e.g., atomic conditional writes, strongly consistent reads). + +**Rejected**: While appealing for code reuse, this approach has fundamental problems: + +1. **Different databases need different mechanisms**: PostgreSQL uses native ACID transactions with `SELECT ... FOR UPDATE` (no ledger, no two-phase commit, no recovery needed). Forcing it to use a ledger would be strictly worse—fighting the database instead of leveraging its strengths. + +2. **Primitives don't compose uniformly**: The Cassandra implementation uses LWT for conflict detection and STATIC columns for partition-level metadata. Many databases lack these features. Abstracting over such differences creates a lowest-common-denominator that makes all backends worse. + +3. **Performance vs. correctness trade-offs vary**: What constitutes an acceptable trade-off depends on what the backend provides. PostgreSQL can use row locks (blocking). Cassandra must use conflict detection (non-blocking). A shared abstraction can't optimize for both. + +4. **Backend-specific optimizations matter**: PostgreSQL's native rollback is orders of magnitude faster than implementing manual rollback. Cassandra's LWT parallelization strategy differs from PostgreSQL's lock acquisition order. These details affect correctness and performance. + +**Better approach**: Share high-level validation logic (condition expression evaluation, error types, request parsing) but keep coordination mechanisms backend-specific. The trait defines *what* (DynamoDB API semantics) not *how* (implementation mechanism). + +## Open Questions + +1. **Transaction timeout**: What is appropriate timeout for `started_at` before considering transaction failed and triggering recovery? Suggested: 60 seconds (conservative, allows for slow operations). + +2. **Cross-keyspace transactions**: Should transactions spanning multiple accounts (different keyspaces) be supported? DynamoDB supports cross-table transactions within same account. Implementation would require ledger to store keyspace per item (already included in schema). + +3. **GSI/LSI updates**: How do secondary index updates integrate with transaction protocol? Should index updates be part of COMMIT phase? Recommendation: treat index entries as additional items in transaction (add to ledger, PREPARE/COMMIT them). + +4. **Streams integration**: Should stream records be written during COMMIT phase (atomically with item writes) or after COMMIT completes? Recommendation: write stream records during COMMIT phase, include in transaction for atomicity. + +5. **Ledger retention**: Should failed transactions remain in ledger for debugging, or be deleted immediately after recovery? Trade-off between observability and storage cost. + +## Implementation Plan + +1. **Phase 1**: Schema changes (add transaction columns to all item tables) +2. **Phase 2**: Implement `TransactGetItems` (simpler - just parallel reads) +3. **Phase 3**: Implement `TransactWriteItems` PREPARE phase with LWT +4. **Phase 4**: Implement COMMIT/ROLLBACK phases +5. **Phase 5**: Integrate with non-transactional operations (add `IF prepared_txn_id IS NULL`) +6. **Phase 6**: Implement failed transaction detection and recovery +7. **Phase 7**: Testing (unit tests, integration tests, correctness tests with concurrent transactions) + +Estimated effort: **3-4 weeks** for complete implementation and testing. + +--- + +## License + +Copyright 2026 ExtendDB contributors. Licensed under the Apache License, Version 2.0. +See [LICENSE](../../LICENSE) for the full text. + +This software is provided "as is" without warranty of any kind. ExtendDB is not +affiliated with, endorsed by, or sponsored by Amazon Web Services. "DynamoDB" is +a trademark of Amazon.com, Inc. diff --git a/docs/adr/0018-cassandra-stream-implementation.md b/docs/adr/0018-cassandra-stream-implementation.md new file mode 100644 index 00000000..cd0c4020 --- /dev/null +++ b/docs/adr/0018-cassandra-stream-implementation.md @@ -0,0 +1,467 @@ +# ADR-0008: DynamoDB Streams Implementation on Cassandra + +- Status: Draft +- Date: 2026-06-25 +- Deciders: ExtendDB Cassandra plugin contributors + +## Context + +DynamoDB Streams captures a time-ordered sequence of item-level modifications in DynamoDB tables. From the [AWS DynamoDB Streams documentation](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Streams.html): + +> "A DynamoDB stream is an ordered flow of information about changes to items in a DynamoDB table. When you enable a stream on a table, DynamoDB captures information about every modification to data items in the table." + +### DynamoDB Streams Semantics (Public API Behavior) + +**Ordering guarantees:** +- Each stream record is assigned a sequence number +- "For each item that is modified in a DynamoDB table, the stream records appear in the same sequence as the actual modifications to the item" +- Sequence numbers reflect the order in which records were published to the stream + +**Data retention:** +- "All data in DynamoDB Streams is subject to a 24-hour lifetime" +- Records older than 24 hours are subject to removal at any time + +**Shard structure:** +- Stream records are organized into shards +- Each shard contains multiple stream records +- Applications can process records from multiple shards in parallel + +**Stream view types (StreamSpecification):** +- `KEYS_ONLY`: Only key attributes of modified item +- `NEW_IMAGE`: Entire item after modification +- `OLD_IMAGE`: Entire item before modification +- `NEW_AND_OLD_IMAGES`: Both old and new item images + +**Atomicity with data operations:** +- Stream records must be captured atomically with the data modification +- No stream record if operation fails +- No operation success without stream record + +### PostgreSQL Reference Implementation + +The PostgreSQL backend implements streams using: +- PostgreSQL SEQUENCE for monotonically increasing sequence numbers +- Multi-table transaction (data + stream records in single ACID transaction) +- Fixed 4 shards per stream with CRC32-based partition key hashing +- Stream records stored in data database (not catalog) +- Background worker for periodic TTL cleanup + +### Cassandra Constraints + +- **No SEQUENCE support**: Cannot use PostgreSQL's `nextval()` approach +- **LOGGED BATCH**: Provides atomicity across tables (suitable for data + stream writes) +- **Native TTL**: Per-row TTL on INSERT (more efficient than periodic DELETE) +- **Multi-node deployment**: Multiple ExtendDB nodes may write to same stream concurrently + +## Proposal + +Implement DynamoDB Streams on Cassandra using **Hybrid Logical Clocks (HLC) for sequence numbers** and **LOGGED BATCH for atomic writes** of data and stream records. + +### Schema Design + +**Stream shards table (in account keyspace):** + +```cql +CREATE TABLE {account_keyspace}.stream_shards ( + shard_id text PRIMARY KEY, + table_id text, + parent_shard_id text, + starting_sequence_number text, + ending_sequence_number text, + created_at timestamp +); + +-- Index for queries by table_id (used during describe_stream) +CREATE INDEX ON stream_shards (table_id); +``` + +**Stream records table (in account keyspace):** + +```cql +CREATE TABLE {account_keyspace}.stream_records ( + shard_id text, + sequence_number text, + table_id text, + event_name text, -- INSERT, MODIFY, REMOVE + record_data text, -- JSON-serialized StreamRecord + created_at timestamp, + PRIMARY KEY (shard_id, sequence_number) +) WITH CLUSTERING ORDER BY (sequence_number ASC); +``` + +**Rationale for account keyspace:** +- Enables atomic writes with item data (same keyspace, can use LOGGED BATCH) +- Stream records are account-specific data +- Account deletion drops entire keyspace (cascades streams automatically) +- Matches PostgreSQL pattern (data database, not catalog) + +### Hybrid Logical Clock (HLC) for Sequence Numbers + +**Problem:** Cassandra has no atomic sequence generator like PostgreSQL's SEQUENCE. + +**Solution:** Use Hybrid Logical Clock pattern with node identifier: + +**Format (23 digits, within DynamoDB's 40-char limit):** +``` +{timestamp_ms:013}{counter:06}{node_id:04} +``` + +**Example:** Node 5 at timestamp 1719345600000, first write: +``` +"17293456000000000010005" + └─timestamp──┘││││││└─node─┘ + └─counter─┘ +``` + +**Components (ordered by significance for lexicographic comparison):** +1. **Physical timestamp** (13 digits): Milliseconds since Unix epoch + - Primary ordering - wall-clock time + - Most significant segment +2. **Logical counter** (6 digits): Per-node, per-millisecond counter + - Secondary ordering - handles concurrent writes in same millisecond + - Resets when timestamp advances + - Supports 999,999 operations/millisecond/node (overflow is theoretical) +3. **Node ID** (4 digits): ExtendDB server identifier + - Tie-breaker only (effectively impossible to reach with 6-digit counter) + - Derived from config or hostname hash + - Range: 0001-9999 + +Note: The 21-digit format used by PostgreSQL is NOT a DynamoDB API requirement. DynamoDB allows sequence numbers up to 40 characters. + +**Algorithm:** +```rust +struct HybridClock { + node_id: u16, + last_timestamp_ms: i64, + logical_counter: u32, // u32 to hold up to 999_999 +} + +fn generate_sequence_number(clock: &mut HybridClock) -> String { + let now_ms = current_timestamp_ms(); + + if now_ms > clock.last_timestamp_ms { + clock.last_timestamp_ms = now_ms; + clock.logical_counter = 0; + } else { + clock.logical_counter += 1; + // Counter exhausted: wait for next millisecond. + // Requires ~1B writes/sec/node — will never occur in practice. + if clock.logical_counter > 999_999 { + sleep(1ms); + clock.last_timestamp_ms = current_timestamp_ms(); + clock.logical_counter = 0; + } + } + + // Format: timestamp (13) + counter (6) + node_id (4) = 23 digits + format!("{:013}{:06}{:04}", + clock.last_timestamp_ms, + clock.logical_counter, + clock.node_id) +} +``` + +**Why this works:** +- **Ordered:** Lexicographic string comparison matches temporal order +- **Unique:** Node ID prevents collisions across ExtendDB nodes +- **DynamoDB compatible:** 23-character numeric string, well within 40-char limit +- **No coordination:** Each node maintains independent in-memory state +- **Future-ready:** HLC pattern supports PITR and replication use cases + +### Shard Assignment + +**Fixed 4 shards per stream** (matches PostgreSQL and DynamoDB behavior): + +**Shard ID format:** +``` +shardId-{table_id}-{000000000000} // 12-digit zero-padded index +``` + +**Assignment algorithm:** +```rust +fn assign_shard(partition_key: &str, table_id: &str) -> String { + let hash = crc32fast::hash(partition_key.as_bytes()); + let shard_idx = (hash as usize) % 4; + format!("shardId-{}-{:012}", table_id, shard_idx) +} +``` + +**Rationale:** +- CRC32 provides stable, deterministic hashing +- Same partition key always routes to same shard (consistent) +- Good distribution across shards +- Uses `table_id` (UUID), not `table_name` — prevents shard ID collision if a table is deleted and recreated with the same name (criterion 8.2) +- Matches PostgreSQL implementation + +### Atomic Stream + Data Writes + +**Use existing LOGGED BATCH pattern** (already used for data + index writes): + +```rust +// Build batch statements +let mut batch_statements = Vec::new(); + +// 1. Data operation (UPDATE/INSERT/DELETE) +batch_statements.push(data_statement); + +// 2. Index updates (if any) +sync_indexes(&mut batch_statements, ...); + +// 3. Stream record (if streams enabled) +if let Some(stream_spec) = table.stream_specification { + let sequence_number = hlc.generate_sequence_number(); + let shard_id = assign_shard(&partition_key, &table_id); + let record = build_stream_record(old_item, new_item, &stream_spec); + + batch_statements.push(format!( + "INSERT INTO {}.stream_records \ + (shard_id, sequence_number, table_id, event_name, record_data, created_at) \ + VALUES ('{}', '{}', '{}', '{}', '{}', {}) \ + USING TTL {}", + account_keyspace, shard_id, sequence_number, table_id, + event_name, record_json, now_ms, ttl_seconds + )); +} + +// Execute atomically +let batch_query = format!("BEGIN BATCH\n{}\nAPPLY BATCH", + batch_statements.join(";\n")); +session.query(&batch_query).await?; +``` + +**Event type determination:** +- `old=None, new=Some` → INSERT +- `old=Some, new=Some` → MODIFY +- `old=Some, new=None` → REMOVE +- `old=None, new=None` → No record (operation had no effect) + +### TTL Strategy + +**Use Cassandra native row-level TTL:** + +**Default retention:** 30 hours (108,000 seconds) +- DynamoDB contract: "at least 24 hours" retention +- 6-hour buffer provides operational margin +- Protects against clock skew and brief instance downtime +- Still exceeds minimum guarantee + +**Implementation:** +```cql +INSERT INTO stream_records (...) VALUES (...) +USING TTL 108000; +``` + +**Benefits:** +- Automatic expiration (no background worker needed) +- More efficient than periodic DELETE queries +- Simpler than PostgreSQL implementation +- Per-row granularity + +### OLD_IMAGE Capture + +For `StreamViewType` of `OLD_IMAGE` or `NEW_AND_OLD_IMAGES`: + +**Requirement:** Must read item before modification to capture old state + +**Implementation:** +- Already reading for condition expression evaluation (common case) +- If no condition but OLD_IMAGE needed: explicit SELECT before write +- Read within same connection/session for consistency +- Acceptable performance trade-off for correctness + +### Stream Initialization + +**During CreateTable (if StreamSpecification present):** + +1. Generate `stream_label` (ISO 8601 timestamp) +2. Update catalog: `tables.stream_label`, `tables.stream_specification` +3. Create 4 shard rows in account keyspace: + ```cql + INSERT INTO stream_shards (shard_id, table_id, starting_sequence_number) + VALUES ('shardId-{table_id}-{i:012}', '{table_id}', '000000000000000000000'); + ``` + +### OLD_IMAGE Capture + +For `StreamViewType` of `OLD_IMAGE` or `NEW_AND_OLD_IMAGES`, the pre-mutation item state is required. This is already guaranteed: index maintenance requires a pre-image read on every write path that can affect indexed attributes. Stream record capture piggybacks on this existing read — no additional round-trip is needed in the common case. + +### Stream APIs + +**Important:** The `StreamEngine` trait's `write_stream_record` method cannot be used for stream record writes. The atomicity requirement (criterion 5.1) mandates that stream records be written in the same LOGGED BATCH as the data operation. A standalone trait method that executes its own query cannot participate in a caller's batch. + +Instead, stream record writes are handled by an internal helper that returns a CQL statement to be appended to the batch being built by the write operation (the same pattern used for index maintenance). The `StreamEngine` trait is used only for the read-side APIs (`GetShardIterator`, `GetRecords`, `DescribeStream`, `ListStreams`) and control-plane operations (`init_stream_shards`, `disable_stream`). + +**StreamEngine trait methods used:** + +```rust +// Read records from shard with pagination +fn get_stream_records( + shard_id: &str, + after_sequence: Option<&str>, + limit: i64, +) -> Result<(Vec, Option)>; + +// Describe stream metadata and shards +fn describe_stream( + account_id: &str, + input: &DescribeStreamInput, +) -> Result; + +// List streams for account +fn list_streams( + account_id: &str, + table_name: Option<&str>, + limit: i64, + exclusive_start_stream_arn: Option<&str>, +) -> Result<(Vec, Option)>; + +// Assign shard for partition key +fn assign_shard( + account_id: &str, + table_name: &str, + partition_key: &str, +) -> Result; + +// Generate next sequence number (HLC) +fn next_sequence_number(&self, shard_id: &str) -> Result; +``` + +## Alternatives Considered + +### Alternative 1: Simple Timestamp Sequences + +**Approach:** Use microsecond timestamps without logical counter + +**Rejected because:** +- Collision risk at high write rates (>1M ops/sec/node) +- No mechanism for tie-breaking across nodes +- Would require retry logic on Cassandra duplicate key errors +- Less robust for future replication/PITR use cases + +### Alternative 2: Counter Table + +**Approach:** Use Cassandra counter table for sequence generation + +**Rejected because:** +- Cassandra counters not idempotent (retry doubles increment) +- Cannot read counter value within LOGGED BATCH +- Would require separate round-trip for each sequence number +- Performance impact on write path + +## Consequences + +### Positive + +- **No schema changes to items tables** (unlike transactions) +- **Atomic writes** using existing LOGGED BATCH pattern +- **Collision-free sequences** with HLC + Node ID +- **Native TTL** simpler than PostgreSQL's worker approach +- **Future-ready** for PITR and replication features +- **Customer-friendly** TTL buffer exceeds minimum guarantee + +### Negative + +- **Node ID configuration required** for multi-node deployments +- **Microsecond precision** subject to clock quality (standard limitation) +- **OLD_IMAGE requires read-before-write** (performance cost, already done for conditions) +- **HLC state per node** (in-memory, lost on restart - acceptable) + +### Neutral + +- **Different from PostgreSQL** (HLC vs SEQUENCE) but equivalent semantics +- **21-digit sequences** may have gaps (DynamoDB doesn't prohibit this) +- **Cassandra-specific advantages** (native TTL) vs disadvantages (no SEQUENCE) + +## Code Reuse: Refactoring Database-Independent Logic + +The PostgreSQL implementation and this Cassandra proposal share significant database-independent logic that should be extracted into the `storage` crate: + +### Functions to Extract + +**1. Shard Assignment (`assign_shard_id`)** +```rust +/// Assign a shard ID based on partition key hash (CRC32). +pub fn assign_shard_id(pk_value: &str, shard_ids: &[String]) -> &str { + let hash = crc32fast::hash(pk_value.as_bytes()); + let idx = (hash as usize) % shard_ids.len(); + &shard_ids[idx] +} +``` +- Currently duplicated in both backends +- Identical CRC32 hashing logic +- Ensures consistent shard assignment across all backends + +**2. Stream Record Construction (`build_stream_record`)** +```rust +/// Build a StreamRecord from operation context. +pub fn build_stream_record( + event_type: StreamEventName, + keys: BTreeMap, + old_item: Option, + new_item: Option, + view_type: StreamViewType, + region: &str, + user_identity: Option, + sequence_number: String, +) -> StreamRecord +``` +- Determines event type from old/new state +- Filters images by view type +- Calculates size_bytes +- Assigns event_id (UUID v4) +- Pure data transformation with no I/O + +**3. Shard ID Formatting (`format_shard_id`)** +```rust +/// Generate standard shard ID format. +pub fn format_shard_id(table_name: &str, shard_index: u32) -> String { + format!("shardId-{table_name}-{shard_index:012}") +} +``` +- Ensures consistent shard ID format across backends +- Used during shard initialization + +### Rationale + +**Unlike transactions** (where different backends need different *mechanisms* like ledgers vs native ACID), streams share identical *computations* but differ only in *storage primitives*: + +- **Shared:** Hash computation, record construction, formatting +- **Backend-specific:** Sequence generation (SEQUENCE vs HLC), atomic writes (transactions vs BATCH), TTL (worker vs native) + +**Benefits:** +- Eliminates 100+ lines of duplicated code per backend +- Ensures consistency (identical hashing, formatting, record structure) +- Simplifies testing (test once in storage crate) +- Zero performance cost (pure functions) +- Future backends get streams logic "for free" + +**Backend-Specific Remaining:** +- Sequence number generation (trait method) +- Shard metadata queries (may need caching in Cassandra) +- Atomic write coordination (transactions vs BATCH) +- TTL implementation (background worker vs native) + +### Implementation Priority + +This refactoring should be done **as part of the Cassandra streams implementation**, not afterward: +1. Extract shared logic to storage crate first +2. Refactor PostgreSQL to use shared functions +3. Implement Cassandra streams using shared functions +4. Benefits both backends immediately + +## Resolved Questions + +1. **Node ID assignment strategy:** Derived as `crc32(format!("{}:{}", hostname, port)) % 9999 + 1`. Hostname alone is insufficient — multiple instances on the same host listening on different ports must have distinct node IDs. Hostname + port is stable across restarts, requires no configuration, and works correctly in containerized environments where the hostname is a pod/container ID and the port is the service binding. Hash collision (two instances mapping to the same node ID) is not a correctness problem — it only creates a theoretical ordering ambiguity that requires matching timestamp + counter on the same shard simultaneously. + +2. **HLC state persistence:** Not needed. On restart, `now_ms` will always be greater than any previously generated timestamp (restarts take at least 1ms). Clock-going-backwards is handled by clamping to `last_timestamp_ms` and incrementing the counter, with a WARN log. + +3. **TTL configuration:** Global setting (`stream_retention_hours`, default 30). Per-stream configuration adds complexity with no practical benefit — DynamoDB itself uses a fixed 24-hour retention. + +4. **Clock skew handling:** Log a WARN if `now_ms < last_timestamp_ms`. Clamp to `last_timestamp_ms` and increment counter. No hard error — the sequence remains valid and ordered. + +## References + +- [DynamoDB Streams Documentation](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Streams.html) +- [DynamoDB Streams API Reference](https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_Operations_Amazon_DynamoDB_Streams.html) +- [PostgreSQL Reference Implementation](../../extenddb/crates/storage-postgres/src/stream_engine.rs) +- [Hybrid Logical Clocks Paper](https://cse.buffalo.edu/tech-reports/2014-04.pdf) (Kulkarni et al.) diff --git a/docs/adr/0019-cassandra-logical-backup-restore.md b/docs/adr/0019-cassandra-logical-backup-restore.md new file mode 100644 index 00000000..f0fe5ddf --- /dev/null +++ b/docs/adr/0019-cassandra-logical-backup-restore.md @@ -0,0 +1,51 @@ +# ADR-0009: Logical backup and restore for Cassandra + +**Status:** Accepted +**Date:** 2026-08-03 + +## Context + +ExtendDB's `BackupEngine` exposes DynamoDB-compatible on-demand backup, restore, listing, deletion, and continuous-backup status operations. Cassandra's native snapshots are node-local SSTable artifacts managed through `nodetool`, JMX, and filesystem/object-storage tooling. They cannot be created, enumerated, or restored through the CQL session available to this plugin, and a distributed snapshot requires cluster-level orchestration outside the storage trait. + +The plugin uses a keyspace per account. Backup payloads therefore need to remain account-isolated, while ARN-addressed metadata must remain available if the source table is deleted. + +## Decision + +Implement on-demand backups as logical snapshots: + +* Store authoritative backup metadata in catalog table `backups_by_arn`, physically partitioned by `(account_id, backup_arn)` so ARN-addressed reads and mutations cannot cross account boundaries. +* Store denormalized, query-shaped listing rows in `backups_by_account` and `backups_by_table`; no `ALLOW FILTERING` or secondary index is required. `backups_by_table` is intentionally keyed by table name because `ListBackups` accepts a name and backups outlive their source table. Each row also records the immutable source `table_id`, so delete/recreate generations remain distinguishable while old backups stay discoverable and restorable. +* Store item JSON in the owning account keyspace's `backup_items` table. Partition by `(backup_arn, bucket)` and cap each bucket at 64 items. Given DynamoDB's 400 KiB item limit, this bounds a payload partition to roughly 25 MiB before Cassandra overhead. +* Generate backup IDs as `-<8 random hex characters>`, matching the PostgreSQL backend and DynamoDB-compatible ARN shape. +* Write metadata as `CREATING`, scan and persist all item chunks, then use one bounded logged batch to create both listing rows and publish the authoritative row as `AVAILABLE`. Failed creation removes written chunks and metadata best-effort. Readers and restore accept only `AVAILABLE` rows. +* Delete transitions metadata to `DELETING`, removes listing rows and payload partitions idempotently, then transitions to `DELETED`. Describe/list treat non-`AVAILABLE` backups as absent, while the successful delete response reports `DELETED`. +* Restore creates the target through the normal table path, deserializes every snapshot item, and writes through the normal Cassandra item path so typed partition/sort-key columns are reconstructed. The target is made `ACTIVE` only after item-count verification. A failed restore removes the partial target best-effort. +* Scope every ARN lookup and mutation to the caller's account even though the ARN is globally unique. The shared engine layer separately rejects foreign-account ARNs with `AccessDeniedException`. + +Catalog and account schemas are introduced as V002 migrations. The migrate workflow applies account-data migrations to every existing account keyspace; new accounts already receive all registered migrations during provisioning. + +## Consistency and feature boundary + +This is an item-consistent logical copy, not a cluster SSTable snapshot. Cassandra has no MVCC snapshot spanning a token-range scan, so concurrent writes can race backup creation. This matches the current PostgreSQL reference's logical scan limitation but is not advertised as strict point-in-time recovery. + +`DescribeContinuousBackups` and `UpdateContinuousBackups` persist DynamoDB-compatible status. `RestoreTableToPointInTime` remains explicitly unsupported, matching the upstream engine handler. Implementing real PITR requires mutation history or CDC plus a restore watermark and retention worker; taking a fresh on-demand backup and labeling it a historical restore would violate fidelity. + +The first implementation restores base table schema and items, matching the PostgreSQL reference. GSI/LSI definitions, tags, TTL configuration, and stream history are not restored. + +## Consequences + +### Positive + +* Backup APIs work through CQL without privileged node access. +* Payloads stay in account-isolated keyspaces and are removed with the account keyspace. +* Listing and ARN lookup follow Cassandra query-first schema design. +* Bounded payload partitions avoid a single unbounded backup partition. +* Interrupted creation is never exposed as an available backup. + +### Negative + +* Backup creation is not a strict point-in-time image under concurrent mutation. +* Snapshot and restore cost scale linearly with item count and currently write items sequentially. +* Backup and restore execute synchronously in the request task for parity with the current backends. A production-scale iteration should persist jobs and move payload copying, retries, and reconciliation into background workers. +* Interrupted `CREATING`/`DELETING` operations need a future reconciliation worker for guaranteed orphan cleanup. +* Restoring secondary-index and other table-adjacent configuration requires a future schema extension. diff --git a/docs/adr/0020-cassandra-occ-update-item.md b/docs/adr/0020-cassandra-occ-update-item.md new file mode 100644 index 00000000..c1664812 --- /dev/null +++ b/docs/adr/0020-cassandra-occ-update-item.md @@ -0,0 +1,55 @@ +# ADR-0020: Optimistic Concurrency Control for UpdateItem and PutItem + +- Status: Accepted +- Date: 2026-08-31 +- Deciders: ExtendDB Cassandra plugin contributors + +## Context + +`update_item_impl` and the conditional path of `put_item_impl` use a read-check-write pattern: read `item_data`, mutate in Rust, write back. This is not atomic. Under concurrent writes to the same item, the last writer wins and intermediate updates are silently lost. This affects all `UpdateItem` calls — not just conditional ones. The root cause is the non-atomic read-modify-write cycle itself. + +## Options Considered + +1. **Version-based OCC via LWT** — Add a `version bigint` column to each data table. Every successful write increments it. The read-modify-write cycle is made atomic by conditioning the write on `version = AND prepared_txn_id = NULL`. A `[applied] = false` response means another writer won the race; retry from the read. + +2. **Serialized writes via a per-item lock service** — Acquire an external distributed lock per item key before each write. Rejected: adds infrastructure dependency and defeats Cassandra's distributed architecture. + +3. **Accept last-writer-wins** — Document the behavior and leave it to callers to use conditional expressions. Rejected: DynamoDB's `UpdateItem` is defined to be atomic; silent data loss is not acceptable. + +## Decision + +Version-based OCC via LWT (option 1). + +## Rationale + +- Closes the race atomically at the storage layer with no external dependency. +- The LWT condition must include both `version = ?` and `prepared_txn_id = NULL`. Checking `version` alone is insufficient: a transaction could PREPARE the row (setting `prepared_txn_id`) without changing `version`, and a concurrent non-transactional writer's `IF version = ?` would then succeed and overwrite a prepared item, corrupting the in-flight transaction. +- Existing rows with `version = NULL` are treated as version 0; the first write uses `IF version = NULL AND prepared_txn_id = NULL`. +- Transaction COMMIT must increment `version` (`version = version + 1`) so that a non-transactional writer that read a pre-transaction version cannot overwrite committed transaction data. The `IF prepared_txn_id = ?` guard on COMMIT ensures the blind increment is safe. +- Transaction PREPARE and ROLLBACK do not touch `version`; PREPARE sets `prepared_txn_id` (which blocks non-transactional writers via their LWT condition), and ROLLBACK leaves `version` at the pre-PREPARE value since the item was not modified. + +## Retry policy + +- **Max retries:** 20 +- **Base delay:** 2 ms +- **Backoff:** full jitter — `sleep = random(0, base * 2^min(attempt, 3))`, capped at a 16 ms window to bound tail latency +- **Retry on:** `[applied] = false` (clean lost race) only +- **Do not retry on:** Cassandra errors (timeout, unavailable) — propagate immediately + +## Consequences + +- A `version bigint` column is added to all data tables via migration. +- `update_item_impl` and the conditional `put_item_impl` path use an OCC retry loop. +- `commit_put_or_update` in the transaction system adds `version = version + 1` to the COMMIT UPDATE. +- Under high contention (many concurrent writers to the same item), tail latency increases due to retries. The retry ceiling (20) and bounded backoff (max 16 ms window) prevent unbounded latency growth. + +--- + +## License + +Copyright 2026 ExtendDB contributors. Licensed under the Apache License, Version 2.0. +See [LICENSE](../../LICENSE) for the full text. + +This software is provided "as is" without warranty of any kind. ExtendDB is not +affiliated with, endorsed by, or sponsored by Amazon Web Services. "DynamoDB" is +a trademark of Amazon.com, Inc. diff --git a/docs/adr/README.md b/docs/adr/README.md index ff627acc..8c27fcf0 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -36,4 +36,23 @@ decision, write a new ADR. | [0004](0004-vector-search-exact-scan.md) | Vector search is an exact scan over one row per vector | Accepted | | [0005](0005-index-build-lifecycle-ownership.md) | Index-build lifecycle stays in the backend until a second backend needs it | Accepted | | [0006](0006-pgvector-storage-and-scoring.md) | Vector storage on PostgreSQL uses pgvector's type, and the engine decides what it cannot compute | Accepted | -| [0010](0010-cassandra-ttl-expiration-queue.md) | Durable sharded expiration queue for Cassandra TTL | Accepted | \ No newline at end of file +| [0010](0010-cassandra-ttl-expiration-queue.md) | Cassandra: durable sharded expiration queue for TTL | Accepted | +| [0011](0011-cassandra-foreign-key-emulation.md) | Cassandra: foreign key constraint emulation | Accepted | +| [0012](0012-cassandra-transaction-atomicity-patterns.md) | Cassandra: transaction and atomicity patterns for IAM catalog | Accepted | +| [0013](0013-cassandra-keyspace-awareness.md) | Cassandra: dynamic keyspace construction | Accepted | +| [0014](0014-cassandra-upsert-semantics.md) | Cassandra: natural UPSERT semantics | Accepted | +| [0015](0015-cassandra-account-keyspace-provisioning.md) | Cassandra: account keyspace provisioning timing | Accepted | +| [0016](0016-cassandra-index-table-primary-key-structure.md) | Cassandra: index table primary-key structure | Accepted | +| [0017](0017-cassandra-transaction-implementation.md) | Cassandra: TransactWriteItems / TransactGetItems implementation | Accepted | +| [0018](0018-cassandra-stream-implementation.md) | Cassandra: DynamoDB Streams implementation | Accepted | +| [0019](0019-cassandra-logical-backup-restore.md) | Cassandra: logical backup and restore | Accepted | +| [0020](0020-cassandra-occ-update-item.md) | Cassandra: optimistic concurrency control for UpdateItem and PutItem | Accepted | +| [0011](0011-foreign-key-emulation.md) | Cassandra: foreign key constraint emulation | Accepted | +| [0012](0012-transaction-atomicity-patterns.md) | Cassandra: transaction and atomicity patterns for IAM catalog | Accepted | +| [0013](0013-keyspace-awareness.md) | Cassandra: dynamic keyspace construction | Accepted | +| [0014](0014-upsert-semantics.md) | Cassandra: natural UPSERT semantics | Accepted | +| [0015](0015-account-keyspace-provisioning.md) | Cassandra: account keyspace provisioning timing | Accepted | +| [0016](0016-index-table-primary-key-structure.md) | Cassandra: index table primary-key structure | Accepted | +| [0017](0017-transaction-implementation.md) | Cassandra: TransactWriteItems / TransactGetItems implementation | Accepted | +| [0018](0018-stream-implementation.md) | Cassandra: DynamoDB Streams implementation | Accepted | +| [0019](0019-logical-backup-restore.md) | Cassandra: logical backup and restore | Accepted | \ No newline at end of file diff --git a/docs/rfcs/draft-cassandra-backend.md b/docs/rfcs/draft-cassandra-backend.md new file mode 100644 index 00000000..d40f7411 --- /dev/null +++ b/docs/rfcs/draft-cassandra-backend.md @@ -0,0 +1,264 @@ +# RFC-000X: Apache Cassandra Storage Backend + +- Status: Draft +- Author: @jcshepherd +- Created: 2026-09-10 +- Tracking issue: TBD + +## Summary + +This RFC proposes a production-grade Apache Cassandra storage backend for ExtendDB. The design targets teams that +operate Cassandra clusters and want linear horizontal scalability, native multi-datacenter replication, and a peer-to-peer +architecture with no single write bottleneck: a deployment profile the reference PostgreSQL backend does not address. + +The non-trivial design work is in conforming to DynamoDB semantics on a database that provides neither multi-row ACID +transactions nor native sequence primitives. This document focuses on those gaps, the decisions made to close them, and +the places where full parity with Amazon DynamoDB is not yet achieved. + +## Motivation + +ExtendDB's PostgreSQL backend is well-suited for single-node and small-cluster deployments, but PostgreSQL's single-primary +architecture limits write scalability and does not provide native multi-datacenter active-active replication. Teams that +need linear, horizontal write scaling, geographic distribution with active-active writes, or a peer-to-peer architecture +with no single point of failure often already operate Cassandra, and will want DynamoDB API compatibility without adopting +a new infrastructure dependency. + +Cassandra is a natural fit for this role. Its data model — partition key for distribution, clustering key for ordering +within a partition — maps directly onto DynamoDB's data architecture. Both systems are designed for high write throughput +with tunable consistency, both treat non-key attributes as schemaless, and both support native multi-datacenter replication. +The design challenges addressed in this RFC arise not from a mismatch between the two systems but from the specific +DynamoDB features — transactions, TTL with Streams integration, eventually consistent GSIs — that require capabilities +Cassandra does not provide natively. + +The Cassandra backend is not a replacement for the PostgreSQL backend. It targets a different operational profile: +higher complexity in exchange for horizontal scalability and geographic distribution. + +## Detailed Design + +### Repository structure + +The backend lives at `../../crates/storage-cassandra` in the main ExtendDB repository, following the mono-repo structure +prescribed by RFC-0002. It is selected at build time via a `cassandra` Cargo feature flag on the `extenddb` binary crate. +Backends are mutually exclusive; a build enabling more than one is rejected at compile time. + +### Plugin registration + +The crate exposes a single `backend()` function returning an `extenddb_storage::Backend` descriptor. The thin `main` +binary calls `extenddb_storage::set_backend(extenddb_storage_cassandra::backend())` before dispatching any subcommand. +The descriptor carries factory functions for bootstrapping, config parsing, settings and diagnostics store access, +and server component construction. + +### Data Architecture + +The backend uses Cassandra keyspaces to independently manage cross-account 'catalog' data, and individual account-owned +data. + +**`{prefix}_catalog`** — system-wide metadata, created during `extenddb init`. Holds accounts, IAM entities (users, +groups, roles, policies, access keys), table definitions, stream shard metadata, idempotency tokens, settings, and schema +migration history. + +**`{prefix}_account_{account_id}`** — one keyspace per ExtendDB account, created when the account is created. Holds item +tables (one per DynamoDB table), secondary index tables, stream records, transaction ledger, TTL expiration queues, and +backup payloads. Account deletion drops the entire keyspace, atomically removing all the account's data. + +The keyspace-per-account split is primarily an operational decision: it allows independent replication configuration per +account, per-account granularity in Cassandra's backup and monitoring tooling, and scoped schema migrations. Account +keyspaces are created eagerly at account creation time so the schema-agreement cost is paid before the user creates +any ExtendDB resources in their account. + +Item tables use typed columns for the primary key (`pk`, and `sk_s`/`sk_n`/`sk_b` for string, number, and binary sort +keys respectively) and a JSON blob for all other attributes. Typed key columns enable Cassandra's native clustering-key +ordering and range scans for sort key conditions. The JSON blob preserves DynamoDB's schemaless attribute model without +requiring schema migrations when items gain or lose non-key attributes. + +All data-plane writes and strongly consistent reads use `LOCAL_QUORUM`. Lightweight Transactions (LWT) use Paxos and are +linearizable within a datacenter. Background workers use `LOCAL_ONE` where eventual consistency is acceptable. + +### Catalog integrity + +The IAM catalog — which has parent-child relationships between accounts, users, roles, policies, and access keys — relies +on referential integrity and transactional mutations across entities to maintain consistency. Cassandra does not support +referential integrity constraints, and Cassandra's logged batches (atomic batch updates) impose significant constraints +on LWT statements (`IF NOT EXISTS`, `IF EXISTS`) within a batch. Duplicate detection and multi-row atomic writes cannot +be composed into a single operation. The Cassandra backend uses two compensating patterns: + +**Referential integrity** is enforced via application-layer pre-checks: before inserting a child entity, a quorum read +verifies the parent exists, returning the same error a database FK violation would produce. This is correct on the normal +path. The narrow exception is a concurrent deletion race: if a parent entity is deleted in the window between the +pre-check read and the child insert, an orphaned child record can be created. This window is milliseconds on a local +cluster, and IAM deletions are rare admin-driven operations, so the practical risk is low. Orphaned records are cleaned +up when the parent account is deleted (via `DROP KEYSPACE`). This is a behavioral difference from DynamoDB, where IAM +operations are atomically enforced at the service layer. See [ADR-0011](../adr/0011-cassandra-foreign-key-emulation.md). + +**Uniqueness and multi-row atomicity** for operations that must both detect duplicates and write multiple rows (e.g., +creating a user, which writes both a user record and an initial policy row) use a check-then-batch pattern: a SELECT +checks for duplicates, then a logged batch writes all rows atomically. Because LWTs across partitions cannot be part of +the same logged batch execution, the duplicate check and the write are separate operations, introducing a narrow race +window between them. Again, IAM operations are infrequent enough that the collision probability is negligible. See [ADR-0012](../adr/0012-cassandra-transaction-atomicity-patterns.md). + +### Transactions + +DynamoDB's `TransactWriteItems` requires serializable isolation: all operations succeed or all fail, concurrent transactions +on overlapping items are serialized, and in-progress state is never visible to concurrent transactions. Cassandra logged +batches provide atomicity but not isolation: two concurrent batches can interleave freely, and there is no mechanism to +block a non-transactional write to an item that a transaction is currently preparing. + +The implementation uses a two-phase commit protocol with per-item intent markers and LWT for conflict detection. The full +protocol is specified in [ADR-0017](../adr/0017-cassandra-transaction-implementation.md); the three decisions worth highlighting here: + +**Intent markers via LWT.** Each item row carries a `prepared_txn_id` column. The PREPARE phase claims each item atomically +using an LWT conditioned on `prepared_txn_id IS NULL`. If any claim fails, because another transaction or a non-transactional +write holds the item, the transaction cancels and all claimed items are released. Non-transactional writes (`PutItem`, +`UpdateItem`, `DeleteItem`) check the same column and return `TransactionConflictException` if it is set. Read operations +return the last committed value and ignore the marker entirely, matching DynamoDB's read-committed visibility for +non-transactional reads. + +**Durable ledger.** Before touching any item, a ledger entry is written to `transaction_ledger` in the account keyspace +containing the complete write set. The ledger lives in the account keyspace — not the catalog — because transactions are +scoped to a single account's data, and co-locating the ledger with the data it describes means it is dropped atomically +with the account. The ledger is written in two phases: key information before PREPARE begins, full computed write data +after all claims succeed and before transitioning to COMMITTING. A transaction in COMMITTING state therefore always has +complete write data available for recovery. + +**Recovery worker.** A background worker scans the transaction ledger every 30 seconds for transactions older than +60 seconds. Transactions in COMMITTING state are resumed; transactions in PREPARING state are rolled back. All recovery +operations are idempotent. LWT conditions handle items already committed or rolled back by a concurrent recovery pass. + +### Secondary indexes (GSI/LSI) + +**LSIs** are updated synchronously in the same (atomic) logged batch as the base table write. The batch deletes the old +index row (if the item previously existed and had values for the index key attributes) and inserts the new one. Items +without values for the index key attributes are omitted, consistent with DynamoDB's sparse index behavior. + +**GSIs** are eventually consistent by design in DynamoDB, and the implementation reflects this. Similarly to the reference +PostgreSQL backend, a queue entry is written atomically in the same logged batch as the base write, ensuring no update +is silently lost even if the server crashes immediately after the batch commits. A background worker processes queue +entries and applies the index update. + +**Index table primary key structure** requires care. A naive translation of the base table's key schema would place the +index sort key as a regular column, but Cassandra range scans require the sort key to be a clustering key. The index +table PRIMARY KEY is therefore `((index_pk), index_sk_*, base_pk, base_sk_*)`: the index partition key as the partition +key, index sort key columns as leading clustering keys (enabling range scans for `KeyConditionExpression`), and base +table key columns as trailing clustering keys to ensure each base item appears at most once even when multiple items +share the same index key values. DELETE operations on the index must supply all primary key components (index keys and +base keys) which differs from what a direct port of the base table delete logic would expect. See [ADR-0016](../adr/0016-cassandra-index-table-primary-key-structure.md). + +### DynamoDB Streams + +**Sequence numbers.** DynamoDB Streams requires sequence numbers that are strictly ordered and comparable within a shard. +Cassandra has no sequence primitive; its `counter` implementation is not idempotent and cannot participate in a logged +batch. The ExtendDB Cassandra backend generates stream sequence numbers using a Hybrid Logical Clock (HLC): a numeric +string combining a millisecond wall-clock timestamp, a per-node logical counter (for multiple events within the same +millisecond), and a node identifier (for concurrent writes across server nodes). Lexicographic string comparison of HLC +values matches temporal order within a shard. The node identifier is derived from a hash of the host and port, requiring +no configuration and remaining stable across restarts. See [ADR-0018](../adr/0018-cassandra-stream-implementation.md) and the [HLC paper](https://cse.buffalo.edu/tech-reports/2014-04.pdf) for details. + +**Atomic writes.** Stream records are written in the same logged batch as the data modification, guaranteeing that a +stream record is emitted if and only if the data write commits. + +**Retention.** Stream records are stored with a native Cassandra row-level TTL (30 hours, providing a buffer above +DynamoDB's 24-hour minimum). Native TTL is appropriate here because stream record expiry carries no observable DynamoDB +semantics, unlike item TTL expiry, which must emit a `REMOVE` stream record and is addressed in the next section. + +### Time to Live + +DynamoDB TTL deletions are observable: they must emit `REMOVE` stream records with a service identity (`dynamodb.amazonaws.com`), +clean up secondary index rows, and respect the item's current TTL value at delete time: an item whose TTL attribute has +been updated or removed since expiry was scheduled must not be deleted. Cassandra's native row-level TTL evicts rows +silently at the storage layer with no application hook, making it unsuitable for DynamoDB item TTL. + +The implementation uses an application-layer expiration worker backed by a durable, generation-fenced expiration queue. +When TTL is enabled on a table, the backend scans all items and registers those with a valid TTL timestamp into a queue +partitioned by `(table_id, generation, expiry_day, key_shard)`. A background worker sweeps due entries, re-reads each +item to verify it is still expired and unchanged, then deletes it through the normal delete path, including emitting stream +records and cleaning up index rows as part of the deletion. The full state machine, recovery protocol, and operational +constraints are specified in [ADR-0010](../adr/0010-cassandra-ttl-expiration-queue.md). + +Three constraints are worth calling out explicitly: + +**Enabling TTL on a table that is already serving writes requires a brief write quiesce.** The expiration queue is built +by scanning the table at enable time, and writes that began before TTL was enabled cannot be retroactively enrolled in +the new generation. New tables can enable TTL before accepting traffic and avoid this entirely. + +**TTL cannot be enabled on a table with asynchronously propagated GSIs.** The async GSI queue has no version-conditional +replay fence, so a stale TTL delete could overtake a recreated item's GSI insert. Base tables, LSIs, and synchronous GSIs +are supported. + +**Expiration throughput is bounded.** The worker processes up to 100 items per table per 60-second cycle under a per-table +sweep lease. This matches the throughput of the other backends and is sufficient for typical workloads; tables with sustained +high expiration rates will accumulate backlog. + +### Backup and restore + +Cassandra's native snapshot mechanism (`nodetool snapshot`) produces node-local SSTable artifacts managed outside CQL. +Creating, enumerating, or restoring a distributed snapshot requires cluster-level orchestration that is not available +through the driver session, so the backend implements on-demand backups as logical snapshots instead. + +`CreateBackup` scans the source table and writes item payloads into a `backup_items` table in the account keyspace, +partitioned to bound individual partition size. Backup metadata is written in two phases: a `CREATING` row first, then a +transition to `AVAILABLE` only after all item chunks are persisted. An interrupted backup is never visible to callers +as available. `RestoreTableFromBackup` creates the target table through the normal path and writes items back through +the normal item write path, so typed key columns and index rows are reconstructed correctly. See [ADR-0019](../adr/0019-cassandra-logical-backup-restore.md). + +The first implementation restores base table schema and items. GSI/LSI definitions, tags, TTL configuration, and stream +history are not restored; this is a known gap. + +Backup creation and restore execute synchronously within the API request. This is consistent with the current behavior +of all ExtendDB backends, but differs from Amazon DynamoDB, where these are asynchronous operations. The same applies +to `ExportTableToPointInTime` and `ImportTable`. Moving these to background jobs is planned future work. + +## 4. Behavioral Differences from DynamoDB + +The following behaviors differ from Amazon DynamoDB. The complete list across ExtendDB is maintained in +`../differences-from-dynamodb.md`. + +**IAM catalog consistency.** Referential integrity in the IAM catalog is enforced via application-layer pre-checks rather +than atomic database constraints. A narrow race window exists on concurrent parent-entity deletion; see the Catalog +integrity section. + +**TTL: brief effects-before-delete window.** Because Cassandra cannot atomically combine a conditional write on the base +item with mutations in other partitions, secondary index cleanup and stream record emission become durable immediately +before the final base item delete. A brief window exists where index rows are removed but the base item is still present. +This is not visible to normal read operations but is observable if the server crashes in that window and the item is read +before recovery completes. + +**TTL: not supported alongside asynchronously propagated GSIs.** Enabling TTL on a table with async GSIs returns an error. +See the TTL section. + +**TTL: write quiesce required when enabling on a live table.** See the TTL section. + +**Backup is not a strict point-in-time image.** Logical backup scans the table without a cluster-wide snapshot; concurrent +writes can race the scan. `RestoreTableToPointInTime` is not supported. + +**Data movement operations are synchronous.** `CreateBackup`, `RestoreTableFromBackup`, `ExportTableToPointInTime`, +and `ImportTable` execute synchronously and block the API response until complete. Amazon DynamoDB performs these +asynchronously. + +**Transaction recovery is conservative.** Transactions found in PREPARING state during recovery are always rolled back, +even if they could theoretically have been committed. This is safe but means a transaction that completed PREPARE and +then encountered a server crash will be rolled back rather than committed. + +## Known Gaps and Future Work + +**TTL expiration throughput does not scale horizontally.** The sweep lease is per table; the queue is already sharded 64 +ways by key, and those shards are disjoint partitions. Leasing per `(table, shard)` would allow up to 64 concurrent workers +per table with no change to the claim protocol. This is the highest-value follow-up. + +**TTL backfill has no durable cursor.** A failure during `UpdateTimeToLive` restarts the table scan from the beginning, +and the API call blocks for its duration. Large tables will need a durable, checkpointed background backfill before this +path is suitable at scale. + +**Backup and restore are synchronous.** Moving `CreateBackup`, `RestoreTableFromBackup`, `ExportTableToPointInTime`, and +`ImportTable` to background jobs with status polling is planned but not yet implemented. This applies to all current +ExtendDB backends. + +**Restore does not recover GSI/LSI definitions, tags, TTL configuration, or stream history.** + +**Transaction fence uses item data equality.** The TTL deletion protocol conditions the final base item delete on the +stored item image rather than a version counter. A monotonic version column would be a more robust fence and remove +the dependency on stable JSON encoding. + +**Transaction recovery does not reconcile pre-commit images.** The transaction ledger does not persist the pre-commit +item image, so a transaction that crashes between COMMIT and TTL queue reconciliation can leave the item's previous +expiration entry in the queue until it comes due. The entry is inert — the worker revalidates before deleting — but it +is queue garbage. From 1cd9321ff436abf7dd4465b0e8ae882bbed00941 Mon Sep 17 00:00:00 2001 From: Joel Shepherd Date: Mon, 14 Sep 2026 18:20:14 +0000 Subject: [PATCH 19/48] fix(storage-cassandra): Single constant for CATALOG_VERSION, enforce failed login limits, metrics support, atomic access key cleanup --- crates/storage-cassandra/src/bootstrapper.rs | 2 +- crates/storage-cassandra/src/catalog_store.rs | 253 ++++++++++++++++-- crates/storage-cassandra/src/lib.rs | 2 + .../src/management_store/access_keys.rs | 60 ++--- crates/storage-cassandra/src/operations.rs | 2 +- 5 files changed, 265 insertions(+), 54 deletions(-) diff --git a/crates/storage-cassandra/src/bootstrapper.rs b/crates/storage-cassandra/src/bootstrapper.rs index 7ff1264c..232828cd 100644 --- a/crates/storage-cassandra/src/bootstrapper.rs +++ b/crates/storage-cassandra/src/bootstrapper.rs @@ -19,7 +19,7 @@ use crate::config::CassandraStorageConfig; use crate::engine::CassandraEngine; use crate::migrations; -const CATALOG_VERSION: &str = "0.0.3"; +use crate::CATALOG_VERSION; // Helper structs for parsing Cassandra rows #[derive(Debug, Clone, TryFromRow)] diff --git a/crates/storage-cassandra/src/catalog_store.rs b/crates/storage-cassandra/src/catalog_store.rs index 79ccd626..29e29ccf 100644 --- a/crates/storage-cassandra/src/catalog_store.rs +++ b/crates/storage-cassandra/src/catalog_store.rs @@ -36,6 +36,9 @@ pub struct CassandraCatalogStore { /// Cached encryption key (immutable after bootstrap). Avoids /// per-request DB query on access key and assume-role operations. encryption_key: Option>, + /// TTL in seconds applied to every metrics row on insert. + /// Cassandra expires rows natively; `prune_metrics` is a no-op. + metrics_ttl_seconds: i32, } impl CassandraCatalogStore { @@ -53,6 +56,7 @@ impl CassandraCatalogStore { datacenter, replication_factor, encryption_key: None, + metrics_ttl_seconds: 86400, } } @@ -71,6 +75,7 @@ impl CassandraCatalogStore { datacenter, replication_factor, encryption_key: Some(Arc::from(encryption_key.as_str())), + metrics_ttl_seconds: 86400, } } @@ -400,46 +405,260 @@ use extenddb_storage::management_store::{MetricsStore, RateLimitStore}; impl RateLimitStore for CassandraCatalogStore { fn count_principal_failures( &self, - _principal: &str, - _window_seconds: i64, + principal: &str, + window_seconds: i64, ) -> BoxFuture<'_, OpResult> { - Box::pin(async move { Ok(0) }) + let principal = principal.to_owned(); + let session = self.session.clone(); + let catalog_keyspace = self.catalog_keyspace(); + Box::pin(async move { + let cutoff = chrono::Utc::now() + - chrono::Duration::seconds(window_seconds); + let cutoff_ms = cutoff.timestamp_millis(); + let query = format!( + "SELECT COUNT(*) FROM {catalog_keyspace}.login_attempts \ + WHERE principal = ? AND attempted_at > ? AND success = false \ + ALLOW FILTERING" + ); + let result = session + .query_with_values(&query, cdrs_tokio::query_values!(principal.as_str(), cutoff_ms)) + .await + .map_err(|e| { + tracing::error!("count_principal_failures: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + let count: i64 = result + .response_body() + .map_err(|e| OpError::Internal(e.to_string()))? + .into_rows() + .and_then(|mut rows| rows.pop()) + .and_then(|row| row.get_r_by_name("count").ok()) + .unwrap_or(0); + Ok(count) + }) } fn count_ip_failures( &self, - _source_ip: &str, - _window_seconds: i64, + source_ip: &str, + window_seconds: i64, ) -> BoxFuture<'_, OpResult> { - Box::pin(async move { Ok(0) }) + let source_ip = source_ip.to_owned(); + let session = self.session.clone(); + let catalog_keyspace = self.catalog_keyspace(); + Box::pin(async move { + let cutoff = chrono::Utc::now() + - chrono::Duration::seconds(window_seconds); + let cutoff_ms = cutoff.timestamp_millis(); + // No partition key available for source_ip; ALLOW FILTERING is + // acceptable because cleanup_old_attempts keeps the table bounded. + let query = format!( + "SELECT COUNT(*) FROM {catalog_keyspace}.login_attempts \ + WHERE source_ip = ? AND attempted_at > ? AND success = false \ + ALLOW FILTERING" + ); + let result = session + .query_with_values(&query, cdrs_tokio::query_values!(source_ip.as_str(), cutoff_ms)) + .await + .map_err(|e| { + tracing::error!("count_ip_failures: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + let count: i64 = result + .response_body() + .map_err(|e| OpError::Internal(e.to_string()))? + .into_rows() + .and_then(|mut rows| rows.pop()) + .and_then(|row| row.get_r_by_name("count").ok()) + .unwrap_or(0); + Ok(count) + }) } - fn record_failed_login(&self, _principal: &str, _source_ip: Option<&str>) -> BoxFuture<'_, ()> { - Box::pin(async move {}) + fn record_failed_login(&self, principal: &str, source_ip: Option<&str>) -> BoxFuture<'_, ()> { + let principal = principal.to_owned(); + let source_ip = source_ip.map(str::to_owned); + let session = self.session.clone(); + let catalog_keyspace = self.catalog_keyspace(); + Box::pin(async move { + let now_ms = chrono::Utc::now().timestamp_millis(); + let query = format!( + "INSERT INTO {catalog_keyspace}.login_attempts \ + (principal, attempted_at, success, source_ip) VALUES (?, ?, false, ?)" + ); + if let Err(e) = session + .query_with_values( + &query, + cdrs_tokio::query_values!( + principal.as_str(), + now_ms, + source_ip.as_deref() + ), + ) + .await + { + tracing::error!("record_failed_login: {e}"); + } + }) } - fn cleanup_old_attempts(&self, _max_age_seconds: i64) -> BoxFuture<'_, ()> { - Box::pin(async move {}) + fn cleanup_old_attempts(&self, max_age_seconds: i64) -> BoxFuture<'_, ()> { + let session = self.session.clone(); + let catalog_keyspace = self.catalog_keyspace(); + Box::pin(async move { + let cutoff = chrono::Utc::now() + - chrono::Duration::seconds(max_age_seconds); + let cutoff_ms = cutoff.timestamp_millis(); + // Fetch principals with old records, then delete by partition key. + // Cassandra does not support DELETE ... WHERE non-pk < ? without + // ALLOW FILTERING; fetching principals first avoids a full scan on delete. + let select = format!( + "SELECT DISTINCT principal FROM {catalog_keyspace}.login_attempts" + ); + let principals = match session.query(&select).await + .and_then(|r| r.response_body()) + .map(|b| b.into_rows().unwrap_or_default()) + { + Ok(rows) => rows, + Err(e) => { + tracing::error!("cleanup_old_attempts fetch principals: {e}"); + return; + } + }; + let delete = format!( + "DELETE FROM {catalog_keyspace}.login_attempts \ + WHERE principal = ? AND attempted_at < ?" + ); + for row in principals { + let Ok(principal): Result = row.get_r_by_name("principal") else { + continue; + }; + if let Err(e) = session + .query_with_values( + &delete, + cdrs_tokio::query_values!(principal.as_str(), cutoff_ms), + ) + .await + { + tracing::error!("cleanup_old_attempts delete for {principal}: {e}"); + } + } + }) } } use extenddb_storage::management_store::MetricsRow; impl MetricsStore for CassandraCatalogStore { - fn insert_metrics(&self, _rows: &[MetricsRow]) -> BoxFuture<'_, OpResult<()>> { - Box::pin(async move { Ok(()) }) + fn insert_metrics(&self, rows: &[MetricsRow]) -> BoxFuture<'_, OpResult<()>> { + let rows = rows.to_vec(); + let session = self.session.clone(); + let catalog_keyspace = self.catalog_keyspace(); + let ttl = self.metrics_ttl_seconds; + Box::pin(async move { + let query = format!( + "INSERT INTO {catalog_keyspace}.metrics \ + (bucket, metric, table_name, index_name, operation, sum, count, min, max) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) USING TTL {ttl}" + ); + for row in &rows { + #[allow(clippy::cast_possible_truncation)] + let bucket_ms = (row.bucket.unix_timestamp_nanos() / 1_000_000) as i64; + if let Err(e) = session + .query_with_values( + &query, + cdrs_tokio::query_values!( + bucket_ms, + row.metric.as_str(), + row.table_name.as_deref().unwrap_or(""), + row.index_name.as_deref().unwrap_or(""), + row.operation.as_deref().unwrap_or(""), + row.sum, + row.count, + row.min, + row.max + ), + ) + .await + { + tracing::warn!("Failed to insert metrics row: {e}"); + } + } + Ok(()) + }) } fn query_metrics( &self, - _start: time::OffsetDateTime, - _end: time::OffsetDateTime, - _table_name: Option<&str>, - _metric: Option<&str>, + start: time::OffsetDateTime, + end: time::OffsetDateTime, + table_name: Option<&str>, + metric: Option<&str>, ) -> BoxFuture<'_, OpResult>> { - Box::pin(async move { Ok(vec![]) }) + let table_name = table_name.map(str::to_owned); + let metric = metric.map(str::to_owned); + let session = self.session.clone(); + let catalog_keyspace = self.catalog_keyspace(); + Box::pin(async move { + #[allow(clippy::cast_possible_truncation)] + let start_ms = (start.unix_timestamp_nanos() / 1_000_000) as i64; + #[allow(clippy::cast_possible_truncation)] + let end_ms = (end.unix_timestamp_nanos() / 1_000_000) as i64; + + // bucket is part of the composite partition key so a range scan + // requires ALLOW FILTERING; the time window keeps the result bounded. + let query = format!( + "SELECT bucket, metric, table_name, index_name, operation, \ + sum, count, min, max \ + FROM {catalog_keyspace}.metrics \ + WHERE bucket >= ? AND bucket <= ? ALLOW FILTERING" + ); + let result = session + .query_with_values(&query, cdrs_tokio::query_values!(start_ms, end_ms)) + .await + .map_err(|e| { + tracing::warn!("query_metrics: {e}"); + OpError::Internal("Database error".to_owned()) + })?; + + let rows = result + .response_body() + .map_err(|e| OpError::Internal(e.to_string()))? + .into_rows() + .unwrap_or_default(); + + let mut out = Vec::with_capacity(rows.len()); + for row in rows { + use crate::cassandra_util::get_column; + let bucket_ms: i64 = get_column(&row, "bucket", "query_metrics")?; + let bucket = time::OffsetDateTime::from_unix_timestamp(bucket_ms / 1000) + .unwrap_or(time::OffsetDateTime::UNIX_EPOCH); + let metric_val: String = get_column(&row, "metric", "query_metrics")?; + let tn: String = get_column(&row, "table_name", "query_metrics")?; + let idx: String = get_column(&row, "index_name", "query_metrics")?; + let op: String = get_column(&row, "operation", "query_metrics")?; + + if table_name.as_deref().is_some_and(|f| f != tn) { continue; } + if metric.as_deref().is_some_and(|f| f != metric_val) { continue; } + + out.push(MetricsRow { + bucket, + metric: metric_val, + table_name: if tn.is_empty() { None } else { Some(tn) }, + index_name: if idx.is_empty() { None } else { Some(idx) }, + operation: if op.is_empty() { None } else { Some(op) }, + sum: get_column(&row, "sum", "query_metrics")?, + count: get_column(&row, "count", "query_metrics")?, + min: get_column(&row, "min", "query_metrics")?, + max: get_column(&row, "max", "query_metrics")?, + }); + } + out.sort_by_key(|r| r.bucket); + Ok(out) + }) } + /// No-op: Cassandra native TTL (set on insert) handles metrics expiry. fn prune_metrics(&self, _retention: std::time::Duration) -> BoxFuture<'_, OpResult<()>> { Box::pin(async move { Ok(()) }) } diff --git a/crates/storage-cassandra/src/lib.rs b/crates/storage-cassandra/src/lib.rs index a04f34ee..4a4d8c74 100644 --- a/crates/storage-cassandra/src/lib.rs +++ b/crates/storage-cassandra/src/lib.rs @@ -7,6 +7,8 @@ //! as the underlying database. It provides a DynamoDB-compatible API backed //! by Cassandra's distributed architecture. +pub const CATALOG_VERSION: &str = "0.0.3"; + mod admin_store; mod authorization_store; mod backup_engine; diff --git a/crates/storage-cassandra/src/management_store/access_keys.rs b/crates/storage-cassandra/src/management_store/access_keys.rs index 4121571b..56201ad5 100755 --- a/crates/storage-cassandra/src/management_store/access_keys.rs +++ b/crates/storage-cassandra/src/management_store/access_keys.rs @@ -5,6 +5,7 @@ use crate::catalog_store::CassandraCatalogStore; use cdrs_tokio::types::blob::Blob; +use cdrs_tokio::types::IntoRustByName; use extenddb_storage::management_store::{AccessKeyCreated, OpError, OpResult}; impl CassandraCatalogStore { @@ -86,46 +87,35 @@ impl CassandraCatalogStore { ) -> OpResult<()> { let catalog_keyspace = self.catalog_keyspace(); - // Check if key exists and belongs to the correct account/user - let check_query = format!( - "SELECT access_key_id, account_id, user_name FROM {catalog_keyspace}.access_keys \ - WHERE access_key_id = ?" + // Atomic ownership check + delete via LWT. Collapses "not found" and + // "belongs to a different account/user" into the same NotFound error. + let delete_query = format!( + "DELETE FROM {catalog_keyspace}.access_keys \ + WHERE access_key_id = ? IF account_id = ? AND user_name = ?" ); - let row = crate::cassandra_util::query_optional( - self.session(), - &check_query, - cdrs_tokio::query_values!(key_id), - "delete_access_key", - ) - .await?; + let result = self + .session() + .query_with_values( + &delete_query, + cdrs_tokio::query_values!(key_id, account_id, user_name), + ) + .await + .map_err(|e| OpError::Internal(e.to_string()))?; - match row { - None => return Err(OpError::NotFound("Access key not found".to_owned())), - Some(r) => { - // Verify it belongs to the specified account and user - let key_account: String = - crate::cassandra_util::get_column(&r, "account_id", "delete_access_key")?; - let key_user: String = - crate::cassandra_util::get_column(&r, "user_name", "delete_access_key")?; - - if key_account != account_id || key_user != user_name { - return Err(OpError::NotFound("Access key not found".to_owned())); - } - } - } + let applied: bool = result + .response_body() + .map_err(|e| OpError::Internal(e.to_string()))? + .into_rows() + .and_then(|mut rows| rows.pop()) + .and_then(|row| row.get_r_by_name("[applied]").ok()) + .unwrap_or(false); - // Delete the key (by PRIMARY KEY only) - let delete_query = - format!("DELETE FROM {catalog_keyspace}.access_keys WHERE access_key_id = ?"); + if !applied { + return Err(OpError::NotFound("Access key not found".to_owned())); + } - crate::cassandra_util::execute( - self.session(), - &delete_query, - cdrs_tokio::query_values!(key_id), - "delete_access_key", - ) - .await + Ok(()) } pub(crate) async fn list_access_keys_impl( diff --git a/crates/storage-cassandra/src/operations.rs b/crates/storage-cassandra/src/operations.rs index 724a45a5..1aad91d3 100644 --- a/crates/storage-cassandra/src/operations.rs +++ b/crates/storage-cassandra/src/operations.rs @@ -90,7 +90,7 @@ impl OperationsEngine for CassandraOperationsEngine { } fn catalog_version(&self) -> String { - "0.0.1".to_string() + crate::CATALOG_VERSION.to_string() } fn is_sensitive_key(&self, key: &str) -> bool { From 0d12af0a4a1c0f9293cdfd20cd289883ab41d388 Mon Sep 17 00:00:00 2001 From: Joel Shepherd Date: Mon, 14 Sep 2026 20:06:33 +0000 Subject: [PATCH 20/48] chore(storage-cassandra): removing 'test' used for debugging (PR338 B11) --- tests/test_transaction_operations.py | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/tests/test_transaction_operations.py b/tests/test_transaction_operations.py index d00380e3..3eb774b0 100755 --- a/tests/test_transaction_operations.py +++ b/tests/test_transaction_operations.py @@ -326,27 +326,6 @@ def test_transact_write_conditional_put_fail(dynamodb_client, hash_table): resp = dynamodb_client.get_item(TableName=hash_table, Key={"pk": {"S": "cp-2"}}) assert resp["Item"]["v"]["S"] == "old" -def test_transact_write_conditional_put_fail_diag(dynamodb_client, hash_table): - dynamodb_client.put_item(TableName=hash_table, Item={"pk": {"S": "cp-2"}, "v": {"S": "old"}}) - # Verify the item is readable back via get_item - resp = dynamodb_client.get_item(TableName=hash_table, Key={"pk": {"S": "cp-2"}}) - print(f"\nget_item after put: {resp.get('Item')}") - # Now try the transaction - try: - dynamodb_client.transact_write_items( - TransactItems=[{"Put": { - "TableName": hash_table, - "Item": {"pk": {"S": "cp-2"}, "v": {"S": "new"}}, - "ConditionExpression": "attribute_not_exists(pk)", - }}] - ) - print("NO EXCEPTION - transaction succeeded (wrong)") - # Check what's in the item now - resp2 = dynamodb_client.get_item(TableName=hash_table, Key={"pk": {"S": "cp-2"}}) - print(f"item after transaction: {resp2.get('Item')}") - except Exception as e: - print(f"EXCEPTION: {type(e).__name__}: {e}") - # --------------------------------------------------------------------------- # TransactWriteItems — size limit and condition edge cases # --------------------------------------------------------------------------- From dd78118a5cf0e144eb3526fc96863008eaf17da4 Mon Sep 17 00:00:00 2001 From: Joel Shepherd Date: Mon, 14 Sep 2026 20:12:34 +0000 Subject: [PATCH 21/48] fix(storage-cassandra): correctly map key placeholders to attribute names in is_attribute_not_exists_key (PR338 B2) --- .../src/data/put_get_item.rs | 99 ++++++++++++++++++- 1 file changed, 97 insertions(+), 2 deletions(-) diff --git a/crates/storage-cassandra/src/data/put_get_item.rs b/crates/storage-cassandra/src/data/put_get_item.rs index 359000f7..6eb031f5 100644 --- a/crates/storage-cassandra/src/data/put_get_item.rs +++ b/crates/storage-cassandra/src/data/put_get_item.rs @@ -118,7 +118,7 @@ impl CassandraEngine { .map(|k| k.attribute_name.as_str()) .collect(); - let key_not_exists_condition = is_attribute_not_exists_key(condition, &key_attr_names); + let key_not_exists_condition = is_attribute_not_exists_key(condition, &key_attr_names, maps); if key_not_exists_condition && ttl_config.is_none() { return self .put_item_if_not_exists( @@ -857,6 +857,7 @@ impl CassandraEngine { pub(crate) fn is_attribute_not_exists_key( condition: Option<&Expr>, key_attr_names: &[&str], + maps: &ExpressionMaps, ) -> bool { let Some(Expr::Function { name, args }) = condition else { return false; @@ -873,5 +874,99 @@ pub(crate) fn is_attribute_not_exists_key( let extenddb_core::expression::PathElement::Attribute(attr) = &path[0] else { return false; }; - key_attr_names.contains(&attr.as_str()) + // Resolve expression name alias (e.g. "#p" -> "pk") before comparing. + let resolved = maps + .names + .get(attr.as_str()) + .map(String::as_str) + .unwrap_or(attr.as_str()); + key_attr_names.contains(&resolved) +} + +#[cfg(test)] +mod tests { + use super::*; + use extenddb_core::expression::{ExpressionMaps, PathElement}; + use std::collections::HashMap; + + fn ane_expr(attr: &str) -> Expr { + Expr::Function { + name: "attribute_not_exists".to_owned(), + args: vec![Expr::Path(vec![PathElement::Attribute(attr.to_owned())])], + } + } + + fn maps_with_names(pairs: &[(&str, &str)]) -> ExpressionMaps { + let names: HashMap = + pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(); + ExpressionMaps::new(names, HashMap::new()) + } + + #[test] + fn plain_key_attr_matches() { + let maps = ExpressionMaps::default(); + let expr = ane_expr("pk"); + assert!(is_attribute_not_exists_key(Some(&expr), &["pk"], &maps)); + } + + #[test] + fn plain_non_key_attr_does_not_match() { + let maps = ExpressionMaps::default(); + let expr = ane_expr("guard"); + assert!(!is_attribute_not_exists_key(Some(&expr), &["pk"], &maps)); + } + + #[test] + fn alias_resolving_to_key_matches() { + // attribute_not_exists(#p) with {"#p": "pk"} must match key "pk". + let maps = maps_with_names(&[("#p", "pk")]); + let expr = ane_expr("#p"); + assert!(is_attribute_not_exists_key(Some(&expr), &["pk"], &maps)); + } + + #[test] + fn alias_resolving_to_non_key_does_not_match() { + // attribute_not_exists(#g) with {"#g": "guard"} must not match key "pk". + let maps = maps_with_names(&[("#g", "guard")]); + let expr = ane_expr("#g"); + assert!(!is_attribute_not_exists_key(Some(&expr), &["pk"], &maps)); + } + + #[test] + fn unresolved_alias_literal_does_not_match_key() { + // "#p" with no names map must not match key "pk" (the literal "#p" != "pk"). + let maps = ExpressionMaps::default(); + let expr = ane_expr("#p"); + assert!(!is_attribute_not_exists_key(Some(&expr), &["pk"], &maps)); + } + + #[test] + fn none_condition_does_not_match() { + let maps = ExpressionMaps::default(); + assert!(!is_attribute_not_exists_key(None, &["pk"], &maps)); + } + + #[test] + fn wrong_function_does_not_match() { + let maps = ExpressionMaps::default(); + let expr = Expr::Function { + name: "attribute_exists".to_owned(), + args: vec![Expr::Path(vec![PathElement::Attribute("pk".to_owned())])], + }; + assert!(!is_attribute_not_exists_key(Some(&expr), &["pk"], &maps)); + } + + #[test] + fn composite_path_does_not_match() { + // attribute_not_exists(a.b) — nested path, not a simple key attribute. + let maps = ExpressionMaps::default(); + let expr = Expr::Function { + name: "attribute_not_exists".to_owned(), + args: vec![Expr::Path(vec![ + PathElement::Attribute("a".to_owned()), + PathElement::Attribute("b".to_owned()), + ])], + }; + assert!(!is_attribute_not_exists_key(Some(&expr), &["a"], &maps)); + } } From 1dd8a57118f4ff55b54a98a582f082b56af27056 Mon Sep 17 00:00:00 2001 From: Joel Shepherd Date: Mon, 14 Sep 2026 22:02:50 +0000 Subject: [PATCH 22/48] fix(storage-cassandra): put_item() and delete_item() use occ_write() and occ_delete_lwt() respectively. version is always non-null i64 (removing null-on-read treated as 0) and null-version code branches have been removed. (PR338 B1/B3/B4) --- .../storage-cassandra/src/data/delete_item.rs | 182 +++++++- crates/storage-cassandra/src/data/mod.rs | 36 -- .../src/data/put_get_item.rs | 438 +++++------------- .../storage-cassandra/src/data/update_item.rs | 40 +- 4 files changed, 298 insertions(+), 398 deletions(-) diff --git a/crates/storage-cassandra/src/data/delete_item.rs b/crates/storage-cassandra/src/data/delete_item.rs index 45f14ef1..1342696b 100644 --- a/crates/storage-cassandra/src/data/delete_item.rs +++ b/crates/storage-cassandra/src/data/delete_item.rs @@ -216,7 +216,7 @@ impl CassandraEngine { // Always read to check prepared_txn_id for transaction conflict detection. let select_query = format!( - "SELECT item_data, prepared_txn_id FROM {data_keyspace}.{ddb_table} WHERE pk = ? AND {sk_col} = ?" + "SELECT item_data, version, prepared_txn_id FROM {data_keyspace}.{ddb_table} WHERE pk = ? AND {sk_col} = ?" ); let old_result = @@ -226,17 +226,18 @@ impl CassandraEngine { .response_body() .map_err(|e| StorageError::Internal(format!("Parse response: {e}")))?; - let (old_item_opt, prepared_txn_id_opt) = if let Some(rows) = body.into_rows() { + let (old_item_opt, version, prepared_txn_id_opt) = if let Some(rows) = body.into_rows() { if let Some(row) = rows.into_iter().next() { let item_data: Option = row.get_by_name("item_data").ok().flatten(); + let version: i64 = row.get_by_name("version").ok().flatten().unwrap_or(0); let prepared_txn_id: Option = row.get_by_name("prepared_txn_id").ok().flatten(); - (item_data.map(json_to_item).transpose()?, prepared_txn_id) + (item_data.map(json_to_item).transpose()?, version, prepared_txn_id) } else { - (None, None) + (None, 0, None) } } else { - (None, None) + (None, 0, None) }; if let Some(expected_item) = expected_claimed_item { @@ -276,13 +277,36 @@ impl CassandraEngine { self.acquire_ttl_mutation_claim(key_info, key, old_item_opt.as_ref()) .await? } else { + // No TTL claim and no transaction owner: use an OCC LWT to fence + // concurrent writers. Without this, a concurrent UpdateItem that + // read the same pre-image can race the plain DELETE. + if old_item_opt.is_some() { + let fence_cql = format!( + "DELETE FROM {data_keyspace}.{ddb_table} WHERE pk = ? AND {sk_col} = ? \ + IF version = ? AND prepared_txn_id = null" + ); + let fence_applied = self + .occ_delete_lwt(&fence_cql, pk_text.as_ref(), Some(&sk), version) + .await?; + if !fence_applied { + return Err(StorageError::ConditionFailed(old_item_opt)); + } + // LWT deleted the row; run secondary effects (indexes, stream) if needed. + if !indexes.is_empty() || stream.is_some() { + self.delete_item_secondary_effects( + &data_keyspace, + key_info, + &indexes, + old_item_opt.as_ref(), + stream, + sys_delay, + ) + .await?; + } + return Ok(if return_old { old_item_opt } else { None }); + } None }; - // Pinned here, immediately after the claim and before any further - // await, so it is strictly newer than the image this request owns - // yet strictly older than anything a later owner commits. A request - // suspended past its claim lifetime therefore loses to that later - // owner instead of overwriting it. let mutation_timestamp = chrono::Utc::now().timestamp_micros(); // Delete the item (with index updates if needed). @@ -429,7 +453,7 @@ impl CassandraEngine { // Always read to check prepared_txn_id for transaction conflict detection let select_query = format!( - "SELECT item_data, prepared_txn_id FROM {data_keyspace}.{ddb_table} WHERE pk = ?" + "SELECT item_data, version, prepared_txn_id FROM {data_keyspace}.{ddb_table} WHERE pk = ?" ); let row = crate::cassandra_util::query_optional( @@ -440,13 +464,14 @@ impl CassandraEngine { ) .await?; - let (old_item_opt, prepared_txn_id_opt) = if let Some(row) = row { + let (old_item_opt, version, prepared_txn_id_opt) = if let Some(row) = row { let item_data: Option = row.get_by_name("item_data").ok().flatten(); + let version: i64 = row.get_by_name("version").ok().flatten().unwrap_or(0); let prepared_txn_id: Option = row.get_by_name("prepared_txn_id").ok().flatten(); - (item_data.map(json_to_item).transpose()?, prepared_txn_id) + (item_data.map(json_to_item).transpose()?, version, prepared_txn_id) } else { - (None, None) + (None, 0, None) }; if let Some(expected_item) = expected_claimed_item { @@ -486,6 +511,32 @@ impl CassandraEngine { self.acquire_ttl_mutation_claim(key_info, key, old_item_opt.as_ref()) .await? } else { + // No TTL claim and no transaction owner: use an OCC LWT to fence + // concurrent writers. + if old_item_opt.is_some() { + let fence_cql = format!( + "DELETE FROM {data_keyspace}.{ddb_table} WHERE pk = ? \ + IF version = ? AND prepared_txn_id = null" + ); + let fence_applied = self + .occ_delete_lwt(&fence_cql, pk_text.as_ref(), None, version) + .await?; + if !fence_applied { + return Err(StorageError::ConditionFailed(old_item_opt)); + } + if !indexes.is_empty() || stream.is_some() { + self.delete_item_secondary_effects( + &data_keyspace, + key_info, + &indexes, + old_item_opt.as_ref(), + stream, + sys_delay, + ) + .await?; + } + return Ok(if return_old { old_item_opt } else { None }); + } None }; // Pinned here, immediately after the claim and before any further @@ -1357,6 +1408,109 @@ impl CassandraEngine { Ok(()) } + + /// Issue a conditional `DELETE ... IF version = ? AND prepared_txn_id = null` LWT. + /// + /// Returns `true` if applied (row deleted), `false` if the fence condition + /// was not met (another writer changed the row between our read and write). + pub(crate) async fn occ_delete_lwt( + &self, + cql: &str, + pk: &str, + sk: Option<&extenddb_storage::util::SortKeyValue>, + version: i64, + ) -> Result { + use cdrs_tokio::types::value::Value; + + let result = if let Some(sk) = sk { + let sk_val = super::index::sk_to_value(sk); + let qv = cdrs_tokio::query::QueryValues::SimpleValues(vec![ + Value::from(pk), + sk_val, + version.into(), + ]); + crate::cassandra_util::query_lwt(&self.session, cql, qv).await? + } else { + let qv = cdrs_tokio::query::QueryValues::SimpleValues(vec![ + Value::from(pk), + version.into(), + ]); + crate::cassandra_util::query_lwt(&self.session, cql, qv).await? + }; + ttl_lwt_applied(&result) + } + + /// Run the secondary-effects LOGGED BATCH (index removals + stream record) + /// after an OCC LWT has already deleted the base row. + /// + /// Only called when `!indexes.is_empty() || stream.is_some()`. + async fn delete_item_secondary_effects( + &self, + data_keyspace: &str, + key_info: &extenddb_core::types::TableKeyInfo, + indexes: &[super::index::IndexMeta], + old_item: Option<&extenddb_core::types::Item>, + stream: Option<&extenddb_storage::StreamCapture>, + sys_delay: u64, + ) -> Result<(), StorageError> { + use cdrs_tokio::consistency::Consistency; + use cdrs_tokio::query::BatchQueryBuilder; + + let mut batch = BatchQueryBuilder::new().with_consistency(Consistency::LocalQuorum); + + if !indexes.is_empty() { + super::index::sync_indexes( + &mut batch, + data_keyspace, + &key_info.key_schema, + &key_info.attribute_definitions, + indexes, + old_item, + None, + sys_delay, + )?; + super::index::enqueue_async_indexes( + &self.session, + &mut batch, + data_keyspace, + key_info, + indexes, + old_item, + None, + sys_delay, + ) + .await?; + } + + if let Some(cap) = stream { + if let Some(stmt) = crate::stream_util::stream_record_statement( + data_keyspace, + &key_info.table_id, + key_info, + old_item, + None, + cap, + &self.hlc, + self.stream_retention_seconds, + ) { + batch = + batch.add_query(stmt, cdrs_tokio::query::QueryValues::SimpleValues(vec![])); + } + } + + let built = batch + .build() + .map_err(|e| StorageError::Internal(e.to_string()))?; + self.session + .batch(built) + .await + .map_err(|e| StorageError::Internal(format!("delete secondary effects: {e}")))?; + + if !indexes.is_empty() { + self.gsi_queue.notify_workers(); + } + Ok(()) + } } fn ttl_lwt_applied(result: &cdrs_tokio::frame::Envelope) -> Result { diff --git a/crates/storage-cassandra/src/data/mod.rs b/crates/storage-cassandra/src/data/mod.rs index f421996e..946dbd04 100644 --- a/crates/storage-cassandra/src/data/mod.rs +++ b/crates/storage-cassandra/src/data/mod.rs @@ -112,42 +112,6 @@ pub(crate) async fn query_with_pk_sk( } .map_err(|e| StorageError::Internal(format!("Query failed: {e}"))) } -/// Execute a query with pk, sort key, and `item_data`, returning the result. -/// -/// Helper for INSERT/UPDATE operations. -pub(crate) async fn query_with_pk_sk_item( - session: &CassandraSession, - query: &str, - pk: &str, - sk: &SortKeyValue, - item_text: &str, -) -> Result { - match sk { - SortKeyValue::S(s) => { - session - .query_with_values(query, cdrs_tokio::query_values!(pk, s.as_str(), item_text)) - .await - } - SortKeyValue::N(n) => { - session - .query_with_values( - query, - cdrs_tokio::query_values!(pk, decimal_to_value(n), item_text), - ) - .await - } - SortKeyValue::B(b) => { - session - .query_with_values( - query, - cdrs_tokio::query_values!(pk, Blob::new(b.clone()), item_text), - ) - .await - } - } - .map_err(|e| StorageError::Internal(format!("Query failed: {e}"))) -} - /// Execute a query with `(pk, sk, txn_id_bytes)` bound parameters. /// /// Used for: rollback DELETE, commit DELETE. diff --git a/crates/storage-cassandra/src/data/put_get_item.rs b/crates/storage-cassandra/src/data/put_get_item.rs index 6eb031f5..80f8c3c7 100644 --- a/crates/storage-cassandra/src/data/put_get_item.rs +++ b/crates/storage-cassandra/src/data/put_get_item.rs @@ -13,7 +13,7 @@ use extenddb_storage::error::StorageError; use extenddb_storage::util::{composite_pk_to_text, parse_sk, pk_to_text, sk_column, sk_info}; use super::ddl::data_table_name; -use super::{json_to_item, query_with_pk_sk, query_with_pk_sk_item}; +use super::{json_to_item, query_with_pk_sk}; use crate::CassandraEngine; use crate::stream_util::stream_record_statement; @@ -145,9 +145,9 @@ impl CassandraEngine { let sk = parse_sk(sk_value, sk_type)?; let sk_col = sk_column(sk_type); - // Read old item including prepared_txn_id for transaction conflict detection + // Read old item including version and prepared_txn_id for OCC fence. let select_query = format!( - "SELECT item_data, prepared_txn_id FROM {data_keyspace}.{ddb_table} WHERE pk = ? AND {sk_col} = ?" + "SELECT item_data, version, prepared_txn_id FROM {data_keyspace}.{ddb_table} WHERE pk = ? AND {sk_col} = ?" ); let old_result = @@ -157,20 +157,22 @@ impl CassandraEngine { .response_body() .map_err(|e| StorageError::Internal(format!("Parse response: {e}")))?; - let (old_item_opt, has_prepared_txn) = if let Some(rows) = body.into_rows() { + let (old_item_opt, version, has_prepared_txn) = if let Some(rows) = body.into_rows() { if let Some(row) = rows.into_iter().next() { let item_data: Option = row.get_by_name("item_data").ok().flatten(); + let version: i64 = row.get_by_name("version").ok().flatten().unwrap_or(0); let prepared_txn_id: Option = row.get_by_name("prepared_txn_id").ok().flatten(); ( item_data.map(json_to_item).transpose()?, + version, prepared_txn_id.is_some(), ) } else { - (None, false) + (None, 0, false) } } else { - (None, false) + (None, 0, false) }; // Reject if item is part of an in-flight transaction @@ -194,198 +196,88 @@ impl CassandraEngine { Err(e) => return Err(e), } - let stream_stmt = stream.and_then(|cap| { - stream_record_statement( + let item_existed = old_item_opt.is_some(); + + let ttl_claim = if ttl_config.is_some() { + match self + .acquire_ttl_mutation_claim(key_info, &item, old_item_opt.as_ref()) + .await + { + Ok(claim) => claim, + Err(StorageError::TransactionConflict(_)) if key_not_exists_condition => { + return Err(StorageError::ConditionFailed(old_item_opt)); + } + Err(error) => return Err(error), + } + } else { + None + }; + let mutation_timestamp = ttl_claim.map(|_| chrono::Utc::now().timestamp_micros()); + + let write_result = self + .occ_write( &data_keyspace, - &key_info.table_id, - key_info, - old_item_opt.as_ref(), - Some(&item), - cap, - &self.hlc, - self.stream_retention_seconds, - ) - }); - - if indexes.is_empty() && stream_stmt.is_none() && ttl_config.is_none() { - // Fast path: no batch needed. - let insert_query = format!( - "INSERT INTO {data_keyspace}.{ddb_table} \ - (pk, {sk_col}, item_data) \ - VALUES (?, ?, ?)" - ); - query_with_pk_sk_item( - &self.session, - &insert_query, + &ddb_table, pk_text.as_ref(), - &sk, + Some(&sk), + Some(sk_col), &item_text, + version, + item_existed, + &indexes, + key_info, + old_item_opt.as_ref(), + &item, + sys_delay, + stream, + ttl_config.as_ref().map(|c| c.attribute.as_str()), + ttl_config.as_ref().map(|c| c.generation), + ttl_claim, + mutation_timestamp, ) - .await?; + .await; + if write_result.is_err() { + self.release_ttl_mutation_claim(key_info, &item, ttl_claim) + .await; } else { - // LOGGED BATCH: item insert + optional index updates + optional stream record. - // On a claimed row the base write also clears the claim columns. - // The batch timestamp is pinned after claim acquisition, so these - // nulls lose to any newer owner's Paxos cells exactly as the item - // cells do — no separate release round trip is needed on success. - let insert_cql = if ttl_config.is_some() { - format!( - "INSERT INTO {data_keyspace}.{ddb_table} \ - (pk, {sk_col}, item_data, prepared_txn_id, prepared_txn_timestamp) \ - VALUES (?, ?, ?, null, null)" - ) - } else { - format!( - "INSERT INTO {data_keyspace}.{ddb_table} \ - (pk, {sk_col}, item_data) \ - VALUES (?, ?, ?)" - ) - }; - let insert_qv = cdrs_tokio::query::QueryValues::SimpleValues(vec![ - cdrs_tokio::types::value::Value::from(pk_text.as_str()), - super::index::sk_to_value(&sk), - item_text.as_str().into(), - ]); - let mut batch = BatchQueryBuilder::new() - .with_consistency(Consistency::LocalQuorum) - .add_query(insert_cql, insert_qv); - - if !indexes.is_empty() { - super::index::sync_indexes( - &mut batch, - &data_keyspace, - &key_info.key_schema, - &key_info.attribute_definitions, - &indexes, - old_item_opt.as_ref(), - Some(&item), - sys_delay, - )?; - } - - let async_enqueued = if indexes.is_empty() { - 0 - } else { - super::index::enqueue_async_indexes( - &self.session, - &mut batch, - &data_keyspace, - key_info, - &indexes, - old_item_opt.as_ref(), - Some(&item), - sys_delay, - ) - .await? - }; - - if let Some(config) = ttl_config.as_ref() { - super::ttl::add_ttl_reconciliation_mutation( - &mut batch, - &data_keyspace, - key_info, - &config.attribute, - &item, - )?; - super::ttl::add_ttl_queue_mutations( - &mut batch, - &data_keyspace, - key_info, - &config.attribute, - config.generation, - old_item_opt.as_ref(), - Some(&item), - )?; - } - - if let Some(stmt) = stream_stmt { - batch = - batch.add_query(stmt, cdrs_tokio::query::QueryValues::SimpleValues(vec![])); - } - - // Acquire only after every fallible side-effect statement has been - // prepared, then release this exact claim on every remaining path. - let ttl_claim = if ttl_config.is_some() { - match self - .acquire_ttl_mutation_claim(key_info, &item, old_item_opt.as_ref()) - .await - { - Ok(claim) => claim, - // An absent-row claim that cannot be taken means the row - // now exists, which is exactly what this condition - // forbids. Report the condition failure rather than - // retrying a write that can never apply. - Err(StorageError::TransactionConflict(_)) if key_not_exists_condition => { - return Err(StorageError::ConditionFailed(old_item_opt)); - } - Err(error) => return Err(error), - } - } else { - None - }; - if let Some(timestamp) = ttl_claim.map(|_| chrono::Utc::now().timestamp_micros()) { - batch = batch.with_timestamp(timestamp); - } - - let built = match batch.build() { - Ok(built) => built, - Err(error) => { - self.release_ttl_mutation_claim(key_info, &item, ttl_claim) - .await; - return Err(StorageError::Internal(error.to_string())); - } - }; - if let Err(error) = self.session.batch(built).await { - self.release_ttl_mutation_claim(key_info, &item, ttl_claim) - .await; - return Err(StorageError::Internal(format!("Batch execution: {error}"))); - } - // The batch itself released the claim at its pinned timestamp; - // the detached exact release covers a trailing local clock - // without costing the request a Paxos round. self.spawn_release_ttl_claim(key_info, &item, ttl_claim); + } + let applied = write_result?; - if async_enqueued > 0 { - self.gsi_queue.notify_workers(); - } + if !applied { + // Lost OCC race — another writer changed the item between our read and write. + return Err(StorageError::ConditionFailed(old_item_opt)); } Ok(if return_old { old_item_opt } else { None }) } else { // PK-only table (no sort key) - // Read old item including prepared_txn_id for transaction conflict detection + // Read old item including version and prepared_txn_id for OCC fence. let select_query = format!( - "SELECT item_data, prepared_txn_id FROM {data_keyspace}.{ddb_table} WHERE pk = ?" + "SELECT item_data, version, prepared_txn_id FROM {data_keyspace}.{ddb_table} WHERE pk = ?" ); - let old_result = self - .session - .query_with_values( - &select_query, - cdrs_tokio::query_values!(pk_text.as_ref() as &str), - ) - .await - .map_err(|e| StorageError::Internal(format!("Select for put_item: {e}")))?; - - let body = old_result - .response_body() - .map_err(|e| StorageError::Internal(format!("Parse response: {e}")))?; + let row = crate::cassandra_util::query_optional( + &self.session, + &select_query, + cdrs_tokio::query_values!(pk_text.as_ref() as &str), + "put_item read", + ) + .await?; - let (old_item_opt, has_prepared_txn) = if let Some(rows) = body.into_rows() { - if let Some(row) = rows.into_iter().next() { - let item_data: Option = row.get_by_name("item_data").ok().flatten(); - let prepared_txn_id: Option = - row.get_by_name("prepared_txn_id").ok().flatten(); - ( - item_data.map(json_to_item).transpose()?, - prepared_txn_id.is_some(), - ) - } else { - (None, false) - } + let (old_item_opt, version, has_prepared_txn) = if let Some(row) = row { + let item_data: Option = row.get_by_name("item_data").ok().flatten(); + let version: i64 = row.get_by_name("version").ok().flatten().unwrap_or(0); + let prepared_txn_id: Option = + row.get_by_name("prepared_txn_id").ok().flatten(); + ( + item_data.map(json_to_item).transpose()?, + version, + prepared_txn_id.is_some(), + ) } else { - (None, false) + (None, 0, false) }; // Reject if item is part of an in-flight transaction @@ -409,156 +301,56 @@ impl CassandraEngine { Err(e) => return Err(e), } - let stream_stmt = stream.and_then(|cap| { - stream_record_statement( + let item_existed = old_item_opt.is_some(); + + let ttl_claim = if ttl_config.is_some() { + match self + .acquire_ttl_mutation_claim(key_info, &item, old_item_opt.as_ref()) + .await + { + Ok(claim) => claim, + Err(StorageError::TransactionConflict(_)) if key_not_exists_condition => { + return Err(StorageError::ConditionFailed(old_item_opt)); + } + Err(error) => return Err(error), + } + } else { + None + }; + let mutation_timestamp = ttl_claim.map(|_| chrono::Utc::now().timestamp_micros()); + + let write_result = self + .occ_write( &data_keyspace, - &key_info.table_id, + &ddb_table, + pk_text.as_ref(), + None, + None, + &item_text, + version, + item_existed, + &indexes, key_info, old_item_opt.as_ref(), - Some(&item), - cap, - &self.hlc, - self.stream_retention_seconds, + &item, + sys_delay, + stream, + ttl_config.as_ref().map(|c| c.attribute.as_str()), + ttl_config.as_ref().map(|c| c.generation), + ttl_claim, + mutation_timestamp, ) - }); - - if indexes.is_empty() && stream_stmt.is_none() && ttl_config.is_none() { - // Fast path: no batch needed. - let insert_query = format!( - "INSERT INTO {data_keyspace}.{ddb_table} \ - (pk, item_data) \ - VALUES (?, ?)" - ); - self.session - .query_with_values( - &insert_query, - cdrs_tokio::query_values!(pk_text.as_ref() as &str, item_text.as_str()), - ) - .await - .map_err(|e| StorageError::Internal(format!("Insert item: {e}")))?; + .await; + if write_result.is_err() { + self.release_ttl_mutation_claim(key_info, &item, ttl_claim) + .await; } else { - // LOGGED BATCH: item insert + optional index updates + optional stream record. - // On a claimed row the base write also clears the claim columns - // (see the sort-key path for the timestamp reasoning). - let insert_cql = if ttl_config.is_some() { - format!( - "INSERT INTO {data_keyspace}.{ddb_table} \ - (pk, item_data, prepared_txn_id, prepared_txn_timestamp) \ - VALUES (?, ?, null, null)" - ) - } else { - format!( - "INSERT INTO {data_keyspace}.{ddb_table} \ - (pk, item_data) \ - VALUES (?, ?)" - ) - }; - let insert_qv = cdrs_tokio::query::QueryValues::SimpleValues(vec![ - cdrs_tokio::types::value::Value::from(pk_text.as_str()), - item_text.as_str().into(), - ]); - let mut batch = BatchQueryBuilder::new() - .with_consistency(Consistency::LocalQuorum) - .add_query(insert_cql, insert_qv); - - if !indexes.is_empty() { - super::index::sync_indexes( - &mut batch, - &data_keyspace, - &key_info.key_schema, - &key_info.attribute_definitions, - &indexes, - old_item_opt.as_ref(), - Some(&item), - sys_delay, - )?; - } - - let async_enqueued = if indexes.is_empty() { - 0 - } else { - super::index::enqueue_async_indexes( - &self.session, - &mut batch, - &data_keyspace, - key_info, - &indexes, - old_item_opt.as_ref(), - Some(&item), - sys_delay, - ) - .await? - }; - - if let Some(config) = ttl_config.as_ref() { - super::ttl::add_ttl_reconciliation_mutation( - &mut batch, - &data_keyspace, - key_info, - &config.attribute, - &item, - )?; - super::ttl::add_ttl_queue_mutations( - &mut batch, - &data_keyspace, - key_info, - &config.attribute, - config.generation, - old_item_opt.as_ref(), - Some(&item), - )?; - } - - if let Some(stmt) = stream_stmt { - batch = - batch.add_query(stmt, cdrs_tokio::query::QueryValues::SimpleValues(vec![])); - } - - // Acquire only after every fallible side-effect statement has been - // prepared, then release this exact claim on every remaining path. - let ttl_claim = if ttl_config.is_some() { - match self - .acquire_ttl_mutation_claim(key_info, &item, old_item_opt.as_ref()) - .await - { - Ok(claim) => claim, - // An absent-row claim that cannot be taken means the row - // now exists, which is exactly what this condition - // forbids. Report the condition failure rather than - // retrying a write that can never apply. - Err(StorageError::TransactionConflict(_)) if key_not_exists_condition => { - return Err(StorageError::ConditionFailed(old_item_opt)); - } - Err(error) => return Err(error), - } - } else { - None - }; - if let Some(timestamp) = ttl_claim.map(|_| chrono::Utc::now().timestamp_micros()) { - batch = batch.with_timestamp(timestamp); - } - - let built = match batch.build() { - Ok(built) => built, - Err(error) => { - self.release_ttl_mutation_claim(key_info, &item, ttl_claim) - .await; - return Err(StorageError::Internal(error.to_string())); - } - }; - if let Err(error) = self.session.batch(built).await { - self.release_ttl_mutation_claim(key_info, &item, ttl_claim) - .await; - return Err(StorageError::Internal(format!("Batch execution: {error}"))); - } - // The batch itself released the claim at its pinned timestamp; - // the detached exact release covers a trailing local clock - // without costing the request a Paxos round. self.spawn_release_ttl_claim(key_info, &item, ttl_claim); + } + let applied = write_result?; - if async_enqueued > 0 { - self.gsi_queue.notify_workers(); - } + if !applied { + return Err(StorageError::ConditionFailed(old_item_opt)); } Ok(if return_old { old_item_opt } else { None }) diff --git a/crates/storage-cassandra/src/data/update_item.rs b/crates/storage-cassandra/src/data/update_item.rs index 91d939f1..fef3462c 100644 --- a/crates/storage-cassandra/src/data/update_item.rs +++ b/crates/storage-cassandra/src/data/update_item.rs @@ -100,8 +100,7 @@ impl CassandraEngine { ) .await; let (old_json, version) = match read { - Ok(read) => read, - // Another owner holds the row. On a TTL-enabled table that is + Ok(read) => read, // Another owner holds the row. On a TTL-enabled table that is // transient, so treat it like a lost OCC race and re-read. Err(StorageError::TransactionConflict(message)) => { if attempt == OCC_MAX_RETRIES { @@ -239,8 +238,8 @@ impl CassandraEngine { /// Read `item_data`, `version`, and `prepared_txn_id` for OCC. /// - /// Returns `(existing_item, version)`. `version` is `None` for rows that - /// pre-date the OCC column (treated as version 0 — `IF version = null`). + /// Returns `(existing_item, version)`. A null `version` column (rows written + /// before the OCC protocol was enforced) is treated as `0`. /// /// Returns `TransactionConflict` if `prepared_txn_id` is set. async fn occ_read( @@ -251,7 +250,7 @@ impl CassandraEngine { sk: Option<&extenddb_storage::util::SortKeyValue>, sk_col: Option<&'static str>, ttl_enabled: bool, - ) -> Result<(Option, Option), StorageError> { + ) -> Result<(Option, i64), StorageError> { use cdrs_tokio::types::IntoRustByName as _; let row_opt = if let (Some(sk), Some(sk_col)) = (sk, sk_col) { @@ -280,7 +279,7 @@ impl CassandraEngine { }; let Some(row) = row_opt else { - return Ok((None, None)); + return Ok((None, 0)); }; let prepared_txn_id: Option = row.get_by_name("prepared_txn_id").ok().flatten(); @@ -290,7 +289,7 @@ impl CassandraEngine { // Static partition metadata can produce a physical row with no logical item. let item_data: Option = row.get_by_name("item_data").ok().flatten(); - let version: Option = row.get_by_name("version").ok().flatten(); + let version: i64 = row.get_by_name("version").ok().flatten().unwrap_or(0); Ok((item_data.map(json_to_item).transpose()?, version)) } @@ -300,7 +299,7 @@ impl CassandraEngine { /// Uses `IF version = ? AND prepared_txn_id = null` for existing items, or /// `IF item_data = null AND prepared_txn_id = null` for logical creation. #[allow(clippy::too_many_arguments)] - async fn occ_write( + pub(crate) async fn occ_write( &self, data_keyspace: &str, ddb_table: &str, @@ -308,7 +307,7 @@ impl CassandraEngine { sk: Option<&extenddb_storage::util::SortKeyValue>, sk_col: Option<&'static str>, item_json_str: &str, - version: Option, + version: i64, item_existed: bool, indexes: &[super::index::IndexMeta], key_info: &TableKeyInfo, @@ -334,44 +333,35 @@ impl CassandraEngine { ) }); - let next_version = version.unwrap_or(0) + 1; + let next_version = version + 1; // Build the LWT statement used when no TTL claim owns the existing row. let (lwt_cql, lwt_qv) = if item_existed { - let version_cond = if version.is_some() { - "version = ?".to_owned() - } else { - "version = null".to_owned() - }; if let (Some(sk), Some(sk_col)) = (sk, sk_col) { let cql = format!( "UPDATE {data_keyspace}.{ddb_table} SET item_data = ?, version = ? \ WHERE pk = ? AND {sk_col} = ? \ - IF {version_cond} AND prepared_txn_id = null" + IF version = ? AND prepared_txn_id = null" ); - let mut vals: Vec = vec![ + let vals = vec![ item_json_str.into(), next_version.into(), cdrs_tokio::types::value::Value::from(pk_text), super::index::sk_to_value(sk), + version.into(), ]; - if let Some(version) = version { - vals.push(version.into()); - } (cql, cdrs_tokio::query::QueryValues::SimpleValues(vals)) } else { let cql = format!( "UPDATE {data_keyspace}.{ddb_table} SET item_data = ?, version = ? \ - WHERE pk = ? IF {version_cond} AND prepared_txn_id = null" + WHERE pk = ? IF version = ? AND prepared_txn_id = null" ); - let mut vals: Vec = vec![ + let vals = vec![ item_json_str.into(), next_version.into(), cdrs_tokio::types::value::Value::from(pk_text), + version.into(), ]; - if let Some(version) = version { - vals.push(version.into()); - } (cql, cdrs_tokio::query::QueryValues::SimpleValues(vals)) } } else if let (Some(sk), Some(sk_col)) = (sk, sk_col) { From 49f9532edfbd82f7954c2bfd33daabd1ffb919c6 Mon Sep 17 00:00:00 2001 From: Joel Shepherd Date: Mon, 14 Sep 2026 23:21:56 +0000 Subject: [PATCH 23/48] fix(storage-cassandra): consolidate methods for checking 'applied' return flag on LWT statements, retry unconditional put on LWT conflict --- .../storage-cassandra/src/cassandra_util.rs | 26 +++++++++++++++++++ .../storage-cassandra/src/data/delete_item.rs | 24 ++++------------- .../src/data/put_get_item.rs | 13 ++++++++++ .../storage-cassandra/src/data/update_item.rs | 22 +--------------- 4 files changed, 45 insertions(+), 40 deletions(-) diff --git a/crates/storage-cassandra/src/cassandra_util.rs b/crates/storage-cassandra/src/cassandra_util.rs index 1e1d10c5..a4ebf5c0 100644 --- a/crates/storage-cassandra/src/cassandra_util.rs +++ b/crates/storage-cassandra/src/cassandra_util.rs @@ -317,6 +317,32 @@ pub fn now_millis() -> i64 { .unwrap_or_default() .as_millis() as i64 } +/// Parse the `[applied]` boolean from an already-executed LWT response envelope. +/// +/// Returns `Ok(true)` if the condition was met and the write applied, +/// `Ok(false)` if not applied (lost race). A missing result row is treated as +/// applied (defensive: Cassandra always returns a row for LWT statements). +/// +/// # Errors +/// Returns an error only if the response body cannot be parsed. +pub fn lwt_applied( + result: &cdrs_tokio::frame::Envelope, +) -> Result { + use cdrs_tokio::types::IntoRustByName as _; + let body = result.response_body().map_err(|e| { + extenddb_storage::error::StorageError::Internal(format!("lwt_applied response_body: {e}")) + })?; + let Some(rows) = body.into_rows() else { + return Ok(true); + }; + let Some(row) = rows.into_iter().next() else { + return Ok(true); + }; + row.get_r_by_name("[applied]").map_err(|e| { + extenddb_storage::error::StorageError::Internal(format!("lwt_applied parse [applied]: {e}")) + }) +} + /// /// Parses the `[applied]` column from the Cassandra LWT response. /// Use for `INSERT ... IF NOT EXISTS` and `UPDATE ... IF ...` statements. diff --git a/crates/storage-cassandra/src/data/delete_item.rs b/crates/storage-cassandra/src/data/delete_item.rs index 1342696b..4a79d8b9 100644 --- a/crates/storage-cassandra/src/data/delete_item.rs +++ b/crates/storage-cassandra/src/data/delete_item.rs @@ -813,7 +813,7 @@ impl CassandraEngine { ) .await? }; - ttl_lwt_applied(&result) + crate::cassandra_util::lwt_applied(&result) } /// Whether the base row's exact owner is `work_id`, read at `LOCAL_QUORUM`. @@ -1096,7 +1096,7 @@ impl CassandraEngine { ) .await? }; - ttl_lwt_applied(&result) + crate::cassandra_util::lwt_applied(&result) } async fn claim_absent_ttl_item( @@ -1155,7 +1155,7 @@ impl CassandraEngine { .await } .map_err(|error| StorageError::Internal(format!("Claim absent TTL item: {error}")))?; - ttl_lwt_applied(&result) + crate::cassandra_util::lwt_applied(&result) } async fn claim_ttl_item( @@ -1218,7 +1218,7 @@ impl CassandraEngine { .await } .map_err(|error| StorageError::Internal(format!("Claim TTL item: {error}")))?; - ttl_lwt_applied(&result) + crate::cassandra_util::lwt_applied(&result) } /// Fire the exact conditional release off the request's latency path. @@ -1437,7 +1437,7 @@ impl CassandraEngine { ]); crate::cassandra_util::query_lwt(&self.session, cql, qv).await? }; - ttl_lwt_applied(&result) + crate::cassandra_util::lwt_applied(&result) } /// Run the secondary-effects LOGGED BATCH (index removals + stream record) @@ -1513,17 +1513,3 @@ impl CassandraEngine { } } -fn ttl_lwt_applied(result: &cdrs_tokio::frame::Envelope) -> Result { - let rows = result - .response_body() - .map_err(|error| StorageError::Internal(format!("Parse TTL claim: {error}")))? - .into_rows() - .unwrap_or_default(); - let Some(row) = rows.first() else { - return Err(StorageError::Internal( - "TTL claim returned no LWT result".to_owned(), - )); - }; - row.get_r_by_name("[applied]") - .map_err(|error| StorageError::Internal(format!("Parse TTL claim result: {error}"))) -} diff --git a/crates/storage-cassandra/src/data/put_get_item.rs b/crates/storage-cassandra/src/data/put_get_item.rs index 80f8c3c7..a7e1fee4 100644 --- a/crates/storage-cassandra/src/data/put_get_item.rs +++ b/crates/storage-cassandra/src/data/put_get_item.rs @@ -46,6 +46,19 @@ impl CassandraEngine { return Err(StorageError::TransactionConflict(message)); } Err(StorageError::TransactionConflict(_)) => ttl_claim_backoff(attempt).await, + // Unconditional put lost the OCC race — retry against the new version. + // After exhausting retries, surface as TransactionConflict (same as + // DynamoDB's behaviour for persistent write contention). + Err(StorageError::ConditionFailed(_)) + if condition.is_none() && attempt == TTL_CLAIM_MAX_RETRIES => + { + return Err(StorageError::TransactionConflict( + "Unconditional put lost OCC race after max retries".to_owned(), + )); + } + Err(StorageError::ConditionFailed(_)) if condition.is_none() => { + ttl_claim_backoff(attempt).await; + } other => return other, } } diff --git a/crates/storage-cassandra/src/data/update_item.rs b/crates/storage-cassandra/src/data/update_item.rs index fef3462c..1cfc072b 100644 --- a/crates/storage-cassandra/src/data/update_item.rs +++ b/crates/storage-cassandra/src/data/update_item.rs @@ -395,7 +395,7 @@ impl CassandraEngine { .query_with_values(&lwt_cql, lwt_qv) .await .map_err(|e| StorageError::Internal(format!("occ_write lwt: {e}")))?; - if !occ_applied(&result)? { + if !crate::cassandra_util::lwt_applied(&result)? { return Ok(false); } if indexes.is_empty() && stream_stmt.is_none() { @@ -512,23 +512,3 @@ impl CassandraEngine { } } -/// Extract `[applied]` from an LWT response. Returns `Ok(true)` if applied, -/// `Ok(false)` if not applied (lost race), `Err` only on parse failure. -fn occ_applied(result: &cdrs_tokio::frame::Envelope) -> Result { - use cdrs_tokio::types::IntoRustByName as _; - let body = result - .response_body() - .map_err(|e| StorageError::Internal(format!("occ_applied response_body: {e}")))?; - let Some(rows) = body.into_rows() else { - // No rows in response means the statement was not a conditional write - // (shouldn't happen here) — treat as applied. - return Ok(true); - }; - let Some(row) = rows.into_iter().next() else { - return Ok(true); - }; - let applied: bool = row - .get_r_by_name("[applied]") - .map_err(|e| StorageError::Internal(format!("occ_applied parse [applied]: {e}")))?; - Ok(applied) -} From 5b971e7e5d138fa999b22789c603b28719875cfe Mon Sep 17 00:00:00 2001 From: Joel Shepherd Date: Tue, 15 Sep 2026 00:02:32 +0000 Subject: [PATCH 24/48] fix(storage-cassandra): delete idempotency token if transaction ledger write fails (PR338, B5) --- .../src/data/transactions.rs | 92 ++++++++++++------- 1 file changed, 60 insertions(+), 32 deletions(-) diff --git a/crates/storage-cassandra/src/data/transactions.rs b/crates/storage-cassandra/src/data/transactions.rs index 389c139a..1c59b942 100644 --- a/crates/storage-cassandra/src/data/transactions.rs +++ b/crates/storage-cassandra/src/data/transactions.rs @@ -307,6 +307,15 @@ impl CassandraEngine { // Do not replace the original cancellation error. tracing::error!("transact_write: rollback failed for txn {txn_id}: {e}"); } + // B5: release the token so a retry with the same token can proceed. + if let Some((idempotency_account, token, _)) = idempotency { + self.delete_idempotency_token( + &self.catalog_keyspace(), + idempotency_account, + token, + ) + .await; + } Err(StorageError::TransactionCanceled(reasons)) } } @@ -827,7 +836,10 @@ impl CassandraEngine { .collect() } - /// Check and reserve an account-scoped idempotency token, scoped to `account_id`. + /// Check and reserve an account-scoped idempotency token. + /// + /// Returns `Ok(())` when the token is freshly reserved. + /// Returns `Err(IdempotentReplay)` / `Err(IdempotentMismatch)` when already present. async fn check_idempotency_token( &self, keyspace: &str, @@ -836,18 +848,19 @@ impl CassandraEngine { fingerprint: &str, ) -> Result<(), StorageError> { let select_query = format!( - "SELECT fingerprint FROM {keyspace}.idempotency_tokens_by_account WHERE account_id = ? AND \"token\" = ?" + "SELECT fingerprint FROM {keyspace}.idempotency_tokens_by_account \ + WHERE account_id = ? AND \"token\" = ?" ); - let row = query_optional::( + // Fast path: token already present (common on retry). + if let Some(row) = query_optional::( &self.session, &select_query, cdrs_tokio::query_values!(account_id, token), "check_idempotency_token", ) - .await?; - - if let Some(row) = row { + .await? + { let stored_fp: String = get_column(&row, "fingerprint", "check_idempotency_token")?; return if stored_fp == fingerprint { Err(StorageError::IdempotentReplay) @@ -856,36 +869,22 @@ impl CassandraEngine { }; } - // Insert with LWT to handle concurrent requests racing on the same token. + // Reserve with LWT to handle concurrent requests racing on the same token. let insert_query = format!( - "INSERT INTO {keyspace}.idempotency_tokens_by_account (account_id, \"token\", fingerprint, created_at) \ - VALUES (?, ?, ?, ?) IF NOT EXISTS" + "INSERT INTO {keyspace}.idempotency_tokens_by_account \ + (account_id, \"token\", fingerprint, created_at) VALUES (?, ?, ?, ?) IF NOT EXISTS" ); let now = crate::cassandra_util::now_millis(); + let result = crate::cassandra_util::apply_lwt( + &self.session, + &insert_query, + cdrs_tokio::query_values!(account_id, token, fingerprint, now), + "check_idempotency_token insert", + ) + .await?; - let result = self - .session - .query_with_values( - &insert_query, - cdrs_tokio::query_values!(account_id, token, fingerprint, now), - ) - .await - .map_err(|e| { - tracing::error!("check_idempotency_token insert: {e}"); - StorageError::Internal("Database error".to_owned()) - })?; - - // If the LWT was not applied, a concurrent request won the race. - // Read back what was stored and return the appropriate error. - let applied: bool = result - .response_body() - .ok() - .and_then(cdrs_tokio::frame::message_response::ResponseBody::into_rows) - .and_then(|mut rows| rows.drain(..).next()) - .and_then(|row| row.get_r_by_name("[applied]").ok()) - .unwrap_or(true); - - if !applied { + if !result { + // Lost the LWT race — read back to return the right error. let row = query_optional::( &self.session, &select_query, @@ -907,6 +906,35 @@ impl CassandraEngine { Ok(()) } + /// Delete a previously reserved idempotency token. + /// + /// Called on every cancellation path to prevent token poisoning (B5): a + /// token reserved before the ledger is committed must be released if the + /// transaction is cancelled, so a retry with the same token can proceed. + async fn delete_idempotency_token( + &self, + keyspace: &str, + account_id: &str, + token: &str, + ) { + let delete_query = format!( + "DELETE FROM {keyspace}.idempotency_tokens_by_account \ + WHERE account_id = ? AND \"token\" = ?" + ); + if let Err(e) = crate::cassandra_util::execute::( + &self.session, + &delete_query, + cdrs_tokio::query_values!(account_id, token), + "delete_idempotency_token", + ) + .await + { + // Non-fatal: the token will eventually expire via Cassandra TTL. + // Log so it is visible in ops, but do not replace the original error. + tracing::warn!("delete_idempotency_token failed (token will expire): {e}"); + } + } + /// Fetch an item for transaction (reads `item_data` and `prepared_txn_id`). /// /// If `skip_txn_id` is `Some(id)`, rows prepared by that transaction are From f15f43f3bb4ca4e8ff19e01ab0384d1798c92963 Mon Sep 17 00:00:00 2001 From: Joel Shepherd Date: Tue, 15 Sep 2026 00:08:04 +0000 Subject: [PATCH 25/48] test: integration tests for concurrency control on mutating item operations, and idempotency tokens (PR338, B1-5) --- tests/test_conditional_atomicity.py | 320 ++++++++++++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 tests/test_conditional_atomicity.py diff --git a/tests/test_conditional_atomicity.py b/tests/test_conditional_atomicity.py new file mode 100644 index 00000000..bcf01c44 --- /dev/null +++ b/tests/test_conditional_atomicity.py @@ -0,0 +1,320 @@ +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 + +"""Regression tests for conditional-write atomicity fixes (PR #338 B1/B3/B4). + +B1 — attribute_not_exists condition on a non-key attribute must be atomic: + exactly one of N concurrent PutItem calls with the same key and + condition_expression="attribute_not_exists(guard)" must succeed. + +B3 — conditional DeleteItem must not race with a concurrent UpdateItem: + no serial ordering permits both succeeding while leaving the item absent. + +B4 — unconditional PutItem must not be silently overwritten by a concurrent + UpdateItem that started before the put landed. + +REQ-TEST-B1, REQ-TEST-B3, REQ-TEST-B4 +""" + +from __future__ import annotations + +import threading +import uuid + +import pytest +from botocore.exceptions import ClientError + +from conftest import scoped_table, wait_for_active + + +def _make_client(endpoint_url): + import os + import boto3 + kwargs = { + "service_name": "dynamodb", + "region_name": os.environ.get("AWS_DEFAULT_REGION", "us-east-1"), + } + if endpoint_url: + kwargs["endpoint_url"] = endpoint_url + if endpoint_url.startswith("https://"): + kwargs["verify"] = False + return boto3.client(**kwargs) + + +# --------------------------------------------------------------------------- +# B1 — attribute_not_exists on a non-key attribute is atomic +# --------------------------------------------------------------------------- + +class TestB1AttributeNotExistsAtomicity: + """Exactly one concurrent PutItem with attribute_not_exists(guard) must win.""" + + @pytest.fixture(scope="class") + def table(self, dynamodb_client): + with scoped_table(dynamodb_client) as name: + yield name + + def test_exactly_one_winner(self, table, endpoint_url): + pk = f"b1-{uuid.uuid4().hex[:12]}" + n_threads = 20 + successes = [] + failures = [] + barrier = threading.Barrier(n_threads) + + def _attempt(i): + client = _make_client(endpoint_url) + barrier.wait() + try: + client.put_item( + TableName=table, + Item={ + "pk": {"S": pk}, + "guard": {"S": f"owner-{i}"}, + }, + ConditionExpression="attribute_not_exists(#g)", + ExpressionAttributeNames={"#g": "guard"}, + ) + successes.append(i) + except ClientError as e: + if e.response["Error"]["Code"] == "ConditionalCheckFailedException": + failures.append(i) + else: + raise + + threads = [threading.Thread(target=_attempt, args=(i,)) for i in range(n_threads)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert len(successes) == 1, ( + f"Expected exactly 1 winner, got {len(successes)}: {successes}" + ) + assert len(failures) == n_threads - 1 + + +# --------------------------------------------------------------------------- +# B3 — conditional DeleteItem races with UpdateItem +# --------------------------------------------------------------------------- + +class TestB3DeleteUpdateRace: + """After a conditional delete and a concurrent update, item must not vanish + while the update also reports success — that would violate serializability.""" + + @pytest.fixture(scope="class") + def table(self, dynamodb_client): + with scoped_table(dynamodb_client) as name: + yield name + + def test_no_lost_update_on_delete_race(self, table, endpoint_url): + n_rounds = 30 + for _ in range(n_rounds): + pk = f"b3-{uuid.uuid4().hex[:12]}" + seed_client = _make_client(endpoint_url) + seed_client.put_item( + TableName=table, + Item={"pk": {"S": pk}, "v": {"N": "1"}}, + ) + + delete_ok = [] + update_ok = [] + barrier = threading.Barrier(2) + + def _delete(): + client = _make_client(endpoint_url) + barrier.wait() + try: + client.delete_item( + TableName=table, + Key={"pk": {"S": pk}}, + ConditionExpression="#v = :one", + ExpressionAttributeNames={"#v": "v"}, + ExpressionAttributeValues={":one": {"N": "1"}}, + ) + delete_ok.append(True) + except ClientError as e: + if e.response["Error"]["Code"] != "ConditionalCheckFailedException": + raise + + def _update(): + client = _make_client(endpoint_url) + barrier.wait() + try: + client.update_item( + TableName=table, + Key={"pk": {"S": pk}}, + UpdateExpression="SET #v = :two", + ConditionExpression="#v = :one", + ExpressionAttributeNames={"#v": "v"}, + ExpressionAttributeValues={ + ":one": {"N": "1"}, + ":two": {"N": "2"}, + }, + ) + update_ok.append(True) + except ClientError as e: + if e.response["Error"]["Code"] != "ConditionalCheckFailedException": + raise + + td = threading.Thread(target=_delete) + tu = threading.Thread(target=_update) + td.start() + tu.start() + td.join() + tu.join() + + # Both cannot succeed: that would require the item to be deleted + # (v=1 matched delete) AND updated (v=1 matched update) — impossible + # in any serial order. + assert not (delete_ok and update_ok), ( + f"pk={pk}: both delete and update succeeded — atomicity violation" + ) + + # Verify final state is consistent with whichever operation won. + resp = seed_client.get_item( + TableName=table, Key={"pk": {"S": pk}} + ) + item = resp.get("Item") + if delete_ok: + assert item is None, "Delete succeeded but item still present" + elif update_ok: + assert item is not None and item["v"]["N"] == "2", ( + "Update succeeded but item has wrong value" + ) + # If neither succeeded (both lost the race to each other's version + # fence), the item remains at v=1 — that is a valid serial outcome. + + +# --------------------------------------------------------------------------- +# B4 — unconditional PutItem is not silently overwritten by a racing UpdateItem +# --------------------------------------------------------------------------- + +class TestB4PutUpdateRace: + """An unconditional PutItem must not be silently lost to a concurrent + UpdateItem that read the old version before the put landed.""" + + @pytest.fixture(scope="class") + def table(self, dynamodb_client): + with scoped_table(dynamodb_client) as name: + yield name + + def test_put_not_lost_to_concurrent_update(self, table, endpoint_url): + n_rounds = 30 + for _ in range(n_rounds): + pk = f"b4-{uuid.uuid4().hex[:12]}" + seed_client = _make_client(endpoint_url) + seed_client.put_item( + TableName=table, + Item={"pk": {"S": pk}, "src": {"S": "orig"}}, + ) + + barrier = threading.Barrier(2) + put_done = threading.Event() + update_done = threading.Event() + + def _put(): + client = _make_client(endpoint_url) + barrier.wait() + client.put_item( + TableName=table, + Item={"pk": {"S": pk}, "src": {"S": "put"}}, + ) + put_done.set() + + def _update(): + client = _make_client(endpoint_url) + barrier.wait() + try: + client.update_item( + TableName=table, + Key={"pk": {"S": pk}}, + UpdateExpression="SET u = :one", + ExpressionAttributeValues={":one": {"N": "1"}}, + ) + except ClientError: + pass + update_done.set() + + tp = threading.Thread(target=_put) + tu = threading.Thread(target=_update) + tp.start() + tu.start() + tp.join() + tu.join() + + resp = seed_client.get_item( + TableName=table, Key={"pk": {"S": pk}} + ) + item = resp.get("Item") + assert item is not None, "Item disappeared" + + # The put replaced the whole item. If the update also succeeded it + # must have been serialized AFTER the put (so src="put" and u=1), + # or BEFORE the put (so src="put" and u absent). Either way src + # must be "put" — the put must not be silently overwritten. + assert item["src"]["S"] == "put", ( + f"PutItem result was overwritten: item={item}" + ) + + +# --------------------------------------------------------------------------- +# B5 — idempotency token is released on transaction cancellation +# --------------------------------------------------------------------------- + +class TestB5IdempotencyTokenReleasedOnCancel: + """A TransactWriteItems that fails a condition must release its token so + a subsequent retry with the same token (after fixing the condition) succeeds.""" + + @pytest.fixture(scope="class") + def table(self, dynamodb_client): + with scoped_table(dynamodb_client) as name: + yield name + + def test_token_released_after_cancellation(self, table, endpoint_url): + client = _make_client(endpoint_url) + pk_target = f"b5-target-{uuid.uuid4().hex[:12]}" + pk_blocker = f"b5-blocker-{uuid.uuid4().hex[:12]}" + + # Seed the target so attribute_not_exists(pk) fails on first attempt. + client.put_item( + TableName=table, + Item={"pk": {"S": pk_target}, "v": {"N": "0"}}, + ) + + token = f"t-{uuid.uuid4().hex[:33]}" + + def _transact(): + return client.transact_write_items( + TransactItems=[ + { + "Put": { + "TableName": table, + "Item": {"pk": {"S": pk_blocker}, "v": {"N": "1"}}, + "ConditionExpression": "attribute_not_exists(pk)", + } + }, + { + "Put": { + "TableName": table, + "Item": {"pk": {"S": pk_target}, "v": {"N": "1"}}, + "ConditionExpression": "attribute_not_exists(pk)", + } + }, + ], + ClientRequestToken=token, + ) + + # First attempt: pk_target already exists → condition fails → TransactionCanceledException. + with pytest.raises(ClientError) as exc_info: + _transact() + assert exc_info.value.response["Error"]["Code"] == "TransactionCanceledException" + + # Remove the blocker so both conditions can pass on retry. + client.delete_item(TableName=table, Key={"pk": {"S": pk_target}}) + + # Retry with the same token — must succeed and write the items (not replay as no-op). + _transact() + + resp = client.get_item(TableName=table, Key={"pk": {"S": pk_blocker}}) + assert "Item" in resp, ( + "Retry with same token after cancellation wrote nothing — token was poisoned (B5)" + ) From 845f933cc66ba05b00f38107bf12bbc398afc4a4 Mon Sep 17 00:00:00 2001 From: Joel Shepherd Date: Tue, 15 Sep 2026 21:04:54 +0000 Subject: [PATCH 26/48] chore:rebase from main --- Cargo.lock | 2 +- crates/storage-cassandra/src/metadata_engine.rs | 1 + crates/storage/src/config.rs | 13 ------------- 3 files changed, 2 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 51a2f14f..10efb9d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1406,7 +1406,7 @@ dependencies = [ [[package]] name = "extenddb-storage-cassandra" -version = "0.1.10" +version = "0.1.11" dependencies = [ "aes-gcm", "async-trait", diff --git a/crates/storage-cassandra/src/metadata_engine.rs b/crates/storage-cassandra/src/metadata_engine.rs index 2510e195..56159eeb 100755 --- a/crates/storage-cassandra/src/metadata_engine.rs +++ b/crates/storage-cassandra/src/metadata_engine.rs @@ -976,6 +976,7 @@ impl MetadataEngine for CassandraEngine { &self, account_id: &str, table_name: &str, + _ttl_attribute: &str, ) -> BoxFuture<'_, Result<(), StorageError>> { let account_id = account_id.to_owned(); let table_name = table_name.to_owned(); diff --git a/crates/storage/src/config.rs b/crates/storage/src/config.rs index b7b6aca3..3bdb1204 100644 --- a/crates/storage/src/config.rs +++ b/crates/storage/src/config.rs @@ -62,19 +62,6 @@ pub trait StorageConfig: Send + Sync + std::fmt::Debug { /// Clone this config into a boxed trait object. fn clone_box(&self) -> Box; - /// Set the server instance identifier for this backend. - /// - /// Called by the server before constructing the storage engine, passing - /// a stable unique identifier for this server process (typically the - /// bind address, e.g. "192.168.1.1:18443"). Backends that need per-instance - /// identity (e.g. for HLC node IDs) implement this; others can ignore it. - fn set_instance_id(&mut self, _instance_id: &str) {} - - /// Return the instance identifier set via `set_instance_id`, if any. - fn instance_id(&self) -> Option<&str> { - None - } - /// Enable downcasting to specific storage engine config types to allow /// access to engine-specific configuration (e.g. `keyspace_prefix` for /// the Cassandra backend). From e94622ff25113c2f1ee73e441465b1f9156f81b4 Mon Sep 17 00:00:00 2001 From: Joel Shepherd Date: Tue, 15 Sep 2026 21:41:09 +0000 Subject: [PATCH 27/48] feat: normalize stream record sequence numbers to backend-specified width --- crates/engine/src/streams.rs | 7 +++++-- crates/storage/src/lib.rs | 11 +++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/streams.rs b/crates/engine/src/streams.rs index 0803a80f..e1f85af4 100755 --- a/crates/engine/src/streams.rs +++ b/crates/engine/src/streams.rs @@ -111,13 +111,16 @@ pub async fn handle_get_shard_iterator( "SequenceNumber is required for AT_SEQUENCE_NUMBER iterator type".to_owned(), ) })?; - let n = raw.parse::().map_err(|_| { + let n = raw.parse::().map_err(|_| { DynamoDbError::ValidationException("Invalid SequenceNumber".to_owned()) })?; // n == 0: sequence 0 is the first possible record, so "at 0" // means "read from the beginning" — same as TRIM_HORIZON. if n > 0 { - format!("{:021}", n - 1) + // Pad to the backend's stored width so lexicographic order + // matches numeric order against stored sequence numbers. + let width = ctx.storage.sequence_number_width(); + format!("{:0>width$}", n - 1) } else { String::new() } diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 185dcd21..607c1faf 100755 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -75,6 +75,9 @@ use extenddb_core::types::{ use error::StorageError; +/// Default fixed width for stream record sequence numbers in characters (left-padded). +pub(crate) const DEFAULT_SEQ_NUMBER_WIDTH: usize = 21; + // Type aliases for complex return types used in trait methods. /// Result of an update/put/delete that may return old and/or new item images. pub type ItemPairResult = Result<(Option, Option), StorageError>; @@ -609,6 +612,14 @@ pub trait StreamEngine: Send + Sync { /// Generate the next sequence number for a shard. fn next_sequence_number(&self, shard_id: &str) -> BoxFuture<'_, Result>; + /// Width of sequence numbers stored by this backend, in decimal digits. + /// Client-supplied sequence numbers (e.g. from `GetShardIterator`) must be + /// zero-padded to this width before lexicographic comparison against stored + /// values. Defaults to 21 (PostgreSQL, SQLite, MongoDB). + fn sequence_number_width(&self) -> usize { + DEFAULT_SEQ_NUMBER_WIDTH + } + /// Validate that a shard exists for the given stream ARN. /// /// Returns `Ok(())` if the shard exists and belongs to the stream. From 214c8f04e63d0beb391233c644b637075a92dbf7 Mon Sep 17 00:00:00 2001 From: Joel Shepherd Date: Tue, 15 Sep 2026 22:19:48 +0000 Subject: [PATCH 28/48] test: unit test for sequence number padding --- crates/engine/src/streams.rs | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/crates/engine/src/streams.rs b/crates/engine/src/streams.rs index e1f85af4..40f3b2fc 100755 --- a/crates/engine/src/streams.rs +++ b/crates/engine/src/streams.rs @@ -265,3 +265,32 @@ fn storage_to_dynamo(e: StorageError) -> DynamoDbError { } } } + +#[cfg(test)] +mod tests { + /// AT_SEQUENCE_NUMBER converts to AFTER by subtracting 1 and padding to + /// the backend's stored width. Verify that an unpadded client input is + /// normalised correctly for both the 21-digit (postgres/sqlite/mongodb) + /// and 23-digit (cassandra) cases, and that the edge case n=0 maps to + /// TRIM_HORIZON (empty string). + #[test] + fn at_sequence_number_padding() { + let cases: &[(&str, usize, &str)] = &[ + ("5", 21, "000000000000000000004"), + ("000000000000000000005", 21, "000000000000000000004"), + ("5", 23, "00000000000000000000004"), + ("00000000000000000000005", 23, "00000000000000000000004"), + ("0", 21, ""), + ("1", 21, "000000000000000000000"), + ]; + for (input, width, expected) in cases { + let n: u128 = input.parse().unwrap(); + let result = if n > 0 { + format!("{:0>width$}", n - 1) + } else { + String::new() + }; + assert_eq!(&result, expected, "input={input} width={width}"); + } + } +} From 71d99920b7cfe2b4d464b0723fc4c9c92fca848c Mon Sep 17 00:00:00 2001 From: Joel Shepherd Date: Tue, 15 Sep 2026 22:42:22 +0000 Subject: [PATCH 29/48] fix(storage-cassandra): override sequence_number_width to 23 for HLC format --- crates/storage-cassandra/src/stream_engine.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/storage-cassandra/src/stream_engine.rs b/crates/storage-cassandra/src/stream_engine.rs index 27520696..e3c6088f 100755 --- a/crates/storage-cassandra/src/stream_engine.rs +++ b/crates/storage-cassandra/src/stream_engine.rs @@ -477,6 +477,12 @@ impl StreamEngine for CassandraEngine { Box::pin(async move { Ok(seq) }) } + /// Override the stream sequence number fixed width to 23 for the HLC format: + /// 13-digit ms timestamp + 6-digit counter + 4-digit node ID. + fn sequence_number_width(&self) -> usize { + 23 + } + fn validate_shard( &self, account_id: &str, From 88b2750424a2219ed2a3d82ecc5aec7025b3d3d6 Mon Sep 17 00:00:00 2001 From: Joel Shepherd Date: Tue, 15 Sep 2026 23:20:08 +0000 Subject: [PATCH 30/48] fix: table tags are deleted along with the table --- crates/storage-cassandra/src/delete_table.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/storage-cassandra/src/delete_table.rs b/crates/storage-cassandra/src/delete_table.rs index 7d26bc73..43067ef2 100644 --- a/crates/storage-cassandra/src/delete_table.rs +++ b/crates/storage-cassandra/src/delete_table.rs @@ -126,6 +126,17 @@ impl CassandraEngine { .await .map_err(|e| StorageError::Internal(format!("Delete continuous backup state: {e}")))?; + // Delete tags for this table so they don't survive recreation under the same name. + let delete_tags_query = + format!("DELETE FROM {catalog_keyspace}.tags WHERE resource_arn = ?"); + self.session + .query_with_values( + &delete_tags_query, + cdrs_tokio::query_values!(description.table_arn.as_str()), + ) + .await + .map_err(|e| StorageError::Internal(format!("Delete tags: {e}")))?; + // Delete table catalog entry let delete_table_query = format!( "DELETE FROM {catalog_keyspace}.tables WHERE account_id = ? AND table_name = ?" From d14716154228ca4bccc2b9f909ba551c5d5750f5 Mon Sep 17 00:00:00 2001 From: Joel Shepherd Date: Tue, 15 Sep 2026 23:37:51 +0000 Subject: [PATCH 31/48] fix: fix overlooked call site for drop_ttl_index --- crates/storage-cassandra/tests/ttl_integration.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/storage-cassandra/tests/ttl_integration.rs b/crates/storage-cassandra/tests/ttl_integration.rs index e1fe11ef..58d94ebe 100644 --- a/crates/storage-cassandra/tests/ttl_integration.rs +++ b/crates/storage-cassandra/tests/ttl_integration.rs @@ -271,7 +271,7 @@ async fn test_ttl_metadata_enable_disable_and_listing() { .await .expect("disable TTL metadata"); engine - .drop_ttl_index(&table.key_info.account_id, &table.key_info.table_name) + .drop_ttl_index(&table.key_info.account_id, &table.key_info.table_name, "expires_at") .await .expect("clear TTL queue"); assert_eq!( From d9aedfff245a44561d99eb9022c570d0a7ff660a Mon Sep 17 00:00:00 2001 From: Joel Shepherd Date: Thu, 17 Sep 2026 00:38:54 +0000 Subject: [PATCH 32/48] fix:addressing 'PII' information in bootstrapper output --- crates/storage-cassandra/src/bootstrapper.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/storage-cassandra/src/bootstrapper.rs b/crates/storage-cassandra/src/bootstrapper.rs index 232828cd..548cf2c2 100644 --- a/crates/storage-cassandra/src/bootstrapper.rs +++ b/crates/storage-cassandra/src/bootstrapper.rs @@ -102,7 +102,7 @@ impl CassandraBootstrapper { .clone() .try_into() .map_err(|e: toml::de::Error| { - StorageError::Internal(format!("Invalid cassandra config: {e}")) + StorageError::Internal(format!("Invalid Cassandra config: {e}")) })?; // Check for conflicts between CLI args and config values @@ -470,7 +470,7 @@ impl Bootstrapper for CassandraBootstrapper { // Create default account let account_id = generate_account_id(); - println!("--- Creating default account '{account_id}'..."); + println!("--- Creating default account..."); let account_name = "default"; let insert_cql = format!( @@ -486,18 +486,18 @@ impl Bootstrapper for CassandraBootstrapper { .await .map_err(|e| OpError::Internal(format!("Create account: {e}")))?; - println!(" Account ID: {account_id}"); + println!(" Default account created"); // Create account-specific keyspace let account_keyspace = self.engine.account_keyspace(&account_id); - println!("--- Creating account keyspace '{account_keyspace}'..."); + println!("--- Creating account keyspace..."); self.engine .create_keyspace(&account_keyspace) .await .map_err(|e| OpError::Internal(format!("Create account keyspace: {e}")))?; // Run data migrations in account keyspace - println!("--- Running data migrations for account '{account_id}'..."); + println!("--- Running data migrations for default account..."); migrations::run_data_migrations(&self.engine.session_arc(), &account_keyspace).await?; Ok(()) @@ -527,7 +527,7 @@ impl Bootstrapper for CassandraBootstrapper { .is_some_and(|rows| !rows.is_empty()); if exists { - println!("--- Admin user '{username}' already exists, skipping."); + println!("--- Admin user already exists, skipping."); return Ok(AdminBootstrapResult { username: username.to_string(), generated_password: None, @@ -536,7 +536,7 @@ impl Bootstrapper for CassandraBootstrapper { }); } - println!("--- Creating admin user '{username}'..."); + println!("--- Creating admin user..."); // Generate or use provided password let password = if let Some(pw) = env_password { From 439ca5b4a527064bd61b3bcfd959bd12045d277a Mon Sep 17 00:00:00 2001 From: Scott Robinson Date: Thu, 17 Sep 2026 19:54:12 +0000 Subject: [PATCH 33/48] fix: restore the PR #339 work dropped by the rebase from main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The branch reconstruction (845f933 and the rebuilt series after it) re-applied the backend squash and the PR #338 OCC changes but not merged PR #339, so the follow-up work reviewed and approved there vanished from the branch. This restores it, adapted to the reconstructed base rather than re-applied verbatim: #338's OCC layer (occ_write / occ_delete_lwt, always-non-null version) and the rebuilt claim protocol supersede #339's put-path version bumps and claim-fence changes, and those stay superseded — the base's newer per-page repair cursors and table-not-found tolerance are also kept where #339's equivalents were older. What comes back is everything orthogonal: * The self-healing audit: a leaseless, paced rescan of every ready TTL table that re-registers any item whose queue entry is missing (a lost registration was otherwise permanent for an item never written again, with nothing alarming). One quorum read per healthy item, deferring to claimed work, standing aside on lifecycle movement. Its repair count (TtlAuditRepairedEntryCount) is the drift signal. * Transaction recovery reconciles the pre-commit image: PREPARE persists each row's prior image into the ledger (serde-defaulted, old ledgers stay insert-only) and COMMITTING recovery retires the old queue entry as the live path does, DELETE ops included. The insert-only reconcile helpers lose their last caller and are removed. * Sweep throughput: concurrent waves (16 in flight) over the queue's disjoint shard partitions, batch 100 -> 1,000, lease renewed per 64-row wave, per-row pre-effects gate as a quorum config read rather than a lease renewal (per-row renewals serialize on one catalog partition's Paxos), row failures confined to the row and counted per table (TtlSweepRowErrorCount). * The 5-second epoched TTL-config cache on the write path, invalidated on update_ttl; update_table's GSI-fence decision reads quorum and uncached. The idle outbox pass visits only keyspaces of accounts with TTL tables, widening every tenth cycle. * The repair worker emits TtlRepairMarkerCount, the unresolved-marker gauge ADR-0010 asks operators to watch, from an (observed, resolved) tuple merged into the base's newer cursor-paging implementation. * The Cassandra CI workflow (deleted outright by the rebase), the skip-without-Cassandra test gating that keeps the serviceless workspace test job green, the benchmark harness, the audit integration test, the workspace clippy fixes, and the ADR / differences-doc updates. Verified against a fresh Cassandra 4.1: 25/25 ttl_integration serial (88s), 1 metadata, 1,145 workspace lib tests, clippy -D warnings clean, fmt clean. --- .github/workflows/integration-cassandra.yml | 54 ++++ crates/core/src/metrics/collector.rs | 31 +++ crates/core/src/metrics/types.rs | 13 + crates/server/src/management/auth.rs | 4 + crates/storage-cassandra/src/catalog_store.rs | 43 +-- .../storage-cassandra/src/data/delete_item.rs | 33 ++- crates/storage-cassandra/src/data/index.rs | 1 + .../src/data/put_get_item.rs | 9 +- .../src/data/transaction_ledger.rs | 9 + .../src/data/transactions.rs | 81 ++++-- crates/storage-cassandra/src/data/ttl.rs | 42 ++- .../storage-cassandra/src/data/update_item.rs | 3 +- crates/storage-cassandra/src/engine.rs | 31 +++ crates/storage-cassandra/src/lib.rs | 16 +- .../src/management_store/access_keys.rs | 2 +- .../storage-cassandra/src/metadata_engine.rs | 220 +++++++++++++-- crates/storage-cassandra/src/stream_util.rs | 1 + crates/storage-cassandra/src/ttl_worker.rs | 202 ++++++++++++-- crates/storage-cassandra/src/update_table.rs | 5 +- crates/storage-cassandra/tests/common/mod.rs | 26 ++ .../tests/metadata_integration.rs | 3 + crates/storage-cassandra/tests/ttl_bench.rs | 203 ++++++++++++++ .../tests/ttl_integration.rs | 254 +++++++++++++++++- crates/storage-sqlite/src/vector_search.rs | 6 +- .../0010-cassandra-ttl-expiration-queue.md | 14 +- docs/differences-from-dynamodb.md | 4 +- 26 files changed, 1170 insertions(+), 140 deletions(-) create mode 100644 .github/workflows/integration-cassandra.yml create mode 100644 crates/storage-cassandra/tests/ttl_bench.rs diff --git a/.github/workflows/integration-cassandra.yml b/.github/workflows/integration-cassandra.yml new file mode 100644 index 00000000..7c446558 --- /dev/null +++ b/.github/workflows/integration-cassandra.yml @@ -0,0 +1,54 @@ +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 +name: Cassandra Integration Tests + +on: + pull_request: + merge_group: + push: + branches: [main, feat/storage-cassandra-in-tree] + +permissions: + contents: read + +# The Cassandra backend's integration tests (crates/storage-cassandra/tests) +# talk to a live node at 127.0.0.1:9042 with cassandra/cassandra credentials +# and are not feature-gated — without this workflow they never run in CI and +# every green result is a local claim. The TTL claim protocol in particular +# fails silently if the stored item encoding drifts, so these tests are the +# only automated signal for a whole class of regressions. +# +# A single-node container is enough: the tests create their own keyspaces +# (RF=1) and run serially because the sweep and reconciliation passes are +# global. The stock image uses AllowAllAuthenticator, so the credentials the +# tests and health check send are accepted without an auth challenge. +jobs: + cassandra-storage: + runs-on: ubuntu-latest + services: + cassandra: + image: cassandra:4.1 + ports: + - 9042:9042 + env: + CASSANDRA_CLUSTER_NAME: extenddb-ci + HEAP_NEWSIZE: 128M + MAX_HEAP_SIZE: 1024M + options: >- + --health-cmd "cqlsh -u cassandra -p cassandra -e 'SELECT release_version FROM system.local'" + --health-interval 15s + --health-timeout 10s + --health-retries 20 + --health-start-period 60s + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + - name: Unit tests + run: cargo test -p extenddb-storage-cassandra --lib + - name: Metadata integration tests + run: cargo test -p extenddb-storage-cassandra --test metadata_integration -- --test-threads=1 + - name: TTL integration tests + run: cargo test -p extenddb-storage-cassandra --test ttl_integration -- --test-threads=1 diff --git a/crates/core/src/metrics/collector.rs b/crates/core/src/metrics/collector.rs index 56d416a2..f72e21aa 100755 --- a/crates/core/src/metrics/collector.rs +++ b/crates/core/src/metrics/collector.rs @@ -275,6 +275,37 @@ impl MetricsCollector { ); } + /// Record a queue registration the TTL audit found missing and recreated. + pub fn record_ttl_audit_repair(&self, table_name: &str) { + self.record( + MetricName::TtlAuditRepairedEntryCount, + 1.0, + Some(table_name), + None, + None, + ); + } + + /// Record a sweep row that failed and was left for its queue-state retry. + /// + /// Isolated failures are normal (contention, restarts); a sustained + /// nonzero rate on one table means rows are chronically erroring and + /// retrying without ever expiring, which a log line alone won't surface. + pub fn record_ttl_sweep_row_error(&self, table_name: &str) { + self.record( + MetricName::TtlSweepRowErrorCount, + 1.0, + Some(table_name), + None, + None, + ); + } + + /// Record the number of unresolved TTL destroy markers seen in one pass. + pub fn record_ttl_repair_markers(&self, count: f64) { + self.record(MetricName::TtlRepairMarkerCount, count, None, None, None); + } + /// Record TTL deletion staleness (seconds past expiry). pub fn record_ttl_staleness(&self, table_name: &str, staleness_secs: f64) { self.record( diff --git a/crates/core/src/metrics/types.rs b/crates/core/src/metrics/types.rs index 7b55a69c..39d9d07f 100755 --- a/crates/core/src/metrics/types.rs +++ b/crates/core/src/metrics/types.rs @@ -27,6 +27,16 @@ pub enum MetricName { TimeToLiveDeletedItemCount, /// Seconds between TTL expiry and actual deletion (staleness). TtlDeletionStaleness, + /// Queue registrations the periodic TTL audit found missing and recreated. + /// Nonzero means some loss path fired; sustained nonzero means one is + /// firing repeatedly and needs investigation. + TtlAuditRepairedEntryCount, + TtlSweepRowErrorCount, + /// Unresolved TTL destroy markers observed by the repair worker. Sustained + /// growth means destroys are repeatedly ending ambiguously (Cassandra + /// health) or a repair path is stuck; the ADR requires operators to + /// monitor this. + TtlRepairMarkerCount, /// P120c: HTTP request count (dimensions: operation). RequestCount, /// P120c: Storage query count (dimensions: source, category). @@ -64,6 +74,9 @@ impl std::fmt::Display for MetricName { Self::ReturnedBytes => f.write_str("ReturnedBytes"), Self::TimeToLiveDeletedItemCount => f.write_str("TimeToLiveDeletedItemCount"), Self::TtlDeletionStaleness => f.write_str("TtlDeletionStaleness"), + Self::TtlAuditRepairedEntryCount => f.write_str("TtlAuditRepairedEntryCount"), + Self::TtlSweepRowErrorCount => f.write_str("TtlSweepRowErrorCount"), + Self::TtlRepairMarkerCount => f.write_str("TtlRepairMarkerCount"), Self::RequestCount => f.write_str("RequestCount"), Self::StorageQueryCount => f.write_str("StorageQueryCount"), Self::StorageQueryLatency => f.write_str("StorageQueryLatency"), diff --git a/crates/server/src/management/auth.rs b/crates/server/src/management/auth.rs index 168bd164..bc167465 100755 --- a/crates/server/src/management/auth.rs +++ b/crates/server/src/management/auth.rs @@ -36,6 +36,7 @@ pub enum CallerIdentity { /// /// Returns the caller identity on success, or an error response on failure. /// Enforces per-principal lockout and per-IP rate limiting via the storage backend. +#[allow(clippy::result_large_err)] // axum Response as rejection is this module's idiom pub async fn authenticate( headers: &HeaderMap, store: &dyn extenddb_storage::CatalogStore, @@ -143,6 +144,7 @@ pub async fn authenticate( } /// Authenticate as admin only. Returns error if caller is not an admin. +#[allow(clippy::result_large_err)] // axum Response as rejection is this module's idiom pub async fn authenticate_admin( headers: &HeaderMap, store: &dyn extenddb_storage::CatalogStore, @@ -157,6 +159,7 @@ pub async fn authenticate_admin( } } +#[allow(clippy::result_large_err)] // axum Response as rejection is this module's idiom async fn try_admin_auth( username: &str, password: &str, @@ -186,6 +189,7 @@ async fn try_admin_auth( } #[allow(clippy::similar_names)] +#[allow(clippy::result_large_err)] // axum Response as rejection is this module's idiom async fn try_iam_user_auth( username: &str, password: &str, diff --git a/crates/storage-cassandra/src/catalog_store.rs b/crates/storage-cassandra/src/catalog_store.rs index 29e29ccf..e0adc3f6 100644 --- a/crates/storage-cassandra/src/catalog_store.rs +++ b/crates/storage-cassandra/src/catalog_store.rs @@ -412,8 +412,7 @@ impl RateLimitStore for CassandraCatalogStore { let session = self.session.clone(); let catalog_keyspace = self.catalog_keyspace(); Box::pin(async move { - let cutoff = chrono::Utc::now() - - chrono::Duration::seconds(window_seconds); + let cutoff = chrono::Utc::now() - chrono::Duration::seconds(window_seconds); let cutoff_ms = cutoff.timestamp_millis(); let query = format!( "SELECT COUNT(*) FROM {catalog_keyspace}.login_attempts \ @@ -421,7 +420,10 @@ impl RateLimitStore for CassandraCatalogStore { ALLOW FILTERING" ); let result = session - .query_with_values(&query, cdrs_tokio::query_values!(principal.as_str(), cutoff_ms)) + .query_with_values( + &query, + cdrs_tokio::query_values!(principal.as_str(), cutoff_ms), + ) .await .map_err(|e| { tracing::error!("count_principal_failures: {e}"); @@ -447,8 +449,7 @@ impl RateLimitStore for CassandraCatalogStore { let session = self.session.clone(); let catalog_keyspace = self.catalog_keyspace(); Box::pin(async move { - let cutoff = chrono::Utc::now() - - chrono::Duration::seconds(window_seconds); + let cutoff = chrono::Utc::now() - chrono::Duration::seconds(window_seconds); let cutoff_ms = cutoff.timestamp_millis(); // No partition key available for source_ip; ALLOW FILTERING is // acceptable because cleanup_old_attempts keeps the table bounded. @@ -458,7 +459,10 @@ impl RateLimitStore for CassandraCatalogStore { ALLOW FILTERING" ); let result = session - .query_with_values(&query, cdrs_tokio::query_values!(source_ip.as_str(), cutoff_ms)) + .query_with_values( + &query, + cdrs_tokio::query_values!(source_ip.as_str(), cutoff_ms), + ) .await .map_err(|e| { tracing::error!("count_ip_failures: {e}"); @@ -489,11 +493,7 @@ impl RateLimitStore for CassandraCatalogStore { if let Err(e) = session .query_with_values( &query, - cdrs_tokio::query_values!( - principal.as_str(), - now_ms, - source_ip.as_deref() - ), + cdrs_tokio::query_values!(principal.as_str(), now_ms, source_ip.as_deref()), ) .await { @@ -502,20 +502,21 @@ impl RateLimitStore for CassandraCatalogStore { }) } + #[allow(clippy::result_large_err)] // response-body closure; boxing would ripple through cdrs plumbing fn cleanup_old_attempts(&self, max_age_seconds: i64) -> BoxFuture<'_, ()> { let session = self.session.clone(); let catalog_keyspace = self.catalog_keyspace(); Box::pin(async move { - let cutoff = chrono::Utc::now() - - chrono::Duration::seconds(max_age_seconds); + let cutoff = chrono::Utc::now() - chrono::Duration::seconds(max_age_seconds); let cutoff_ms = cutoff.timestamp_millis(); // Fetch principals with old records, then delete by partition key. // Cassandra does not support DELETE ... WHERE non-pk < ? without // ALLOW FILTERING; fetching principals first avoids a full scan on delete. - let select = format!( - "SELECT DISTINCT principal FROM {catalog_keyspace}.login_attempts" - ); - let principals = match session.query(&select).await + let select = + format!("SELECT DISTINCT principal FROM {catalog_keyspace}.login_attempts"); + let principals = match session + .query(&select) + .await .and_then(|r| r.response_body()) .map(|b| b.into_rows().unwrap_or_default()) { @@ -638,8 +639,12 @@ impl MetricsStore for CassandraCatalogStore { let idx: String = get_column(&row, "index_name", "query_metrics")?; let op: String = get_column(&row, "operation", "query_metrics")?; - if table_name.as_deref().is_some_and(|f| f != tn) { continue; } - if metric.as_deref().is_some_and(|f| f != metric_val) { continue; } + if table_name.as_deref().is_some_and(|f| f != tn) { + continue; + } + if metric.as_deref().is_some_and(|f| f != metric_val) { + continue; + } out.push(MetricsRow { bucket, diff --git a/crates/storage-cassandra/src/data/delete_item.rs b/crates/storage-cassandra/src/data/delete_item.rs index 4a79d8b9..6af6bde7 100644 --- a/crates/storage-cassandra/src/data/delete_item.rs +++ b/crates/storage-cassandra/src/data/delete_item.rs @@ -226,13 +226,18 @@ impl CassandraEngine { .response_body() .map_err(|e| StorageError::Internal(format!("Parse response: {e}")))?; - let (old_item_opt, version, prepared_txn_id_opt) = if let Some(rows) = body.into_rows() { + let (old_item_opt, version, prepared_txn_id_opt) = if let Some(rows) = body.into_rows() + { if let Some(row) = rows.into_iter().next() { let item_data: Option = row.get_by_name("item_data").ok().flatten(); let version: i64 = row.get_by_name("version").ok().flatten().unwrap_or(0); let prepared_txn_id: Option = row.get_by_name("prepared_txn_id").ok().flatten(); - (item_data.map(json_to_item).transpose()?, version, prepared_txn_id) + ( + item_data.map(json_to_item).transpose()?, + version, + prepared_txn_id, + ) } else { (None, 0, None) } @@ -469,7 +474,11 @@ impl CassandraEngine { let version: i64 = row.get_by_name("version").ok().flatten().unwrap_or(0); let prepared_txn_id: Option = row.get_by_name("prepared_txn_id").ok().flatten(); - (item_data.map(json_to_item).transpose()?, version, prepared_txn_id) + ( + item_data.map(json_to_item).transpose()?, + version, + prepared_txn_id, + ) } else { (None, 0, None) }; @@ -1431,10 +1440,8 @@ impl CassandraEngine { ]); crate::cassandra_util::query_lwt(&self.session, cql, qv).await? } else { - let qv = cdrs_tokio::query::QueryValues::SimpleValues(vec![ - Value::from(pk), - version.into(), - ]); + let qv = + cdrs_tokio::query::QueryValues::SimpleValues(vec![Value::from(pk), version.into()]); crate::cassandra_util::query_lwt(&self.session, cql, qv).await? }; crate::cassandra_util::lwt_applied(&result) @@ -1482,8 +1489,8 @@ impl CassandraEngine { .await?; } - if let Some(cap) = stream { - if let Some(stmt) = crate::stream_util::stream_record_statement( + if let Some(cap) = stream + && let Some(stmt) = crate::stream_util::stream_record_statement( data_keyspace, &key_info.table_id, key_info, @@ -1492,10 +1499,9 @@ impl CassandraEngine { cap, &self.hlc, self.stream_retention_seconds, - ) { - batch = - batch.add_query(stmt, cdrs_tokio::query::QueryValues::SimpleValues(vec![])); - } + ) + { + batch = batch.add_query(stmt, cdrs_tokio::query::QueryValues::SimpleValues(vec![])); } let built = batch @@ -1512,4 +1518,3 @@ impl CassandraEngine { Ok(()) } } - diff --git a/crates/storage-cassandra/src/data/index.rs b/crates/storage-cassandra/src/data/index.rs index 301c2c92..4ebd72da 100644 --- a/crates/storage-cassandra/src/data/index.rs +++ b/crates/storage-cassandra/src/data/index.rs @@ -425,6 +425,7 @@ pub(crate) async fn enqueue_async_indexes( /// /// Adds a parameterized DELETE statement to the batch. /// Cassandra requires ALL PRIMARY KEY columns for DELETE. +#[allow(clippy::too_many_arguments)] pub(crate) fn delete_index_row_multi( batch: &mut BatchQueryBuilder, account_keyspace: &str, diff --git a/crates/storage-cassandra/src/data/put_get_item.rs b/crates/storage-cassandra/src/data/put_get_item.rs index a7e1fee4..18d4a6cf 100644 --- a/crates/storage-cassandra/src/data/put_get_item.rs +++ b/crates/storage-cassandra/src/data/put_get_item.rs @@ -131,7 +131,8 @@ impl CassandraEngine { .map(|k| k.attribute_name.as_str()) .collect(); - let key_not_exists_condition = is_attribute_not_exists_key(condition, &key_attr_names, maps); + let key_not_exists_condition = + is_attribute_not_exists_key(condition, &key_attr_names, maps); if key_not_exists_condition && ttl_config.is_none() { return self .put_item_if_not_exists( @@ -702,8 +703,10 @@ mod tests { } fn maps_with_names(pairs: &[(&str, &str)]) -> ExpressionMaps { - let names: HashMap = - pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(); + let names: HashMap = pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); ExpressionMaps::new(names, HashMap::new()) } diff --git a/crates/storage-cassandra/src/data/transaction_ledger.rs b/crates/storage-cassandra/src/data/transaction_ledger.rs index 6b541866..9068370f 100644 --- a/crates/storage-cassandra/src/data/transaction_ledger.rs +++ b/crates/storage-cassandra/src/data/transaction_ledger.rs @@ -30,6 +30,7 @@ impl TransactionState { } #[must_use] + #[allow(clippy::should_implement_trait)] // fallible; FromStr would force an error type on callers that treat unknown as None pub fn from_str(s: &str) -> Option { match s { "PREPARING" => Some(Self::Preparing), @@ -68,6 +69,13 @@ pub struct LedgerOp { /// Final item data JSON for PUT/UPDATE (the post-mutation state to write on /// COMMIT). None for DELETE and CHECK, which require no item data. pub item_data: Option, + /// The image the row had before this transaction touched it, captured at + /// PREPARE. COMMITTING recovery uses it to retire the TTL queue entry the + /// item had before the transaction; without it recovery is insert-only. + /// `#[serde(default)]` keeps ledgers written before this field readable — + /// their recovery simply stays insert-only, which is the old behavior. + #[serde(default)] + pub pre_commit_item_data: Option, } /// Transaction ledger entry. @@ -93,6 +101,7 @@ impl CassandraEngine { /// Write a new transaction ledger entry. /// /// Returns an error if the transaction ID already exists (LWT failure). + #[allow(clippy::too_many_arguments)] pub async fn write_ledger_entry( &self, keyspace: &str, diff --git a/crates/storage-cassandra/src/data/transactions.rs b/crates/storage-cassandra/src/data/transactions.rs index 1c59b942..f4ad1b41 100644 --- a/crates/storage-cassandra/src/data/transactions.rs +++ b/crates/storage-cassandra/src/data/transactions.rs @@ -280,11 +280,15 @@ impl CassandraEngine { match prepare_result { Ok(computed_items) => { - // Fill in item_data for UPDATE ops now that PREPARE has computed them - for (ledger_op, computed) in ledger_ops.iter_mut().zip(computed_items.iter()) { + // Fill in the UPDATE post-images and every op's pre-commit + // image now that PREPARE has read them. + for (ledger_op, (computed, pre_commit)) in + ledger_ops.iter_mut().zip(computed_items.iter()) + { if let Some(data) = computed { ledger_op.item_data = Some(data.clone()); } + ledger_op.pre_commit_item_data = pre_commit.clone(); } // Update ledger blob with full data before transitioning to COMMITTING self.update_ledger_blob(&account_keyspace, txn_id, &ledger_ops) @@ -330,9 +334,9 @@ impl CassandraEngine { ops: &[TransactWriteOp<'_>], txn_id: Uuid, txn_timestamp: i64, - ) -> Result>, Vec> { + ) -> Result, Option)>, Vec> { let mut reasons: Vec = Vec::with_capacity(ops.len()); - let mut computed: Vec> = Vec::with_capacity(ops.len()); + let mut computed: Vec<(Option, Option)> = Vec::with_capacity(ops.len()); let mut any_failed = false; for op in ops { @@ -347,7 +351,7 @@ impl CassandraEngine { Err(r) => { any_failed = true; reasons.push(r); - computed.push(None); + computed.push((None, None)); } } } @@ -359,6 +363,16 @@ impl CassandraEngine { } } + /// Serialize a pre-commit image for ledger persistence. + fn pre_commit_json(existing: Option<&Item>) -> Result, CancellationReason> { + existing + .map(|item| { + serde_json::to_string(item) + .map_err(|e| CancellationReason::validation_error(e.to_string())) + }) + .transpose() + } + /// Prepare a single transactional operation. /// /// Returns `Ok(Some(item_data_json))` for UPDATE (the post-mutation state), @@ -368,7 +382,7 @@ impl CassandraEngine { op: &TransactWriteOp<'_>, txn_id: Uuid, txn_timestamp: i64, - ) -> Result, CancellationReason> { + ) -> Result<(Option, Option), CancellationReason> { match op { TransactWriteOp::Put { key_info, @@ -450,7 +464,7 @@ impl CassandraEngine { Err(r) => return Err(r), } } - Ok(None) + Ok((None, Self::pre_commit_json(existing.as_ref())?)) } TransactWriteOp::Delete { key_info, @@ -487,7 +501,7 @@ impl CassandraEngine { // Execute PREPARE self.prepare_item(key_info, key, txn_id, txn_timestamp, false) .await?; - Ok(None) + Ok((None, Self::pre_commit_json(existing.as_ref())?)) } TransactWriteOp::Update { key_info, @@ -595,7 +609,7 @@ impl CassandraEngine { // Return the computed final item so the caller can update the ledger blob let item_json = serde_json::to_string(&item) .map_err(|e| CancellationReason::validation_error(e.to_string()))?; - Ok(Some(item_json)) + Ok((Some(item_json), Self::pre_commit_json(existing.as_ref())?)) } TransactWriteOp::ConditionCheck { key_info, @@ -630,7 +644,7 @@ impl CassandraEngine { )?; // ConditionCheck doesn't prepare any item - Ok(None) + Ok((None, Self::pre_commit_json(existing.as_ref())?)) } } } @@ -831,6 +845,7 @@ impl CassandraEngine { sk_col, sk_val, item_data, + pre_commit_item_data: None, }) }) .collect() @@ -911,12 +926,7 @@ impl CassandraEngine { /// Called on every cancellation path to prevent token poisoning (B5): a /// token reserved before the ledger is committed must be released if the /// transaction is cancelled, so a retry with the same token can proceed. - async fn delete_idempotency_token( - &self, - keyspace: &str, - account_id: &str, - token: &str, - ) { + async fn delete_idempotency_token(&self, keyspace: &str, account_id: &str, token: &str) { let delete_query = format!( "DELETE FROM {keyspace}.idempotency_tokens_by_account \ WHERE account_id = ? AND \"token\" = ?" @@ -1568,19 +1578,34 @@ impl CassandraEngine { } } for op in &ops { - if matches!(op.op.as_str(), "PUT" | "UPDATE") - && let Some(item_data) = op.item_data.as_deref() - { - // Recovery can only re-register the committed image: - // the ledger does not persist the pre-commit image, - // so a transaction that crashes between COMMIT and - // reconciliation can leave the item's previous - // expiration entry in the queue. That entry is - // harmless — the worker revalidates the item before - // deleting anything and retires the entry when it - // comes due — but it is queue garbage until then. - self.reconcile_ttl_item_by_table_id(&op.table_id, item_data) + // The ledger persists the pre-commit image at PREPARE, so + // recovery retires the queue entry the item had before the + // transaction as well as registering the committed one — + // the same transition the live commit path performs. That + // covers DELETE ops too, which previously left their old + // entry queued until its original due time. A ledger + // written before the field exists deserializes it as None, + // and recovery for it stays insert-only (the worker + // revalidates before deleting anything, so the stale entry + // is inert garbage, not a hazard). + match op.op.as_str() { + "PUT" | "UPDATE" => { + self.reconcile_ttl_transition_by_table_id( + &op.table_id, + op.pre_commit_item_data.as_deref(), + op.item_data.as_deref(), + ) .await?; + } + "DELETE" => { + self.reconcile_ttl_transition_by_table_id( + &op.table_id, + op.pre_commit_item_data.as_deref(), + None, + ) + .await?; + } + _ => {} } } } diff --git a/crates/storage-cassandra/src/data/ttl.rs b/crates/storage-cassandra/src/data/ttl.rs index 5d0cb464..e2b2b0fe 100644 --- a/crates/storage-cassandra/src/data/ttl.rs +++ b/crates/storage-cassandra/src/data/ttl.rs @@ -420,6 +420,40 @@ impl TtlRepairGuard { } } +/// Whether `entry` is registered, in any state, at `LOCAL_QUORUM`. +/// +/// The audit's healthy-item fast path: one read, no Paxos, no bucket-registry +/// writes. Any state counts as registered — a claimed row is being expired, +/// which is the strongest possible form of "not lost". +pub(crate) async fn ttl_entry_registered( + engine: &CassandraEngine, + account_keyspace: &str, + table_id: &str, + generation: uuid::Uuid, + entry: &TtlEntry, +) -> Result { + let rows = crate::cassandra_util::query_rows_quorum( + &engine.session, + &format!( + "SELECT key_hash FROM {account_keyspace}.{TTL_QUEUE_TABLE} \ + WHERE table_id = ? AND generation = ? AND bucket = ? AND shard = ? \ + AND expires_at = ? AND key_hash = ? AND key_data = ?" + ), + cdrs_tokio::query_values!( + table_id, + cdrs_tokio::types::value::Bytes::new(generation.as_bytes().to_vec()), + entry.bucket, + entry.shard, + entry.expires_at, + entry.key_hash.as_str(), + entry.key_data.as_str() + ), + "ttl_entry_registered", + ) + .await?; + Ok(!rows.is_empty()) +} + async fn ensure_ttl_bucket_registration( engine: &CassandraEngine, account_keyspace: &str, @@ -467,7 +501,7 @@ pub(crate) async fn insert_ttl_entry( table_id: &str, generation: uuid::Uuid, entry: &TtlEntry, -) -> Result<(), StorageError> { +) -> Result { let generation_bytes = cdrs_tokio::types::value::Bytes::new(generation.as_bytes().to_vec()); // Restore discoverability before the fast path, then confirm it again after @@ -502,7 +536,7 @@ pub(crate) async fn insert_ttl_entry( if TtlWorkState::parse(state.as_deref())? == TtlWorkState::Pending { ensure_ttl_bucket_registration(engine, account_keyspace, table_id, generation, entry) .await?; - return Ok(()); + return Ok(false); } // Claimed work owns this key and may row-delete it while crashing // before its compensating reconcile. Reporting success here would @@ -548,13 +582,13 @@ pub(crate) async fn insert_ttl_entry( if applied { ensure_ttl_bucket_registration(engine, account_keyspace, table_id, generation, entry) .await?; - return Ok(()); + return Ok(true); } let state: Option = row.get_by_name("state").ok().flatten(); if state.as_deref() == Some("PENDING") { ensure_ttl_bucket_registration(engine, account_keyspace, table_id, generation, entry) .await?; - return Ok(()); + return Ok(false); } Err(StorageError::Transient( "TTL reconciliation deferred by in-flight expiration work".to_owned(), diff --git a/crates/storage-cassandra/src/data/update_item.rs b/crates/storage-cassandra/src/data/update_item.rs index 1cfc072b..f90dd4bb 100644 --- a/crates/storage-cassandra/src/data/update_item.rs +++ b/crates/storage-cassandra/src/data/update_item.rs @@ -100,7 +100,7 @@ impl CassandraEngine { ) .await; let (old_json, version) = match read { - Ok(read) => read, // Another owner holds the row. On a TTL-enabled table that is + Ok(read) => read, // Another owner holds the row. On a TTL-enabled table that is // transient, so treat it like a lost OCC race and re-read. Err(StorageError::TransactionConflict(message)) => { if attempt == OCC_MAX_RETRIES { @@ -511,4 +511,3 @@ impl CassandraEngine { Ok(true) } } - diff --git a/crates/storage-cassandra/src/engine.rs b/crates/storage-cassandra/src/engine.rs index 612ce9d1..8550616e 100644 --- a/crates/storage-cassandra/src/engine.rs +++ b/crates/storage-cassandra/src/engine.rs @@ -47,6 +47,25 @@ pub struct CassandraEngine { /// Default GSI propagation delay in milliseconds (for async indexes) pub(crate) gsi_default_delay_ms: Arc, + /// Short-lived cache of per-table TTL configuration for the write path. + /// + /// Every Put/Update/Delete needs the config, and reading it from the + /// catalog per write is one of the two remaining per-write catalog costs. + /// Entries live [`TTL_CONFIG_CACHE_TTL`]; staleness is bounded and safe in + /// both directions *because the audit exists*: a stale `None` makes a + /// write skip its queue mutation (the audit re-registers the item), and a + /// stale generation enqueues into a retired generation (dead rows; + /// the audit registers the live one). The enable-quiescence window in the + /// ADR must exceed this TTL, and `update_ttl` invalidates locally so the + /// issuing host is coherent immediately. + pub(crate) ttl_config_cache: TtlConfigCache, + + /// Bumped by every TTL-config invalidation. A cache miss snapshots it + /// before the catalog read and declines to repopulate if it moved — so a + /// lifecycle change that lands mid-read cannot be shadowed by the stale + /// value being inserted after the invalidation ran. + pub(crate) ttl_config_cache_epoch: Arc, + /// GSI queue handle for waking workers after async enqueues pub(crate) gsi_queue: Arc, @@ -74,6 +93,16 @@ pub struct CassandraEngine { /// Cap on concurrently in-flight detached TTL claim releases. const TTL_RELEASE_MAX_IN_FLIGHT: usize = 1_024; +/// `(account_id, table_name)` → `(fetched_at, config)`; see the field docs. +pub(crate) type TtlConfigCache = Arc< + std::sync::Mutex< + std::collections::HashMap< + (String, String), + (std::time::Instant, Option), + >, + >, +>; + impl CassandraEngine { /// Create a new Cassandra storage engine. pub async fn new(config: &CassandraStorageConfig, region: &str) -> Result { @@ -88,6 +117,8 @@ impl CassandraEngine { datacenter: config.datacenter.clone(), control_plane_notify: Arc::new(tokio::sync::Notify::new()), gsi_default_delay_ms: Arc::new(std::sync::atomic::AtomicU64::new(10)), // Default 10ms + ttl_config_cache: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())), + ttl_config_cache_epoch: Arc::new(std::sync::atomic::AtomicU64::new(0)), gsi_queue: crate::gsi_queue::GsiQueue::new(), hlc: crate::stream_util::new_shared_hlc( config.instance_id.as_deref().unwrap_or("default"), diff --git a/crates/storage-cassandra/src/lib.rs b/crates/storage-cassandra/src/lib.rs index 4a4d8c74..46af6e98 100644 --- a/crates/storage-cassandra/src/lib.rs +++ b/crates/storage-cassandra/src/lib.rs @@ -123,9 +123,22 @@ impl ServerRuntimeHooks for CassandraRuntimeHooks { ttl_worker::ttl_cleanup_worker(ttl_engine, ttl_metrics, ttl_shutdown).await; }); let ttl_repair_engine = self.engine.clone(); + let ttl_repair_metrics = ctx.metrics.clone(); let ttl_repair_shutdown = ctx.shutdown.clone(); let ttl_repair = tokio::spawn(async move { - ttl_worker::ttl_repair_worker(ttl_repair_engine, ttl_repair_shutdown).await; + ttl_worker::ttl_repair_worker( + ttl_repair_engine, + ttl_repair_metrics, + ttl_repair_shutdown, + ) + .await; + }); + let ttl_audit_engine = self.engine.clone(); + let ttl_audit_metrics = ctx.metrics.clone(); + let ttl_audit_shutdown = ctx.shutdown.clone(); + let ttl_audit = tokio::spawn(async move { + ttl_worker::ttl_audit_worker(ttl_audit_engine, ttl_audit_metrics, ttl_audit_shutdown) + .await }); vec![ @@ -134,6 +147,7 @@ impl ServerRuntimeHooks for CassandraRuntimeHooks { gsi_delay_poller, ttl_cleanup, ttl_repair, + ttl_audit, ] } diff --git a/crates/storage-cassandra/src/management_store/access_keys.rs b/crates/storage-cassandra/src/management_store/access_keys.rs index 56201ad5..a721c4d8 100755 --- a/crates/storage-cassandra/src/management_store/access_keys.rs +++ b/crates/storage-cassandra/src/management_store/access_keys.rs @@ -4,8 +4,8 @@ //! Access key, session, and caller-tag operations for `CassandraCatalogStore`. use crate::catalog_store::CassandraCatalogStore; -use cdrs_tokio::types::blob::Blob; use cdrs_tokio::types::IntoRustByName; +use cdrs_tokio::types::blob::Blob; use extenddb_storage::management_store::{AccessKeyCreated, OpError, OpResult}; impl CassandraCatalogStore { diff --git a/crates/storage-cassandra/src/metadata_engine.rs b/crates/storage-cassandra/src/metadata_engine.rs index 56159eeb..aabf2d66 100755 --- a/crates/storage-cassandra/src/metadata_engine.rs +++ b/crates/storage-cassandra/src/metadata_engine.rs @@ -16,13 +16,60 @@ const TTL_CONTROL_MAX_RETRIES: u32 = 4; const TTL_CONTROL_RETRY_DELAY_MS: u64 = 25; impl CassandraEngine { + /// How long a cached TTL configuration may serve the write path. The + /// staleness is safe in both directions because the audit exists; see the + /// cache field docs on the engine. + const TTL_CONFIG_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(5); + pub(crate) async fn ttl_config_for_table( &self, account_id: &str, table_name: &str, ) -> Result, StorageError> { - self.ttl_config_for_table_at(account_id, table_name, false) - .await + let cache_key = (account_id.to_owned(), table_name.to_owned()); + if let Some((fetched_at, config)) = self + .ttl_config_cache + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .get(&cache_key) + && fetched_at.elapsed() < Self::TTL_CONFIG_CACHE_TTL + { + return Ok(config.clone()); + } + let epoch_before = self + .ttl_config_cache_epoch + .load(std::sync::atomic::Ordering::Acquire); + let config = self + .ttl_config_for_table_at(account_id, table_name, false) + .await?; + let mut cache = self + .ttl_config_cache + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + // Opportunistic pruning keeps the map bounded by the live table count. + cache.retain(|_, (fetched_at, _)| fetched_at.elapsed() < Self::TTL_CONFIG_CACHE_TTL); + // An invalidation that ran while we were reading means this value may + // predate a durable lifecycle change: serve it once, do not cache it. + if self + .ttl_config_cache_epoch + .load(std::sync::atomic::Ordering::Acquire) + == epoch_before + { + cache.insert(cache_key, (std::time::Instant::now(), config.clone())); + } + Ok(config) + } + + /// Drop the cached configuration for one table, so the host that issued a + /// lifecycle change observes it immediately. Other hosts converge within + /// the cache TTL. + pub(crate) fn invalidate_ttl_config_cache(&self, account_id: &str, table_name: &str) { + self.ttl_config_cache_epoch + .fetch_add(1, std::sync::atomic::Ordering::AcqRel); + self.ttl_config_cache + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remove(&(account_id.to_owned(), table_name.to_owned())); } /// Read TTL configuration at `LOCAL_QUORUM` for an authoritative absence @@ -424,21 +471,6 @@ impl CassandraEngine { Ok(()) } - pub(crate) async fn reconcile_ttl_item( - &self, - key_info: &extenddb_core::types::TableKeyInfo, - item: &Item, - ) -> Result<(), StorageError> { - let Some(config) = self - .ttl_config_for_table(&key_info.account_id, &key_info.table_name) - .await? - else { - return Ok(()); - }; - self.reconcile_ttl_item_with_config(key_info, item, &config) - .await - } - /// [`Self::reconcile_ttl_item`] for callers that already hold the table's /// TTL configuration, so reconciling does not re-read the catalog. The /// worker calls this once per processed row; the caller is responsible for @@ -462,13 +494,22 @@ impl CassandraEngine { &entry, ) .await + .map(|_| ()) } - pub(crate) async fn reconcile_ttl_item_by_table_id( + /// [`Self::reconcile_ttl_transition`] resolved from a table id, for the + /// transaction recovery path, whose ledger records table ids rather than + /// names. Images arrive as the ledger's JSON strings; `None` old means + /// insert-only (pre-field ledgers), `None` new means a delete. + pub(crate) async fn reconcile_ttl_transition_by_table_id( &self, table_id: &str, - item_data: &str, + old_item_data: Option<&str>, + new_item_data: Option<&str>, ) -> Result<(), StorageError> { + if old_item_data.is_none() && new_item_data.is_none() { + return Ok(()); + } let query = format!( "SELECT account_id, table_name FROM {}.tables WHERE table_id = ?", self.catalog_keyspace() @@ -477,21 +518,141 @@ impl CassandraEngine { &self.session, &query, cdrs_tokio::query_values!(table_id), - "reconcile_ttl_item_by_table_id", + "reconcile_ttl_transition_by_table_id", ) .await?; let Some(row) = rows.first() else { return Ok(()); }; - let account_id: String = - crate::cassandra_util::get_column(row, "account_id", "reconcile_ttl_item_by_table_id")?; - let table_name: String = - crate::cassandra_util::get_column(row, "table_name", "reconcile_ttl_item_by_table_id")?; + let account_id: String = crate::cassandra_util::get_column( + row, + "account_id", + "reconcile_ttl_transition_by_table_id", + )?; + let table_name: String = crate::cassandra_util::get_column( + row, + "table_name", + "reconcile_ttl_transition_by_table_id", + )?; let key_info = self.fetch_table_key_info(&account_id, &table_name).await?; - let item: Item = serde_json::from_str(item_data).map_err(|error| { - StorageError::Internal(format!("Parse recovered TTL item: {error}")) - })?; - self.reconcile_ttl_item(&key_info, &item).await + let parse = |data: Option<&str>| -> Result, StorageError> { + data.map(|data| { + serde_json::from_str(data).map_err(|error| { + StorageError::Internal(format!("Parse recovered TTL item: {error}")) + }) + }) + .transpose() + }; + let old = parse(old_item_data)?; + let new = parse(new_item_data)?; + self.reconcile_ttl_transition(&key_info, old.as_ref(), new.as_ref()) + .await + } + + /// One audit pass over a single ready table: re-register every item whose + /// queue entry is missing. Returns `Some(repaired_count)` when the pass + /// completed, `None` when it stood aside because the table's TTL + /// configuration moved mid-scan. + /// + /// Deliberately LEASELESS. An earlier revision held the table's control + /// lease for the whole scan, which suspended expiration, lifecycle + /// changes, and GSI creation for the scan's duration — hours on a large + /// table, and "the audit is rare" does not bound how long each one lasts. + /// The lease bought nothing the protocol does not already provide: the + /// audit only performs idempotent conditional registrations that defer to + /// claimed work, so it is safe under concurrent sweeps and backfills, and + /// a lifecycle change mid-scan is caught by the per-page quorum config + /// check (a straggler insert into a retired generation is inert dead data + /// in an unswept partition, the same accepted class as a stale-config + /// writer). Two hosts auditing the same table duplicate idempotent reads; + /// at the audit cadence that is cheaper than mutual exclusion. + /// + /// The healthy-item cost is one quorum read (see + /// [`crate::data::ttl::ttl_entry_registered`]); the full write path of + /// `insert_ttl_entry` runs only for items whose registration is actually + /// missing. Pages are paced so a large table's audit is a slow background + /// murmur rather than a read burst. + pub(crate) async fn audit_ttl_queue_for_table( + &self, + account_id: &str, + table_name: &str, + ttl_attribute: &str, + metrics: &extenddb_core::metrics::MetricsCollector, + ) -> Result, StorageError> { + /// Pause between scan pages: bounds steady-state audit IO pressure. + const AUDIT_PAGE_PAUSE: std::time::Duration = std::time::Duration::from_millis(100); + + let Some(config) = self + .ttl_config_for_table_quorum(account_id, table_name) + .await? + else { + return Ok(None); + }; + if config.attribute != ttl_attribute { + return Ok(None); + } + let key_info = self.fetch_table_key_info(account_id, table_name).await?; + let account_keyspace = self.account_keyspace(account_id); + let mut repaired = 0usize; + let mut start_key = None; + loop { + // Per page, at quorum and uncached: a lifecycle change mid-scan + // ends the pass rather than auditing against a retired generation. + if self + .ttl_config_for_table_quorum(account_id, table_name) + .await? + != Some(config.clone()) + { + return Ok(None); + } + let (items, next_key) = self + .scan_impl(&key_info, Some(1_000), start_key.as_ref(), None, None, None) + .await?; + for item in items { + if let Some(entry) = + crate::data::ttl::entry_for_item(&key_info, &item, &config.attribute)? + { + // Cheap verification first; the repair path only on a miss. + if crate::data::ttl::ttl_entry_registered( + self, + &account_keyspace, + &key_info.table_id, + config.generation, + &entry, + ) + .await? + { + continue; + } + match crate::data::ttl::insert_ttl_entry( + self, + &account_keyspace, + &key_info.table_id, + config.generation, + &entry, + ) + .await + { + Ok(true) => { + repaired += 1; + metrics.record_ttl_audit_repair(table_name); + } + Ok(false) => {} + // Claimed work owns this key right now; skip rather + // than fail the pass. The item has an entry (it is + // being expired), so there is nothing to repair. + Err(StorageError::Transient(_)) => {} + Err(error) => return Err(error), + } + } + } + match next_key { + Some(key) => start_key = Some(key), + None => break, + } + tokio::time::sleep(AUDIT_PAGE_PAUSE).await; + } + Ok(Some(repaired)) } /// Scan the table and register an expiration entry for every item that @@ -800,6 +961,9 @@ impl MetadataEngine for CassandraEngine { Err(StorageError::TableNotFound(table_name)) }; } + // The lifecycle change is durable; the issuing host must not keep + // serving the old configuration from its cache. + self.invalidate_ttl_config_cache(&account_id, &table_name); if !enabled { self.complete_ttl_cleanup(&account_id, &table_name, &table_id, cleanup_generation) .await?; diff --git a/crates/storage-cassandra/src/stream_util.rs b/crates/storage-cassandra/src/stream_util.rs index a24af64b..b1f0b473 100644 --- a/crates/storage-cassandra/src/stream_util.rs +++ b/crates/storage-cassandra/src/stream_util.rs @@ -139,6 +139,7 @@ pub struct StreamRecordIdentity { /// - `capture` — stream view type and region from the caller /// - `hlc` — shared HLC for sequence number generation /// - `retention_seconds` — Cassandra TTL for the stream record +#[allow(clippy::too_many_arguments)] pub fn stream_record_statement( account_keyspace: &str, table_id: &str, diff --git a/crates/storage-cassandra/src/ttl_worker.rs b/crates/storage-cassandra/src/ttl_worker.rs index 1edfbecb..965809b5 100644 --- a/crates/storage-cassandra/src/ttl_worker.rs +++ b/crates/storage-cassandra/src/ttl_worker.rs @@ -3,6 +3,7 @@ //! Background processing for DynamoDB TTL expiration. +use futures::StreamExt; use std::sync::Arc; use std::time::Duration; @@ -13,10 +14,33 @@ use extenddb_storage::{CancellationToken, MetadataEngine, TableEngine, sleep_or_ use crate::CassandraEngine; const SCAN_INTERVAL: Duration = Duration::from_secs(60); -const BATCH_SIZE: usize = 100; +/// Queue rows one sweep cycle will process per table. The queue is 64-way +/// sharded into independent partitions and every row transition is +/// conditional on its exact work id, so rows are processed concurrently +/// (see [`TTL_SWEEP_CONCURRENCY`]) and the batch can be sized by throughput +/// need rather than by serial round-trip time. Raising this above the other +/// backends' 100 is deliberate: their sweeps re-scan an indexed table, ours +/// drains a queue, and the per-table lease means the work happens exactly +/// once. +const BATCH_SIZE: usize = 1_000; +/// Concurrent in-flight rows per sweep. Each row costs a handful of quorum +/// reads and LWTs; concurrency hides that latency without changing the +/// protocol, because rows in different queue partitions are independent and +/// two entries for the same item key resolve through the base-row claim (the +/// loser leaves its row for the next cycle). +const TTL_SWEEP_CONCURRENCY: usize = 16; +/// Rows processed between lease renewals and config re-checks. +const TTL_SWEEP_RENEW_EVERY: usize = 64; /// Rows drained per cleanup pass for a retired generation. Cleanup is retried /// every cycle until the generation is empty, so this only bounds one pass. const DRAIN_BATCH_SIZE: usize = 100; +/// Interval between full audits of a TTL-enabled table's expiration queue. +/// +/// The audit is a paged base-table scan, so it is priced like the enable +/// backfill and run rarely. Six hours bounds how long a lost registration can +/// go unnoticed while keeping steady-state cost at four scans per table per +/// day. +const AUDIT_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60); pub(crate) async fn ttl_cleanup_worker( storage: Arc, @@ -38,10 +62,72 @@ pub(crate) async fn ttl_cleanup_worker( /// Unresolved markers are deliberately non-dischargeable and can accumulate /// after repeated process crashes. Keeping this pass on its own task prevents /// that operational debt from delaying the bounded expiration worker. -pub(crate) async fn ttl_repair_worker(storage: Arc, token: CancellationToken) { +pub(crate) async fn ttl_repair_worker( + storage: Arc, + metrics: Arc, + token: CancellationToken, +) { while sleep_or_shutdown(&token, SCAN_INTERVAL).await { - if let Err(error) = reconcile_inflight_repairs_once(&storage).await { - tracing::warn!("TTL worker: inflight repair reconciliation failed: {error}"); + match reconcile_inflight_repairs_once(&storage).await { + Ok((observed_markers, _resolved)) => { + metrics.record_ttl_repair_markers(observed_markers as f64); + } + Err(error) => { + tracing::warn!("TTL worker: inflight repair reconciliation failed: {error}"); + } + } + } +} + +/// Periodically rescan every ready TTL table and re-register any item whose +/// queue entry is missing. +/// +/// This is the self-healing pass. Everything else in the TTL design tries to +/// prevent a registration from being lost — the in-batch queue mutation, the +/// write outbox, the destroy handshake — but a queue entry can be created only +/// by a write or by the enable backfill, so any loss that slips through every +/// guard is *permanent* for an item that is never written again, and nothing +/// alarms. The audit converts that class from permanent to +/// eventually-corrected, and its repair metric is the drift signal: a repair +/// means some loss path fired, and sustained repairs mean one is firing +/// repeatedly. +pub(crate) async fn ttl_audit_worker( + storage: Arc, + metrics: Arc, + token: CancellationToken, +) { + while sleep_or_shutdown(&token, AUDIT_INTERVAL).await { + audit_ttl_queues_once(&storage, &metrics).await; + } +} + +/// One audit pass over every ready TTL table. Public for direct backend +/// integration tests and manual operational triggering. +pub async fn audit_ttl_queues_once(storage: &CassandraEngine, metrics: &MetricsCollector) { + let tables = match MetadataEngine::all_tables_with_ttl_index_ready(storage).await { + Ok(tables) => tables, + Err(error) => { + tracing::warn!("TTL audit: failed to list tables: {error}"); + return; + } + }; + for (account_id, table_name, attribute) in &tables { + match storage + .audit_ttl_queue_for_table(account_id, table_name, attribute, metrics) + .await + { + Ok(Some(repaired)) if repaired > 0 => { + tracing::warn!( + account_id, + table = %table_name, + repaired, + "TTL audit repaired missing queue registrations; some loss path fired" + ); + } + Ok(_) => {} + Err(error) => { + tracing::warn!("TTL audit: {table_name} failed: {error}"); + } } } } @@ -56,6 +142,36 @@ pub async fn reconcile_pending_once( reconcile_pending_older_than(storage, limit, 0).await } +/// Keyspaces the background TTL passes should visit this cycle. +/// +/// Steady state visits only accounts that have a TTL-enabled table: on a +/// deployment with many accounts and few TTL users, scanning every keyspace's +/// 64 outbox partitions each minute is almost entirely empty reads. Every +/// [`FULL_SCAN_EVERY`]th cycle widens to all account keyspaces, so rows left +/// behind for since-disabled or since-deleted tables are still eventually +/// drained rather than lingering forever. +async fn ttl_scan_keyspaces( + storage: &CassandraEngine, + cycle: u64, +) -> Result, StorageError> { + const FULL_SCAN_EVERY: u64 = 10; + if cycle.is_multiple_of(FULL_SCAN_EVERY) { + return crate::workers::list_account_keyspaces(storage).await; + } + let tables = MetadataEngine::all_tables_with_ttl(storage).await?; + let mut keyspaces: Vec = tables + .iter() + .map(|(account_id, _, _)| storage.account_keyspace(account_id)) + .collect(); + keyspaces.sort_unstable(); + keyspaces.dedup(); + Ok(keyspaces) +} + +/// Monotonic pass counter shared by the background TTL passes, driving the +/// periodic widening in [`ttl_scan_keyspaces`]. +static TTL_SCAN_CYCLE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + /// [`reconcile_pending_once`] with an explicit minimum record age in seconds. /// A negative age is useful in tests to include a just-inserted coordinator /// timeuuid. Production no longer needs an age fence: ambiguous destroys retain @@ -71,7 +187,12 @@ pub async fn reconcile_pending_older_than( if limit == 0 { return Ok(0); } - let keyspaces = crate::workers::list_account_keyspaces(storage).await?; + let cycle = if min_age_seconds < 0 { + 0 + } else { + TTL_SCAN_CYCLE.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + }; + let keyspaces = ttl_scan_keyspaces(storage, cycle).await?; let slots = crate::data::ttl::TTL_SHARDS as usize; // A global first-N scan can starve later keyspaces forever under sustained // writes. Treat `limit` as a per-account soft bound and give every one of @@ -251,9 +372,10 @@ const OUTBOX_MAX_PAGES_PER_PARTITION: usize = 8; /// represents operational debt and is logged for visibility. pub async fn reconcile_inflight_repairs_once( storage: &CassandraEngine, -) -> Result { +) -> Result<(usize, usize), StorageError> { use cdrs_tokio::types::IntoRustByName; + let mut observed = 0usize; let mut processed = 0usize; for keyspace in crate::workers::list_account_keyspaces(storage).await? { let registry = match crate::cassandra_util::query_rows_quorum( @@ -378,6 +500,7 @@ pub async fn reconcile_inflight_repairs_once( ); } for row in rows { + observed += 1; let parsed = (|| -> Result<_, StorageError> { let repair_id: uuid::Uuid = row.get_r_by_name("repair_id").map_err(|error| { @@ -480,7 +603,7 @@ pub async fn reconcile_inflight_repairs_once( } } } - Ok(processed) + Ok((observed, processed)) } /// Finish or abort work that was already claimed when a TTL generation was @@ -692,7 +815,6 @@ async fn process_ttl_work_row( storage: &CassandraEngine, key_info: &extenddb_core::types::TableKeyInfo, config: &crate::data::ttl::TtlConfig, - sweep_owner: uuid::Uuid, mut work: crate::data::ttl::TtlWorkRow, ) -> Result { use crate::data::ttl::{TtlStreamPlan, TtlWorkData, TtlWorkState}; @@ -879,19 +1001,22 @@ async fn process_ttl_work_row( // and image. A stale replica cannot satisfy either LWT. If this task // was suspended past either lease, it walks away before index or stream // mutations become visible. - let lease_current = match storage - .renew_ttl_sweep_lease( - &key_info.account_id, - &key_info.table_name, - config, - sweep_owner, - ) + // Last lifecycle gate before irreversible effects: a quorum config + // read, deliberately NOT a lease renewal. Sixteen concurrent rows all + // renewing would contend on one catalog partition's Paxos and undo + // the concurrency this sweep exists for; the wave loop already renews + // the lease between waves, and the per-row fence that matters is the + // seal below, whose Paxos is per item partition. This read only needs + // to detect that TTL was disabled or regenerated while this row was + // in flight. + let lifecycle_current = match storage + .ttl_config_for_table_quorum(&key_info.account_id, &key_info.table_name) .await { - Ok(current) => current, + Ok(current) => current.as_ref() == Some(config), Err(error) => return Err(error), }; - if !lease_current { + if !lifecycle_current { return Ok(false); } let owner_current = match storage @@ -1119,7 +1244,10 @@ pub async fn sweep_once(storage: &CassandraEngine, metrics: &MetricsCollector) { ) .await?; let mut deleted = 0usize; - for row in work { + for wave in work.chunks(TTL_SWEEP_RENEW_EVERY) { + // One renewal and config check per wave instead of per row; + // the pre-effects gate inside process_ttl_work_row still + // re-verifies the config before anything irreversible. if storage.ttl_config_for_table(account_id, table_name).await? != Some(config.clone()) || !storage @@ -1128,14 +1256,36 @@ pub async fn sweep_once(storage: &CassandraEngine, metrics: &MetricsCollector) { { break; } - let expires_at = row.entry.expires_at; - if process_ttl_work_row(storage, &key_info, &config, owner, row).await? { - deleted += 1; - metrics.record_ttl_deletion(table_name); - metrics.record_ttl_staleness( - table_name, - now_epoch.saturating_sub(expires_at) as f64, - ); + let outcomes = futures::stream::iter(wave.iter().cloned().map(|row| { + let key_info = &key_info; + let config = &config; + async move { + let expires_at = row.entry.expires_at; + let outcome = process_ttl_work_row(storage, key_info, config, row).await; + (expires_at, outcome) + } + })) + .buffer_unordered(TTL_SWEEP_CONCURRENCY) + .collect::>() + .await; + for (expires_at, outcome) in outcomes { + match outcome { + Ok(true) => { + deleted += 1; + metrics.record_ttl_deletion(table_name); + metrics.record_ttl_staleness( + table_name, + now_epoch.saturating_sub(expires_at) as f64, + ); + } + Ok(false) => {} + // One row's failure must not abandon the wave or the + // table: its durable queue state drives its own retry. + Err(error) => { + metrics.record_ttl_sweep_row_error(table_name); + tracing::warn!("TTL worker: row failed in {table_name}: {error}"); + } + } } } Ok(deleted) diff --git a/crates/storage-cassandra/src/update_table.rs b/crates/storage-cassandra/src/update_table.rs index d24ec250..35c0677e 100644 --- a/crates/storage-cassandra/src/update_table.rs +++ b/crates/storage-cassandra/src/update_table.rs @@ -380,8 +380,11 @@ impl CassandraEngine { .gsi_default_delay_ms .load(std::sync::atomic::Ordering::Relaxed) != 0; + // Quorum and uncached: the control-lease decision below fences GSI + // creation against TTL lifecycle changes, and a cached stale None + // would skip that fence in exactly the window it exists for. let ttl_enabled = self - .ttl_config_for_table(account_id, &input.table_name) + .ttl_config_for_table_quorum(account_id, &input.table_name) .await? .is_some(); let ttl_control_owner = if !gsi_creates.is_empty() && (creating_async_gsi || ttl_enabled) { diff --git a/crates/storage-cassandra/tests/common/mod.rs b/crates/storage-cassandra/tests/common/mod.rs index a116c828..2a4da01d 100644 --- a/crates/storage-cassandra/tests/common/mod.rs +++ b/crates/storage-cassandra/tests/common/mod.rs @@ -776,3 +776,29 @@ pub async fn put_item_then_lock( .expect("Setting prepared_txn_id should succeed"); } } + +/// True when the suite should be skipped because no Cassandra is reachable. +/// +/// The workspace `cargo test` job in test.yml runs with no services, so these +/// suites probe 127.0.0.1:9042 and skip themselves when nothing is listening. +/// The dedicated integration-cassandra workflow provides a service container +/// and sets `EXTENDDB_TEST_CASSANDRA=required`, under which this never skips: +/// if Cassandra is down there, setup fails loudly rather than the suite +/// silently passing with zero tests executed (the failure mode integration.yml +/// documents for batch_transact_authz). +pub fn skip_without_cassandra() -> bool { + if std::env::var("EXTENDDB_TEST_CASSANDRA").as_deref() == Ok("required") { + return false; + } + let reachable = std::net::TcpStream::connect_timeout( + &std::net::SocketAddr::from(([127, 0, 0, 1], 9042)), + std::time::Duration::from_secs(2), + ) + .is_ok(); + if !reachable { + eprintln!( + "skipping: no Cassandra at 127.0.0.1:9042 (set EXTENDDB_TEST_CASSANDRA=required to fail instead)" + ); + } + !reachable +} diff --git a/crates/storage-cassandra/tests/metadata_integration.rs b/crates/storage-cassandra/tests/metadata_integration.rs index f847e681..9a9b5877 100644 --- a/crates/storage-cassandra/tests/metadata_integration.rs +++ b/crates/storage-cassandra/tests/metadata_integration.rs @@ -31,6 +31,9 @@ fn tag(key: &str, value: &str) -> Tag { /// the cost of testing these as four separate cases. #[tokio::test] async fn test_resource_tag_lifecycle() { + if crate::helpers::skip_without_cassandra() { + return; + } let engine = setup_engine().await; let arn = format!( "arn:aws:dynamodb:us-east-1:123456789012:table/test-{}", diff --git a/crates/storage-cassandra/tests/ttl_bench.rs b/crates/storage-cassandra/tests/ttl_bench.rs new file mode 100644 index 00000000..36c4b312 --- /dev/null +++ b/crates/storage-cassandra/tests/ttl_bench.rs @@ -0,0 +1,203 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! TTL micro-benchmarks against a live Cassandra. +//! +//! Ignored by default: numbers from a single-node container are only +//! meaningful *relative to each other* on the same machine, so these run +//! manually around a change, never in CI: +//! +//! ```text +//! cargo test -p extenddb-storage-cassandra --test ttl_bench --release -- \ +//! --ignored --test-threads=1 --nocapture +//! ``` +//! +//! Reported: put latency on a TTL-enabled vs plain table (the write-path tax), +//! and expiration drain throughput (the sweep ceiling). + +#[path = "common/mod.rs"] +mod helpers; + +use extenddb_core::types::{AttributeValue, Item}; +use extenddb_storage::{DataEngine, MetadataEngine}; + +use crate::helpers::setup_engine; + +const PUT_SAMPLES: usize = 200; +const EXPIRED_ITEMS: usize = 500; + +async fn activate_tables(engine: &extenddb_storage_cassandra::CassandraEngine) { + tokio::time::sleep(std::time::Duration::from_millis(350)).await; + engine + .process_control_plane_transitions() + .await + .expect("process table transitions"); +} + +fn percentiles(mut samples: Vec) -> (u128, u128, u128) { + samples.sort_unstable(); + let pick = |q: f64| samples[((samples.len() - 1) as f64 * q) as usize]; + (pick(0.50), pick(0.90), pick(0.99)) +} + +async fn bench_puts( + engine: &extenddb_storage_cassandra::CassandraEngine, + key_info: &extenddb_core::types::TableKeyInfo, + with_ttl_attribute: bool, + label: &str, +) { + // Each case writes its own key range (the label disambiguates), so a case + // never measures overwriting another case's rows — a TTL-table put over a + // row whose previous image carried a timestamp pays entry-retirement work + // a fresh insert does not. + let future = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + + 86_400; + let mut samples = Vec::with_capacity(PUT_SAMPLES); + for index in 0..PUT_SAMPLES { + let mut item = Item::new(); + item.insert( + "id".to_owned(), + AttributeValue::S(format!("bench-{label}-{index}")), + ); + item.insert( + "value".to_owned(), + AttributeValue::S("x".repeat(256).to_owned()), + ); + if with_ttl_attribute { + item.insert( + "expires_at".to_owned(), + AttributeValue::N(future.to_string()), + ); + } + let started = std::time::Instant::now(); + engine + .put_item(key_info, item, false, None, &Default::default(), None) + .await + .expect("bench put"); + samples.push(started.elapsed().as_micros()); + } + let (p50, p90, p99) = percentiles(samples); + eprintln!("BENCH {label}: n={PUT_SAMPLES} p50={p50}us p90={p90}us p99={p99}us"); +} + +/// Put latency: plain table vs TTL-enabled table (with and without the item +/// actually carrying a timestamp — the claim is taken either way). +#[tokio::test] +#[ignore = "manual benchmark; requires live Cassandra and a quiet machine"] +async fn bench_put_latency_ttl_vs_plain() { + let engine = setup_engine().await; + + let plain = crate::helpers::TestTable::new(&engine, "BenchPlain", false).await; + let ttl = crate::helpers::TestTable::new(&engine, "BenchTtl", false).await; + activate_tables(&engine).await; + engine + .update_ttl( + &ttl.key_info.account_id, + &ttl.key_info.table_name, + "expires_at", + true, + ) + .await + .unwrap(); + engine + .create_ttl_index( + &ttl.key_info.account_id, + &ttl.key_info.table_name, + "expires_at", + ) + .await + .unwrap(); + + bench_puts(&engine, &plain.key_info, false, "put/plain-table").await; + bench_puts(&engine, &ttl.key_info, true, "put/ttl-table+timestamp").await; + bench_puts(&engine, &ttl.key_info, false, "put/ttl-table-no-timestamp").await; +} + +/// Expiration drain throughput: how fast the sweep clears a backlog. +#[tokio::test] +#[ignore = "manual benchmark; requires live Cassandra and a quiet machine"] +async fn bench_expiration_drain_throughput() { + use extenddb_core::metrics::MetricsCollector; + + let engine = setup_engine().await; + let table = crate::helpers::TestTable::new(&engine, "BenchDrain", false).await; + activate_tables(&engine).await; + engine + .update_ttl( + &table.key_info.account_id, + &table.key_info.table_name, + "expires_at", + true, + ) + .await + .unwrap(); + engine + .create_ttl_index( + &table.key_info.account_id, + &table.key_info.table_name, + "expires_at", + ) + .await + .unwrap(); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + for index in 0..EXPIRED_ITEMS { + let mut item = Item::new(); + item.insert("id".to_owned(), AttributeValue::S(format!("drain-{index}"))); + item.insert( + "expires_at".to_owned(), + AttributeValue::N((now - 60).to_string()), + ); + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("seed put"); + } + + let metrics = MetricsCollector::new(); + async fn remaining( + engine: &extenddb_storage_cassandra::CassandraEngine, + key_info: &extenddb_core::types::TableKeyInfo, + ) -> usize { + let mut left = 0usize; + for index in 0..EXPIRED_ITEMS { + let mut key = Item::new(); + key.insert("id".to_owned(), AttributeValue::S(format!("drain-{index}"))); + if engine.get_item(key_info, &key).await.unwrap().is_some() { + left += 1; + } + } + left + } + let started = std::time::Instant::now(); + let mut sweeps = 0usize; + loop { + extenddb_storage_cassandra::ttl_worker::sweep_once(&engine, &metrics).await; + sweeps += 1; + // Full count, not a last-item probe: row failures are confined, so the + // last item can be gone while others remain. + if remaining(&engine, &table.key_info).await == 0 { + break; + } + assert!(sweeps <= 50, "drain did not complete within 50 sweeps"); + } + let elapsed = started.elapsed(); + eprintln!( + "BENCH drain: {EXPIRED_ITEMS} items in {sweeps} sweep passes, {:.1}s total, {:.0} items/sec sweep-time (verified all {EXPIRED_ITEMS} gone)", + elapsed.as_secs_f64(), + EXPIRED_ITEMS as f64 / elapsed.as_secs_f64() + ); +} diff --git a/crates/storage-cassandra/tests/ttl_integration.rs b/crates/storage-cassandra/tests/ttl_integration.rs index 58d94ebe..03245357 100644 --- a/crates/storage-cassandra/tests/ttl_integration.rs +++ b/crates/storage-cassandra/tests/ttl_integration.rs @@ -133,6 +133,9 @@ async fn ttl_inflight_repair_count( #[tokio::test] async fn test_ttl_metadata_enable_disable_and_listing() { + if crate::helpers::skip_without_cassandra() { + return; + } use extenddb_core::types::{AttributeValue, TimeToLiveStatus}; use extenddb_storage::DataEngine; @@ -271,7 +274,11 @@ async fn test_ttl_metadata_enable_disable_and_listing() { .await .expect("disable TTL metadata"); engine - .drop_ttl_index(&table.key_info.account_id, &table.key_info.table_name, "expires_at") + .drop_ttl_index( + &table.key_info.account_id, + &table.key_info.table_name, + "expires_at", + ) .await .expect("clear TTL queue"); assert_eq!( @@ -320,6 +327,9 @@ async fn test_ttl_metadata_enable_disable_and_listing() { #[tokio::test] async fn test_ttl_queue_sweep_and_stale_candidate_protection() { + if crate::helpers::skip_without_cassandra() { + return; + } use std::sync::Arc; use extenddb_core::metrics::MetricsCollector; @@ -451,6 +461,9 @@ async fn test_ttl_queue_sweep_and_stale_candidate_protection() { #[tokio::test] async fn test_ttl_sweep_emits_service_remove_stream_record() { + if crate::helpers::skip_without_cassandra() { + return; + } use std::sync::Arc; use cdrs_tokio::types::IntoRustByName; @@ -622,6 +635,9 @@ async fn test_ttl_sweep_emits_service_remove_stream_record() { #[tokio::test] async fn test_transactional_write_reconciles_ttl_queue() { + if crate::helpers::skip_without_cassandra() { + return; + } use extenddb_core::expression::ExpressionMaps; use extenddb_core::metrics::MetricsCollector; use extenddb_core::types::{AttributeValue, Item, ReturnValuesOnConditionCheckFailure}; @@ -698,6 +714,9 @@ async fn test_transactional_write_reconciles_ttl_queue() { #[tokio::test] async fn test_ttl_claim_serializes_delayed_writer() { + if crate::helpers::skip_without_cassandra() { + return; + } use extenddb_core::types::{AttributeValue, Item}; use extenddb_storage::DataEngine; use extenddb_storage::error::StorageError; @@ -849,6 +868,9 @@ async fn test_ttl_claim_serializes_delayed_writer() { /// against a freshly read image rather than surfaced. #[tokio::test] async fn test_concurrent_ordinary_writes_on_ttl_table_both_succeed() { + if crate::helpers::skip_without_cassandra() { + return; + } use extenddb_core::types::{AttributeValue, Item}; use extenddb_storage::DataEngine; @@ -1156,6 +1178,9 @@ async fn ttl_table_with_expired_item( /// the deletion rather than completing it. #[tokio::test] async fn test_disable_drains_claimed_work_and_releases_its_claim() { + if crate::helpers::skip_without_cassandra() { + return; + } use extenddb_storage::DataEngine; let engine = setup_engine().await; @@ -1219,6 +1244,9 @@ async fn test_disable_drains_claimed_work_and_releases_its_claim() { /// treating it like `CLAIMED` would permit a crash-after-effects inconsistency. #[tokio::test] async fn test_disable_completes_effects_applying_work() { + if crate::helpers::skip_without_cassandra() { + return; + } use extenddb_storage::DataEngine; let engine = setup_engine().await; @@ -1269,6 +1297,9 @@ async fn test_disable_completes_effects_applying_work() { /// random page could starve markers indefinitely. #[tokio::test] async fn test_inflight_marker_traversal_covers_beyond_one_page() { + if crate::helpers::skip_without_cassandra() { + return; + } let engine = setup_engine().await; let table = crate::helpers::TestTable::new(&engine, "TtlMarkerTraversal", false).await; activate_tables(&engine).await; @@ -1359,6 +1390,9 @@ async fn test_inflight_marker_traversal_covers_beyond_one_page() { /// records behind it: the pass pages past the failing prefix within one cycle. #[tokio::test] async fn test_outbox_pages_past_poison_prefix() { + if crate::helpers::skip_without_cassandra() { + return; + } use extenddb_core::types::{AttributeValue, Item}; use extenddb_storage::DataEngine; @@ -1503,6 +1537,9 @@ fn crc32fast_hash(value: &str) -> u32 { /// generation) make TTL permanently un-enableable on the table. #[tokio::test] async fn test_effects_applying_with_changed_image_completes_not_wedges() { + if crate::helpers::skip_without_cassandra() { + return; + } use extenddb_core::metrics::MetricsCollector; use extenddb_core::types::{AttributeValue, Item}; use extenddb_storage::DataEngine; @@ -1612,6 +1649,9 @@ async fn test_effects_applying_with_changed_image_completes_not_wedges() { /// live item is left invisible to its own index. #[tokio::test] async fn test_effects_applying_recovery_restores_shared_key_gsi_row() { + if crate::helpers::skip_without_cassandra() { + return; + } use extenddb_core::expression::{Expr, ExpressionMaps, KeyCondition, PathElement}; use extenddb_core::metrics::MetricsCollector; use extenddb_core::types::AttributeValue; @@ -1793,6 +1833,9 @@ async fn test_effects_applying_recovery_restores_shared_key_gsi_row() { /// delete instead. #[tokio::test] async fn test_disable_completes_effects_applied_work() { + if crate::helpers::skip_without_cassandra() { + return; + } use extenddb_storage::DataEngine; let engine = setup_engine().await; @@ -1845,6 +1888,9 @@ async fn test_disable_completes_effects_applied_work() { /// the age of the table. #[tokio::test] async fn test_drained_past_bucket_registration_is_retired() { + if crate::helpers::skip_without_cassandra() { + return; + } use extenddb_core::metrics::MetricsCollector; let engine = setup_engine().await; @@ -1897,6 +1943,9 @@ async fn test_drained_past_bucket_registration_is_retired() { #[tokio::test] async fn test_ttl_sweep_removes_synchronous_gsi_entry() { + if crate::helpers::skip_without_cassandra() { + return; + } use extenddb_core::expression::{Expr, ExpressionMaps, KeyCondition, PathElement}; use extenddb_core::metrics::MetricsCollector; use extenddb_core::types::AttributeValue; @@ -2011,6 +2060,9 @@ async fn test_ttl_sweep_removes_synchronous_gsi_entry() { #[tokio::test] async fn test_ttl_enable_rejects_asynchronous_gsi() { + if crate::helpers::skip_without_cassandra() { + return; + } use extenddb_storage::error::StorageError; let engine = setup_engine().await; @@ -2045,6 +2097,9 @@ async fn test_ttl_enable_rejects_asynchronous_gsi() { #[tokio::test] async fn test_ttl_reconciles_same_expiry_after_queue_only_claim() { + if crate::helpers::skip_without_cassandra() { + return; + } use cdrs_tokio::types::IntoRustByName; use extenddb_core::metrics::MetricsCollector; use extenddb_core::types::{AttributeValue, Item}; @@ -2312,6 +2367,9 @@ async fn test_ttl_reconciles_same_expiry_after_queue_only_claim() { #[tokio::test] async fn test_ttl_update_recreates_logically_absent_item() { + if crate::helpers::skip_without_cassandra() { + return; + } use extenddb_core::expression::{Expr, ExpressionMaps, PathElement, UpdateAction}; use extenddb_core::types::{AttributeValue, Item}; use extenddb_storage::DataEngine; @@ -2438,6 +2496,9 @@ async fn test_ttl_update_recreates_logically_absent_item() { #[tokio::test] async fn test_conditional_put_recreates_metadata_only_row() { + if crate::helpers::skip_without_cassandra() { + return; + } use extenddb_core::expression::{Expr, PathElement}; use extenddb_core::types::{AttributeValue, Item}; use extenddb_storage::DataEngine; @@ -2512,6 +2573,9 @@ async fn test_conditional_put_recreates_metadata_only_row() { #[tokio::test] async fn test_outbox_restores_bucket_for_existing_pending_row() { + if crate::helpers::skip_without_cassandra() { + return; + } use cdrs_tokio::types::IntoRustByName; use extenddb_core::types::{AttributeValue, Item}; use extenddb_storage::DataEngine; @@ -2646,6 +2710,9 @@ async fn test_outbox_restores_bucket_for_existing_pending_row() { #[tokio::test] async fn test_inflight_repair_repeats_after_late_queue_destroy() { + if crate::helpers::skip_without_cassandra() { + return; + } use cdrs_tokio::types::IntoRustByName; use extenddb_core::types::{AttributeValue, Item}; use extenddb_storage::DataEngine; @@ -2810,6 +2877,7 @@ async fn test_inflight_repair_repeats_after_late_queue_destroy() { extenddb_storage_cassandra::ttl_worker::reconcile_inflight_repairs_once(&engine) .await .unwrap() + .1 >= 1, "the worker must process at least this test's inflight marker" ); @@ -2857,6 +2925,9 @@ async fn test_inflight_repair_repeats_after_late_queue_destroy() { #[tokio::test] async fn test_outbox_limit_is_fair_across_partitions() { + if crate::helpers::skip_without_cassandra() { + return; + } let engine = setup_engine().await; let table = crate::helpers::TestTable::new(&engine, "TtlOutboxFairness", false).await; activate_tables(&engine).await; @@ -2912,6 +2983,9 @@ async fn test_outbox_limit_is_fair_across_partitions() { #[tokio::test] async fn test_non_ttl_put_does_not_enqueue_ttl_reconciliation() { + if crate::helpers::skip_without_cassandra() { + return; + } use extenddb_core::types::{AttributeValue, Item}; use extenddb_storage::DataEngine; @@ -2940,6 +3014,9 @@ async fn test_non_ttl_put_does_not_enqueue_ttl_reconciliation() { #[tokio::test] async fn test_ttl_enabled_table_rejects_new_async_gsi() { + if crate::helpers::skip_without_cassandra() { + return; + } use extenddb_core::types::{ AttributeDefinition, CreateGsiAction, GlobalSecondaryIndexUpdate, KeySchemaElement, KeyType, Projection, ProjectionType, ScalarAttributeType, UpdateTableInput, @@ -3016,3 +3093,178 @@ async fn test_ttl_enabled_table_rejects_new_async_gsi() { if message.contains("asynchronously propagated GSI") )); } + +/// The audit is the last line of defense for the silent-non-expiration class: +/// a queue registration lost through any path this design did not anticipate +/// is permanent for an item that is never written again, because only writes +/// and the enable backfill create registrations. The audit rescans the table +/// and repairs exactly that — simulated here by deleting a live item's queue +/// row out from under it. +#[tokio::test] +async fn test_audit_restores_lost_queue_registration() { + if helpers::skip_without_cassandra() { + return; + } + use extenddb_core::metrics::MetricsCollector; + use extenddb_core::types::{AttributeValue, Item}; + use extenddb_storage::DataEngine; + + let engine = setup_engine().await; + let table = crate::helpers::TestTable::new(&engine, "TtlAuditRepair", false).await; + activate_tables(&engine).await; + engine + .update_ttl( + &table.key_info.account_id, + &table.key_info.table_name, + "expires_at", + true, + ) + .await + .unwrap(); + engine + .create_ttl_index( + &table.key_info.account_id, + &table.key_info.table_name, + "expires_at", + ) + .await + .unwrap(); + + let future = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + + 3_600; + let mut item = Item::new(); + item.insert("id".to_owned(), AttributeValue::S("lost".to_owned())); + item.insert( + "expires_at".to_owned(), + AttributeValue::N(future.to_string()), + ); + engine + .put_item( + &table.key_info, + item.clone(), + false, + None, + &Default::default(), + None, + ) + .await + .unwrap(); + // Discharge the write's own outbox row first, so the audit is the only + // remaining mechanism that can notice the loss below. + extenddb_storage_cassandra::ttl_worker::reconcile_pending_older_than(&engine, 1_000, -60) + .await + .unwrap(); + + // Simulate an unanticipated loss path: the registration vanishes while + // the item lives on. + let keyspace = engine.account_keyspace(&table.key_info.account_id); + let generation = ttl_generation( + &engine, + &table.key_info.account_id, + &table.key_info.table_name, + ) + .await; + // The queue's partition key is composite; enumerate registered partitions + // and delete each one. + let partitions = |engine: &extenddb_storage_cassandra::CassandraEngine| { + let keyspace = keyspace.clone(); + let table_id = table.key_info.table_id.clone(); + let session = engine.session_arc(); + async move { + use cdrs_tokio::types::IntoRustByName; + session + .query_with_values( + &format!( + "SELECT generation, bucket, shard FROM {keyspace}.ttl_expiration_buckets \ + WHERE table_id = ?" + ), + cdrs_tokio::query_values!(table_id.as_str()), + ) + .await + .unwrap() + .response_body() + .unwrap() + .into_rows() + .unwrap_or_default() + .into_iter() + .map(|row| { + let generation: uuid::Uuid = row.get_r_by_name("generation").unwrap(); + let bucket: i64 = row.get_r_by_name("bucket").unwrap(); + let shard: i32 = row.get_r_by_name("shard").unwrap(); + (generation, bucket, shard) + }) + .collect::>() + } + }; + for (row_generation, bucket, shard) in partitions(&engine).await { + engine + .session_arc() + .query_with_values( + &format!( + "DELETE FROM {keyspace}.ttl_expirations WHERE table_id = ? \ + AND generation = ? AND bucket = ? AND shard = ?" + ), + cdrs_tokio::query_values!( + table.key_info.table_id.as_str(), + row_generation, + bucket, + shard + ), + ) + .await + .unwrap(); + } + + let queue_rows = |engine: &extenddb_storage_cassandra::CassandraEngine| { + let keyspace = keyspace.clone(); + let table_id = table.key_info.table_id.clone(); + let session = engine.session_arc(); + let partitions = partitions(engine); + async move { + let mut total = 0usize; + for (row_generation, bucket, shard) in partitions.await { + total += session + .query_with_values( + &format!( + "SELECT key_hash FROM {keyspace}.ttl_expirations WHERE table_id = ? \ + AND generation = ? AND bucket = ? AND shard = ?" + ), + cdrs_tokio::query_values!(table_id.as_str(), row_generation, bucket, shard), + ) + .await + .unwrap() + .response_body() + .unwrap() + .into_rows() + .unwrap_or_default() + .len(); + } + total + } + }; + assert_eq!(queue_rows(&engine).await, 0, "precondition: entry lost"); + + extenddb_storage_cassandra::ttl_worker::audit_ttl_queues_once( + &engine, + &MetricsCollector::new(), + ) + .await; + + assert_eq!( + queue_rows(&engine).await, + 1, + "the audit must re-register the live item's expiration" + ); + let _ = generation; + // And the audit must not have perturbed a healthy queue: a second pass + // repairs nothing and changes nothing. + extenddb_storage_cassandra::ttl_worker::audit_ttl_queues_once( + &engine, + &MetricsCollector::new(), + ) + .await; + assert_eq!(queue_rows(&engine).await, 1); +} diff --git a/crates/storage-sqlite/src/vector_search.rs b/crates/storage-sqlite/src/vector_search.rs index 17b0c5fb..ee98c7a4 100644 --- a/crates/storage-sqlite/src/vector_search.rs +++ b/crates/storage-sqlite/src/vector_search.rs @@ -43,8 +43,10 @@ fn decode_vector(bytes: &[u8], dimensions: usize) -> Result, StorageErr ))); } Ok(bytes - .chunks_exact(4) - .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])) + .as_chunks::<4>() + .0 + .iter() + .map(|c| f32::from_le_bytes(*c)) .collect()) } diff --git a/docs/adr/0010-cassandra-ttl-expiration-queue.md b/docs/adr/0010-cassandra-ttl-expiration-queue.md index 4c8b9522..c027005e 100644 --- a/docs/adr/0010-cassandra-ttl-expiration-queue.md +++ b/docs/adr/0010-cassandra-ttl-expiration-queue.md @@ -52,7 +52,7 @@ Use a generation-fenced, durable TTL-specific state machine. * Maintain `ttl_expiration_buckets` and `ttl_expirations` in every account keyspace. Queue partitions use table ID, TTL-enable generation, day bucket, and one of 64 deterministic key shards. Entries are ordered by expiration time and key. * Accept only positive integral DynamoDB `N` values as epoch seconds. Missing, non-numeric, fractional, zero, and negative values are not queued. * Allocate a fresh generation on every enable. Backfill the active generation synchronously and publish `ttl_index_ready` only after reconciliation. Disable, re-enable, cleanup, and sweeps are fenced by the exact attribute and generation. -* Before enabling TTL on a table that is already accepting writes, quiesce writes that began under the disabled generation until enable and synchronous backfill return. Once enabled, all ordinary writes enter the exact-claim logged-batch path. Enabling TTL before opening a new table to traffic avoids this operational step. +* Before enabling TTL on a table that is already accepting writes, quiesce writes that began under the disabled generation until enable and synchronous backfill return, plus the write-path configuration cache lifetime (5 seconds) — hosts other than the one issuing the change may serve the prior configuration for up to that long. Once enabled, all ordinary writes enter the exact-claim logged-batch path. Enabling TTL before opening a new table to traffic avoids this operational step. * Record ordinary Put/Update reconciliation in a durable key-only `ttl_reconcile_pending` outbox in the same logged batch as the base mutation, whenever the written item carries a valid timestamp. The worker quorum-reads the committed item and authoritative table identity/configuration, restores the bucket registration at quorum before accepting an existing `PENDING` row, and prefers a plain quorum queue read over Paxos when the entry is already present. A conflict with claimed TTL work remains retryable; the outbox is removed at quorum only after reconciliation succeeds. Symmetrically, every conditional destroy of an active-generation queue row first writes a unique `ttl_repair_inflight` marker at `LOCAL_QUORUM`. After a definitive applied true/false response, the destroyer writes a successor normal outbox record at quorum and only then deletes the inflight marker at quorum. Ambiguous errors and crashes retain the non-dischargeable marker, which the dedicated repair worker repeatedly reconciles while its recorded table generation remains active; a late destroy is therefore repaired on a later pass without relying on wall-clock age or skew. Quorum proof that the table or generation retired makes the marker safe to remove. * Reconcile transaction Put/Update/Delete results against the pre-commit image before deleting the recovery ledger, so a transactional write retires the entry the item had before it as well as registering the new one. COMMITTING recovery repeats reconciliation from the persisted ledger payload, which holds only the committed image (see *Known gaps*). * Represent deletion work as `PENDING`, `CLAIMED`, `CLAIM_ABORTING`, `EFFECTS_APPLYING`, and `EFFECTS_APPLIED`. A claim persists the old item image, a stable delete timestamp, a work UUID, and an optional deterministic stream plan. A stale-item or lifecycle abort first wins `CLAIMED` → `CLAIM_ABORTING` on the queue row, then releases the base owner and retires work; a sweep must instead win `CLAIMED` → `EFFECTS_APPLYING` after sealing the exact owner. The mutually exclusive LWTs prevent either side from clearing the other's fence. @@ -103,7 +103,7 @@ Each phase is idempotent and is entered only from durable state. `work_id` is th * LWT claims and lifecycle leases add Cassandra coordination cost to TTL-enabled tables. * A destroy whose result remains ambiguous leaves a `ttl_repair_inflight` marker intentionally. A dedicated worker rechecks active-generation markers and safely removes markers only after quorum metadata proves their table or generation retired; operators must monitor persistent marker count/age and investigate active-generation entries. * Cassandra coordinators and ExtendDB hosts must have synchronized clocks, and data-plane requests must have deadlines well below the request claim lifetime. -* Claims and the exact base delete condition on `item_data` string equality, so the persisted JSON encoding of an item is part of the protocol. A unit test pins that encoding as canonical; a version column would be a more robust fence and is a candidate follow-up. +* Claims fence on the row's `version` column, which every image write bumps (ordinary writes explicitly, transactional commits via server-side increment); rows whose version is null — they predate explicit versioning and have not been rewritten, including rows only ever written transactionally — fall back to `item_data` string equality. The exact base delete keeps owner AND version AND the full image as its condition: version alone is not a unique image identity (two writers can stamp different images with the same successor version under extreme clock divergence, and it restarts across delete/recreate incarnations), and the one statement that destroys data does not rest on a clock assumption. The canonical-encoding unit test therefore remains load-bearing. ### Operating envelope @@ -112,7 +112,7 @@ This version is suitable for production when the deployment stays inside the sup * enable TTL before opening a new table to writes, or briefly quiesce an existing table while enable/backfill completes; * use no GSI with a nonzero propagation delay on a TTL-enabled table; * serve writes for a given table from one Cassandra datacenter, because all claims and lifecycle LWTs use `LOCAL_QUORUM`/`LOCAL_SERIAL` and are therefore linearizable only within a datacenter; -* stay under roughly 100 expirations per table per minute. This is the shared TTL worker's rate, not a Cassandra property — every backend uses a 100-item batch per 60-second cycle and none of them gain throughput from a larger fleet (see *Parity with the PostgreSQL backend*). A table expiring faster than that accumulates backlog; +* stay under roughly 1,000 expirations per table per minute. The sweep processes its batch concurrently across the queue's independent shard partitions (safe because every row transition is conditional on its exact work id), so Cassandra's batch is sized by throughput need — 1,000 per 60-second cycle — rather than by the serial round-trip time that bounds the other backends at 100. The per-table lease means the work still happens exactly once; a table expiring faster than the batch rate accumulates backlog; * size and monitor the expiration backlog, Cassandra LWT latency, and worker health; * keep Cassandra coordinators and ExtendDB hosts time-synchronized, because request batches use explicit timestamps, with request deadlines well below the request claim lifetime; and * accept DynamoDB-style eventual expiration plus the documented brief internal effects-before-delete window. @@ -151,8 +151,8 @@ The bar for this feature is the production readiness of the PostgreSQL TTL imple | Enabling TTL on a live table needs writes quiesced | Expiry is derived from a durable queue that must be backfilled, and a write that began before enable cannot join the new generation. PostgreSQL's `CREATE INDEX CONCURRENTLY` covers live writes with no transition window. | No — needs a durable background backfill that admits pre-enable writes. | | Writes on a TTL-enabled table cost an extra catalog read, one request-path LWT, a detached exact-release LWT, and a logged batch | The claim protocol is on the write path; the exact release is bounded background work. PostgreSQL writes touch nothing TTL-related. | Partly — the claim's marginal value is now small enough that removing it from the ordinary write path is a live proposal. | | Writes for a table must be served from one datacenter | All claims and lifecycle LWTs use `LOCAL_QUORUM`/`LOCAL_SERIAL`. | No — global serial consistency would cost cross-region Paxos on every write. | -| The claim and exact delete fence on `item_data` string equality | There is no version column to condition on. | Yes, as a follow-up: add a monotonic version column. | -| Transaction *recovery* reconciles insert-only | The ledger does not persist the pre-commit image. | Yes, as a follow-up: persist it. | +| Claims fence on the OCC `version` column (with `item_data` fallback for unversioned legacy rows) | Every write now bumps `version`, unifying the two concurrency mechanisms Joel flagged as parallel systems. | Done in this tranche. | +| Transaction recovery reconciles against the ledger-persisted pre-commit image | PREPARE captures each row's prior image; recovery retires the old queue entry as the live path does. | Done in this tranche. | The summary: with this change, Cassandra TTL matches PostgreSQL on every shared behaviour, is stricter on two, and differs on a set of items that all trace back to the absence of cross-partition atomic conditional writes. The only *functional* capability PostgreSQL has and Cassandra does not is TTL alongside asynchronously propagated GSIs. @@ -163,7 +163,5 @@ Deliberately out of scope for this change, in rough priority order: * **Expiration throughput does not scale horizontally.** The sweep lease is per table even though the queue is already sharded 64 ways by key and those shards are disjoint partitions. Leasing per `(table, shard)` would allow up to 64 concurrent workers per table with no change to the claim protocol, because every queue transition is already conditional on the exact work UUID. This is the highest-value follow-up, and it would take Cassandra past the PostgreSQL rate rather than merely matching it. * **The backfill has no durable cursor.** A failure restarts the scan from the beginning, and the `UpdateTimeToLive` call blocks for its duration instead of reporting `ENABLING`. * **TTL alongside asynchronously propagated GSIs**, per the table above. -* **`item_data` equality as the fence.** A monotonic version column would remove the dependency on a stable JSON encoding and stop shipping whole items as LWT condition values. -* **Transaction recovery reconciles insert-only**, so a transaction that crashes between COMMIT and reconciliation can leave the item's previous queue entry behind until it comes due. It is inert — the worker revalidates the item before deleting anything — but it is queue garbage. -* **Catalog reads are uncached.** TTL configuration is read on every write, on top of the index read the write path already performs. +* **Version fence hardening.** The fence now uses the `version` column; remaining follow-up is retiring the `item_data` fallback once no unversioned rows can exist (requires a fleet-wide rewrite pass or an explicit migration), after which the canonical-JSON coupling disappears entirely. * **Recovery-path test coverage.** The transitions this change introduced — draining a retired generation, retiring a drained bucket registration, an expired worker claim — are reasoned about but not yet covered by tests. diff --git a/docs/differences-from-dynamodb.md b/docs/differences-from-dynamodb.md index 25d4bbae..f1c86b86 100755 --- a/docs/differences-from-dynamodb.md +++ b/docs/differences-from-dynamodb.md @@ -57,10 +57,10 @@ adaptation when switching between ExtendDB and the real service. | `UpdateTimeToLive` response timing | Returns immediately; the change takes effect asynchronously (up to about an hour) | All backends await readiness before returning, so the call can be slow on a large table and can exceed a client timeout. PostgreSQL awaits a concurrent index build; Cassandra awaits a full-table backfill of the expiration queue, which has no durable cursor and restarts on the next worker cycle if it fails. | | `DescribeTimeToLive` transitional states | `ENABLING` and `DISABLING` are reported while a change settles | Only `ENABLED` and `DISABLED`. A table reports `ENABLED` as soon as the attribute is set, including while its queue is still being backfilled and nothing will expire yet. | | Concurrent same-key writes on a TTL-enabled table | Unaffected by TTL | Serialized through a base-row claim. Contention is retried internally against a re-read image, so writes stay last-writer-wins; only sustained contention surfaces `TransactionConflictException`. | -| TTL-enabled write cost | Unaffected by TTL | An extra catalog read (overlapped with the index read) and a claim LWT, with the base write becoming a logged batch. The release is folded into the batch, with an exact conditional release performed off the request path. Non-TTL tables keep the existing fast paths. | +| TTL-enabled write cost | Unaffected by TTL | A claim LWT, with the base write becoming a logged batch (measured: roughly 2× put latency on a single-node bench). The TTL configuration is served from a 5-second cache, the remaining catalog read is overlapped with the index read, and the release is folded into the batch with an exact conditional release off the request path. Non-TTL tables keep the existing fast paths. | | Cassandra TTL with asynchronous GSIs | Supported | Not currently supported. The Cassandra backend rejects TTL enable when any GSI has a nonzero effective propagation delay, and rejects creating such a GSI on a TTL-enabled table; base tables, LSIs, and synchronous GSIs are supported. See [ADR-0010](adr/0010-cassandra-ttl-expiration-queue.md). | | Enabling Cassandra TTL on a live table | No application write quiescence required | Writes that began while TTL was disabled must be quiesced until enable and synchronous backfill complete. Prefer enabling TTL before opening a new table to traffic. Once enabled, writes use the durable exact-claim path. | -| TTL expiration throughput | Managed by the service | About 100 items per table per minute on every backend — a fixed 100-item batch per 60-second cycle. This does not increase with fleet size on any backend: PostgreSQL hosts sweep without a lease but each runs the same `ORDER BY ttl LIMIT 100` query and therefore contend for the same rows, while Cassandra takes an exclusive per-table sweep lease and does the same work once. A table expiring faster than this accumulates backlog. | +| TTL expiration throughput | Managed by the service | Cassandra: about 1,000 items per table per minute — the sweep processes its batch concurrently across the queue's shard partitions under one per-table lease. Other backends: about 100 per table per minute (a serial 100-item batch per 60-second cycle, with unleased hosts contending for the same rows). A table expiring faster than its backend's rate accumulates backlog. | | Cassandra TTL deployment bounds | Managed by the service | Writes for a table must be served from one Cassandra datacenter — claims and lifecycle LWTs use `LOCAL_QUORUM`/`LOCAL_SERIAL` and are linearizable only within a datacenter. Cassandra coordinators and ExtendDB hosts must remain time-synchronized because request batches use explicit timestamps, and request deadlines must stay well below the 120-second request claim lifetime. A write whose queue mutation loses an internal race is repaired by the background outbox; an ambiguous queue destroy retains a non-dischargeable marker that is rechecked every worker pass. | | Cassandra TTL lifecycle contention | Not observable | Enabling or disabling TTL, and creating an index that would invalidate a live sweep, take a short lease and retry briefly. Sustained collision reports `ResourceInUseException` with a retry hint. | | TTL modification cooldown | Enforces a cooldown period between enable/disable changes ("Time to live has been modified multiple times within a fixed interval") | No cooldown — TTL can be enabled and disabled immediately. Intentional divergence for faster local development. | From 0aebbe4c1f912d5d6643c9f3b62c971418ebbcff Mon Sep 17 00:00:00 2001 From: Scott Robinson Date: Thu, 17 Sep 2026 20:16:44 +0000 Subject: [PATCH 34/48] fix: OCC delete fast path must write the partition delete high-water mark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ported direct integration suite caught this: the OCC fast path added in the #338 rework (plain delete, no TTL claim, no transaction owner — the most common delete shape) returned after its fence LWT without writing partition_max_delete_timestamp. The transaction prepare path consults that high-water mark to order new-item PUTs against deletes in the same partition, so the common delete silently disarmed the check the claimed and transactional delete paths still fed. Both fast-path arms (sort-key and pk-only) now write the mark before the fence, matching the claimed path's write-before-delete ordering: a mark advanced for a delete the fence then refuses only widens a conservative check, while the reverse order can lose the mark on a crash between the two. --- .../storage-cassandra/src/data/delete_item.rs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/crates/storage-cassandra/src/data/delete_item.rs b/crates/storage-cassandra/src/data/delete_item.rs index 6af6bde7..a6bf3026 100644 --- a/crates/storage-cassandra/src/data/delete_item.rs +++ b/crates/storage-cassandra/src/data/delete_item.rs @@ -286,6 +286,20 @@ impl CassandraEngine { // concurrent writers. Without this, a concurrent UpdateItem that // read the same pre-image can race the plain DELETE. if old_item_opt.is_some() { + // The transaction prepare path consults this high-water mark to + // order new-item PUTs against deletes in the same partition; the + // claimed and transactional delete paths write it before their + // delete, and this fast path must too. Written before the fence: + // if the fence then refuses, a spuriously advanced mark is + // harmless (it only widens a conservative check), while the + // reverse order can lose the mark on a crash between the two. + self.update_partition_max_delete_timestamp_at( + &data_keyspace, + &ddb_table, + &pk_text, + chrono::Utc::now().timestamp_millis(), + ) + .await?; let fence_cql = format!( "DELETE FROM {data_keyspace}.{ddb_table} WHERE pk = ? AND {sk_col} = ? \ IF version = ? AND prepared_txn_id = null" @@ -523,6 +537,20 @@ impl CassandraEngine { // No TTL claim and no transaction owner: use an OCC LWT to fence // concurrent writers. if old_item_opt.is_some() { + // The transaction prepare path consults this high-water mark to + // order new-item PUTs against deletes in the same partition; the + // claimed and transactional delete paths write it before their + // delete, and this fast path must too. Written before the fence: + // if the fence then refuses, a spuriously advanced mark is + // harmless (it only widens a conservative check), while the + // reverse order can lose the mark on a crash between the two. + self.update_partition_max_delete_timestamp_at( + &data_keyspace, + &ddb_table, + &pk_text, + chrono::Utc::now().timestamp_millis(), + ) + .await?; let fence_cql = format!( "DELETE FROM {data_keyspace}.{ddb_table} WHERE pk = ? \ IF version = ? AND prepared_txn_id = null" From fe657a3f689f48e12b9e38d8e0b3d14a85930cd0 Mon Sep 17 00:00:00 2001 From: Scott Robinson Date: Tue, 15 Sep 2026 06:30:13 +0000 Subject: [PATCH 35/48] test: port the plug-in repo's direct storage-trait integration suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original extenddb-cassandra-plugin repository carried 139 direct integration tests against the storage traits — real node, no HTTP server in between — of which the in-tree port had picked up only the four tag tests. The other 135 covered CRUD, Query/Scan semantics and pagination, transactions (TransactGet, TransactWrite rollback and idempotency, ledger operations), GSI/LSI physical schema and the async propagation workers, streams, access keys, accounts, users, groups, roles, policies, admin and settings stores, and backup/restore state. None of that had automated coverage in-tree. One test binary (direct_integration), one module per area, sharing tests/common/mod.rs — the in-tree descendant of the plug-in's helpers.rs, which is why the port is mostly mechanical: keyspace-prefix adaptation, the two CreateTableInput fields added since the fork, and the skip-without-Cassandra guard on all 139 tests so the serviceless workspace test job stays green. Four tests the inventory flagged as weak were strengthened rather than copied: the table lifecycle and settings tests printed outcomes instead of asserting them (the settings test also now restores the live control-plane knob it mutates, which used to leak past the test run); the pk-only delete-timestamp test carried dead bindings and now verifies the delete; and the sync-GSI test's TODO was hiding a real semantic difference — the plug-in engine wrote GSIs synchronously by default, in-tree the default is 10ms async propagation via a queue no test worker drains, so its "verify the write path executes without crashing" would never have caught a lost index write. It now pins the index to synchronous propagation (the same knob production routing reads) and asserts the index row is visible through the index-scan path. The suite runs in parallel: every test provisions its own account and keyspace. Wired into the Cassandra CI workflow after the existing serial suites. --- .github/workflows/integration-cassandra.yml | 5 + .../tests/direct/access_keys.rs | 264 +++ .../tests/direct/accounts.rs | 144 ++ .../tests/direct/admin_store.rs | 171 ++ .../tests/direct/authorization_store.rs | 353 ++++ .../tests/direct/backup_engine.rs | 383 +++++ .../tests/direct/cassandra_engine.rs | 303 ++++ .../tests/direct/delete_item.rs | 747 ++++++++ .../storage-cassandra/tests/direct/groups.rs | 182 ++ .../storage-cassandra/tests/direct/index.rs | 848 +++++++++ .../tests/direct/metadata_engine.rs | 152 ++ .../tests/direct/policies.rs | 84 + .../tests/direct/put_get_item.rs | 708 ++++++++ .../storage-cassandra/tests/direct/query.rs | 1509 +++++++++++++++++ .../storage-cassandra/tests/direct/roles.rs | 286 ++++ crates/storage-cassandra/tests/direct/scan.rs | 782 +++++++++ .../tests/direct/settings_store.rs | 110 ++ .../storage-cassandra/tests/direct/streams.rs | 637 +++++++ .../tests/direct/table_engine.rs | 152 ++ .../tests/direct/transact_get_items.rs | 448 +++++ .../tests/direct/transact_write_items.rs | 585 +++++++ .../tests/direct/transaction_ledger.rs | 296 ++++ .../storage-cassandra/tests/direct/users.rs | 426 +++++ .../tests/direct_integration.rs | 59 + 24 files changed, 9634 insertions(+) create mode 100644 crates/storage-cassandra/tests/direct/access_keys.rs create mode 100644 crates/storage-cassandra/tests/direct/accounts.rs create mode 100644 crates/storage-cassandra/tests/direct/admin_store.rs create mode 100644 crates/storage-cassandra/tests/direct/authorization_store.rs create mode 100644 crates/storage-cassandra/tests/direct/backup_engine.rs create mode 100644 crates/storage-cassandra/tests/direct/cassandra_engine.rs create mode 100644 crates/storage-cassandra/tests/direct/delete_item.rs create mode 100644 crates/storage-cassandra/tests/direct/groups.rs create mode 100644 crates/storage-cassandra/tests/direct/index.rs create mode 100644 crates/storage-cassandra/tests/direct/metadata_engine.rs create mode 100644 crates/storage-cassandra/tests/direct/policies.rs create mode 100644 crates/storage-cassandra/tests/direct/put_get_item.rs create mode 100644 crates/storage-cassandra/tests/direct/query.rs create mode 100644 crates/storage-cassandra/tests/direct/roles.rs create mode 100644 crates/storage-cassandra/tests/direct/scan.rs create mode 100644 crates/storage-cassandra/tests/direct/settings_store.rs create mode 100644 crates/storage-cassandra/tests/direct/streams.rs create mode 100644 crates/storage-cassandra/tests/direct/table_engine.rs create mode 100644 crates/storage-cassandra/tests/direct/transact_get_items.rs create mode 100644 crates/storage-cassandra/tests/direct/transact_write_items.rs create mode 100644 crates/storage-cassandra/tests/direct/transaction_ledger.rs create mode 100644 crates/storage-cassandra/tests/direct/users.rs create mode 100644 crates/storage-cassandra/tests/direct_integration.rs diff --git a/.github/workflows/integration-cassandra.yml b/.github/workflows/integration-cassandra.yml index 7c446558..4c3eebbd 100644 --- a/.github/workflows/integration-cassandra.yml +++ b/.github/workflows/integration-cassandra.yml @@ -52,3 +52,8 @@ jobs: run: cargo test -p extenddb-storage-cassandra --test metadata_integration -- --test-threads=1 - name: TTL integration tests run: cargo test -p extenddb-storage-cassandra --test ttl_integration -- --test-threads=1 + - name: Direct storage-trait integration tests + # Ported from the plug-in repo's tests/rust suite. Parallel-safe: + # each test provisions its own account and keyspace; the shared + # control-plane setting test restores what it mutates. + run: cargo test -p extenddb-storage-cassandra --test direct_integration diff --git a/crates/storage-cassandra/tests/direct/access_keys.rs b/crates/storage-cassandra/tests/direct/access_keys.rs new file mode 100644 index 00000000..91203475 --- /dev/null +++ b/crates/storage-cassandra/tests/direct/access_keys.rs @@ -0,0 +1,264 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Integration tests for access key operations. + +#[cfg(test)] +mod tests { + use crate::helpers::{setup_engine, test_config, unique_test_account, unique_test_id}; + use extenddb_storage::management_store::ManagementStore; + + #[tokio::test] + async fn test_create_access_key() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + + // Need encryption key for access key creation + use extenddb_storage::bootstrapper::helpers::generate_encryption_key; + let enc_key = generate_encryption_key(); + let catalog_store = extenddb_storage_cassandra::CassandraCatalogStore::with_encryption_key( + engine.session_arc(), + config.keyspace_prefix.clone(), + config.datacenter.clone(), + config.replication_factor, + enc_key, + ); + + let account_id = unique_test_account(); + let account_name = format!("TestAccount_{}", unique_test_id()); + let user_name = format!("testuser_{}", unique_test_id()); + + catalog_store + .create_account(&account_id, &account_name) + .await + .expect("Failed to create account"); + + catalog_store + .create_user(&account_id, &user_name, None) + .await + .expect("Failed to create user"); + + catalog_store + .create_access_key(&account_id, &user_name) + .await + .expect("Failed to create access key"); + + println!("✓ Access key created successfully"); + } + + #[tokio::test] + async fn test_store_and_fetch_session() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + + // Need encryption key for session storage + use extenddb_storage::bootstrapper::helpers::generate_encryption_key; + let enc_key = generate_encryption_key(); + let catalog_store = extenddb_storage_cassandra::CassandraCatalogStore::with_encryption_key( + engine.session_arc(), + config.keyspace_prefix.clone(), + config.datacenter.clone(), + config.replication_factor, + enc_key.clone(), + ); + + let account_id = unique_test_account(); + let account_name = format!("TestAccount_{}", unique_test_id()); + let role_name = format!("testrole_{}", unique_test_id()); + let session_name = format!("session_{}", unique_test_id()); + + catalog_store + .create_account(&account_id, &account_name) + .await + .unwrap(); + + let trust_policy = serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"Service": "lambda.amazonaws.com"}, + "Action": "sts:AssumeRole" + }] + }); + + catalog_store + .create_role(&account_id, &role_name, &trust_policy) + .await + .unwrap(); + + let session_token = format!("session_token_{}", unique_test_id()); + let access_key_id = format!("AKIATEST{}", unique_test_id()); + let secret_key = b"test_secret_key_12345678"; + let session_tags = Some(serde_json::json!([ + {"Key": "Department", "Value": "Engineering"}, + {"Key": "Project", "Value": "TestProject"} + ])); + let session_policy = Some(serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": "s3:GetObject", + "Resource": "*" + }] + })); + let expires_at = time::OffsetDateTime::now_utc() + time::Duration::hours(1); + + // Store session + catalog_store + .store_session( + &session_token, + &access_key_id, + secret_key, + &account_id, + &role_name, + &session_name, + &session_tags, + &session_policy, + expires_at, + ) + .await + .unwrap(); + println!("✓ Session stored"); + + // Fetch session data via AuthorizationStore + use extenddb_storage::authorization_store::AuthorizationStore; + let session_data = catalog_store + .fetch_session_data(&account_id, &role_name, &session_name) + .await + .unwrap(); + + assert!(session_data.is_some()); + let data = session_data.unwrap(); + + assert!(data.session_policy.is_some()); + assert_eq!(data.session_tags.len(), 2); + println!("✓ Session data fetched: {} tags", data.session_tags.len()); + + // Verify session tags content + assert!( + data.session_tags + .iter() + .any(|(k, v)| k == "Department" && v == "Engineering") + ); + assert!( + data.session_tags + .iter() + .any(|(k, v)| k == "Project" && v == "TestProject") + ); + println!("✓ Session tags verified"); + } + + #[tokio::test] + async fn test_fetch_caller_tags() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let account_id = unique_test_account(); + let account_name = format!("TestAccount_{}", unique_test_id()); + let user_name = format!("testuser_{}", unique_test_id()); + let role_name = format!("testrole_{}", unique_test_id()); + + catalog_store + .create_account(&account_id, &account_name) + .await + .unwrap(); + + catalog_store + .create_user(&account_id, &user_name, None) + .await + .unwrap(); + + let trust_policy = serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"Service": "ec2.amazonaws.com"}, + "Action": "sts:AssumeRole" + }] + }); + + catalog_store + .create_role(&account_id, &role_name, &trust_policy) + .await + .unwrap(); + + // Tag user + catalog_store + .tag_user( + &account_id, + &user_name, + &[ + ("Environment".to_string(), "Production".to_string()), + ("Owner".to_string(), "TeamA".to_string()), + ], + ) + .await + .unwrap(); + + // Tag role + catalog_store + .tag_role( + &account_id, + &role_name, + &[ + ("Department".to_string(), "Engineering".to_string()), + ("CostCenter".to_string(), "CC123".to_string()), + ], + ) + .await + .unwrap(); + + // Fetch caller tags for user + let user_resource = format!("user/{}", user_name); + let user_tags = catalog_store + .fetch_caller_tags(&account_id, &user_resource) + .await + .unwrap(); + assert_eq!(user_tags.len(), 2); + println!("✓ Fetched {} user caller tags", user_tags.len()); + + // Fetch caller tags for role + let role_resource = format!("role/{}", role_name); + let role_tags = catalog_store + .fetch_caller_tags(&account_id, &role_resource) + .await + .unwrap(); + assert_eq!(role_tags.len(), 2); + println!("✓ Fetched {} role caller tags", role_tags.len()); + + // Fetch caller tags for assumed-role + let assumed_role_resource = format!("assumed-role/{}/session-name", role_name); + let assumed_tags = catalog_store + .fetch_caller_tags(&account_id, &assumed_role_resource) + .await + .unwrap(); + assert_eq!(assumed_tags.len(), 2); + println!("✓ Fetched {} assumed-role caller tags", assumed_tags.len()); + + // Non-existent resource should return empty + let empty_tags = catalog_store + .fetch_caller_tags(&account_id, "invalid/format") + .await + .unwrap(); + assert!(empty_tags.is_empty()); + println!("✓ Invalid resource returns empty tags"); + + // Non-existent user should return empty + let empty_tags = catalog_store + .fetch_caller_tags(&account_id, "user/nonexistent") + .await + .unwrap(); + assert!(empty_tags.is_empty()); + println!("✓ Non-existent user returns empty tags"); + } +} diff --git a/crates/storage-cassandra/tests/direct/accounts.rs b/crates/storage-cassandra/tests/direct/accounts.rs new file mode 100644 index 00000000..fe3aef54 --- /dev/null +++ b/crates/storage-cassandra/tests/direct/accounts.rs @@ -0,0 +1,144 @@ +// Copyright 2026 ExtendDB Contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Integration tests for Account management operations for `CassandraCatalogStore`. + +#[cfg(test)] +mod tests { + use crate::helpers::{setup_engine, test_config, unique_test_account, unique_test_id}; + use extenddb_storage::management_store::ManagementStore; + + #[tokio::test] + async fn test_create_account() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let account_id = unique_test_account(); + let account_name = format!("TestAccount_{}", unique_test_id()); + + catalog_store + .create_account(&account_id, &account_name) + .await + .expect("Failed to create account"); + + println!("✓ Account created successfully"); + + let result = catalog_store + .create_account(&account_id, &account_name) + .await; + match result { + Err(extenddb_storage::management_store::OpError::AlreadyExists(_)) => { + println!("✓ Duplicate account correctly rejected"); + } + other => panic!("Expected AlreadyExists, got: {:?}", other), + } + } + + #[tokio::test] + async fn test_account_operations() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let account_id = unique_test_account(); + let account_name = format!("TestAccount_{}", unique_test_id()); + + // Happy case: create account + catalog_store + .create_account(&account_id, &account_name) + .await + .expect("Failed to create account"); + + println!("✓ Account created successfully"); + + // Unhappy case: duplicate account (after keyspace ensured) + let result = catalog_store + .create_account(&account_id, &account_name) + .await; + + match result { + Err(extenddb_storage::management_store::OpError::AlreadyExists(_)) => { + println!("✓ Duplicate account correctly rejected"); + } + other => panic!("Expected AlreadyExists, got: {:?}", other), + } + + // Happy case: list all accounts + let accounts = catalog_store + .list_all_accounts() + .await + .expect("Failed to list accounts"); + + assert!(accounts.iter().any(|(id, _)| id == &account_id)); + println!("✓ Account listed in list_all_accounts"); + + // Happy case: list accounts full + let accounts_full = catalog_store + .list_all_accounts_full() + .await + .expect("Failed to list accounts full"); + + assert!(accounts_full.iter().any(|(id, _, _)| id == &account_id)); + println!("✓ Account listed in list_all_accounts_full"); + + // Happy case: list accounts for specific account + let accounts_for = catalog_store + .list_accounts_for(&account_id) + .await + .expect("Failed to list accounts for"); + + assert_eq!(accounts_for.len(), 1); + assert_eq!(accounts_for[0].0, account_id); + println!("✓ list_accounts_for works"); + + // Happy case: get account detail + let detail = catalog_store + .get_account_detail(&account_id) + .await + .expect("Failed to get account detail") + .expect("Account detail should exist"); + + assert_eq!(detail.account_name, account_name); + assert_eq!(detail.users.len(), 0); + assert_eq!(detail.groups.len(), 0); + assert_eq!(detail.roles.len(), 0); + println!("✓ Account detail retrieved"); + + // Happy case: dashboard counts + let (account_count, _admin_count) = catalog_store + .dashboard_counts() + .await + .expect("Failed to get dashboard counts"); + + assert!(account_count > 0); + println!("✓ Dashboard counts: {} accounts", account_count); + + // Unhappy case: delete account with tables (would need to create a table first) + // Skip this as it requires full table engine setup + + // Happy case: delete account + catalog_store + .delete_account(&account_id) + .await + .expect("Failed to delete account"); + + println!("✓ Account deleted successfully"); + + // Unhappy case: delete non-existent account + let result = catalog_store.delete_account(&account_id).await; + + match result { + Err(extenddb_storage::management_store::OpError::NotFound(_)) => { + println!("✓ Delete non-existent account correctly rejected"); + } + other => panic!("Expected NotFound, got: {:?}", other), + } + } +} diff --git a/crates/storage-cassandra/tests/direct/admin_store.rs b/crates/storage-cassandra/tests/direct/admin_store.rs new file mode 100644 index 00000000..6a52f6d4 --- /dev/null +++ b/crates/storage-cassandra/tests/direct/admin_store.rs @@ -0,0 +1,171 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Integration tests for AdminStore trait implementation. + +#[cfg(test)] +mod tests { + use crate::helpers::{setup_engine, test_config}; + use extenddb_storage::management_store::AdminStore; + + #[tokio::test] + async fn test_admin_lifecycle() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let admin_name = format!("test-admin-{}", crate::helpers::unique_test_id()); + let password_hash = "test-hash-123"; + + // Create admin + catalog_store + .create_admin(&admin_name, password_hash) + .await + .expect("Failed to create admin"); + println!("✓ Admin created"); + + // Duplicate create should fail + let result = catalog_store.create_admin(&admin_name, password_hash).await; + assert!(matches!( + result, + Err(extenddb_storage::management_store::OpError::AlreadyExists( + _ + )) + )); + println!("✓ Duplicate admin rejected"); + + // List admins (should have at least the test admin we just created) + let admins = catalog_store + .list_admins() + .await + .expect("Failed to list admins"); + assert!(!admins.is_empty()); + assert!(admins.iter().any(|a| a.admin_name == admin_name)); + println!("✓ List admins: {} admin(s)", admins.len()); + + // Delete admin + catalog_store + .delete_admin(&admin_name) + .await + .expect("Failed to delete admin"); + println!("✓ Admin deleted"); + + // Delete non-existent should fail + let result = catalog_store.delete_admin(&admin_name).await; + assert!(matches!( + result, + Err(extenddb_storage::management_store::OpError::NotFound(_)) + )); + println!("✓ Delete non-existent admin rejected"); + + // List should not contain deleted admin + let admins = catalog_store + .list_admins() + .await + .expect("Failed to list admins"); + assert!(!admins.iter().any(|a| a.admin_name == admin_name)); + println!("✓ Admin list no longer contains deleted admin"); + } + + #[tokio::test] + async fn test_admin_password() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let admin_name = format!("password-test-admin-{}", crate::helpers::unique_test_id()); + let password = "test-password-123"; + + // Hash password using bcrypt + let password_hash = tokio::task::spawn_blocking({ + let password = password.to_string(); + move || bcrypt::hash(password, bcrypt::DEFAULT_COST).unwrap() + }) + .await + .unwrap(); + + // Create admin with password + catalog_store + .create_admin(&admin_name, &password_hash) + .await + .expect("Failed to create admin"); + println!("✓ Admin created with password"); + + // Verify correct password + let result = catalog_store + .verify_admin_password(&admin_name, password) + .await + .expect("Failed to verify password"); + assert_eq!(result, Some(true)); + println!("✓ Correct password verified"); + + // Verify wrong password + let result = catalog_store + .verify_admin_password(&admin_name, "wrong-password") + .await + .expect("Failed to verify password"); + assert_eq!(result, Some(false)); + println!("✓ Wrong password rejected"); + + // Verify non-existent admin + let result = catalog_store + .verify_admin_password("nonexistent", password) + .await + .expect("Failed to verify password"); + assert_eq!(result, None); + println!("✓ Non-existent admin returns None"); + + // Change password + let new_password = "new-password-456"; + let new_password_hash = tokio::task::spawn_blocking({ + let password = new_password.to_string(); + move || bcrypt::hash(password, bcrypt::DEFAULT_COST).unwrap() + }) + .await + .unwrap(); + + catalog_store + .change_admin_password(&admin_name, &new_password_hash) + .await + .expect("Failed to change password"); + println!("✓ Password changed"); + + // Old password should fail + let result = catalog_store + .verify_admin_password(&admin_name, password) + .await + .expect("Failed to verify password"); + assert_eq!(result, Some(false)); + println!("✓ Old password no longer works"); + + // New password should work + let result = catalog_store + .verify_admin_password(&admin_name, new_password) + .await + .expect("Failed to verify password"); + assert_eq!(result, Some(true)); + println!("✓ New password works"); + + // Change password for non-existent admin should fail + let result = catalog_store + .change_admin_password("nonexistent", &new_password_hash) + .await; + assert!(matches!( + result, + Err(extenddb_storage::management_store::OpError::NotFound(_)) + )); + println!("✓ Change password for non-existent admin rejected"); + + // Cleanup + catalog_store + .delete_admin(&admin_name) + .await + .expect("Failed to delete admin"); + } +} diff --git a/crates/storage-cassandra/tests/direct/authorization_store.rs b/crates/storage-cassandra/tests/direct/authorization_store.rs new file mode 100644 index 00000000..165adb41 --- /dev/null +++ b/crates/storage-cassandra/tests/direct/authorization_store.rs @@ -0,0 +1,353 @@ +// Copyright 2026 ExtendDB Contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Integration tests for AuthorizationStore trait implementation. + +#[cfg(test)] +mod tests { + use crate::helpers::{setup_engine, test_config, unique_test_account, unique_test_id}; + use extenddb_storage::authorization_store::AuthorizationStore; + use extenddb_storage::management_store::ManagementStore; + + #[tokio::test] + async fn test_fetch_user_policies() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let account_id = unique_test_account(); + let user_name = format!("testuser_{}", unique_test_id()); + + catalog_store + .create_account(&account_id, &format!("TestAccount_{}", unique_test_id())) + .await + .unwrap(); + + catalog_store + .create_user(&account_id, &user_name, None) + .await + .unwrap(); + + let policy_doc = serde_json::json!({"Version": "2012-10-17", "Statement": []}); + catalog_store + .put_policy(&account_id, "user", &user_name, "TestPolicy", &policy_doc) + .await + .unwrap(); + + let policies = catalog_store + .fetch_user_policies(&account_id, &user_name) + .await + .unwrap(); + + assert!(!policies.is_empty()); + println!("✓ Fetched {} user policies", policies.len()); + } + + #[tokio::test] + async fn test_fetch_user_group_policies() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let account_id = unique_test_account(); + let user_name = format!("testuser_{}", unique_test_id()); + + catalog_store + .create_account(&account_id, &format!("TestAccount_{}", unique_test_id())) + .await + .unwrap(); + + catalog_store + .create_user(&account_id, &user_name, None) + .await + .unwrap(); + + let policies = catalog_store + .fetch_user_group_policies(&account_id, &user_name) + .await + .unwrap(); + + // Should be empty - user not in any groups + assert!(policies.is_empty()); + println!("✓ Fetched group policies (empty as expected)"); + } + + #[tokio::test] + async fn test_fetch_user_boundary() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let account_id = unique_test_account(); + let user_name = format!("testuser_{}", unique_test_id()); + + catalog_store + .create_account(&account_id, &format!("TestAccount_{}", unique_test_id())) + .await + .unwrap(); + + catalog_store + .create_user(&account_id, &user_name, None) + .await + .unwrap(); + + let boundary = catalog_store + .fetch_user_boundary(&account_id, &user_name) + .await + .unwrap(); + + assert!(boundary.is_none()); + println!("✓ User has no permissions boundary"); + } + + #[tokio::test] + async fn test_fetch_role_policies() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let account_id = unique_test_account(); + let account_name = format!("TestAccount_{}", unique_test_id()); + let role_name = format!("testrole_{}", unique_test_id()); + + catalog_store + .create_account(&account_id, &account_name) + .await + .unwrap(); + + let trust_policy = serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"Service": "lambda.amazonaws.com"}, + "Action": "sts:AssumeRole" + }] + }); + + catalog_store + .create_role(&account_id, &role_name, &trust_policy) + .await + .unwrap(); + + // Add a policy to the role + let policy_doc = serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": "s3:GetObject", + "Resource": "*" + }] + }); + + catalog_store + .put_policy(&account_id, "role", &role_name, "TestPolicy", &policy_doc) + .await + .unwrap(); + + // Fetch role policies + let policies = catalog_store + .fetch_role_policies(&account_id, &role_name) + .await + .unwrap(); + + assert_eq!(policies.len(), 1); + println!("✓ Fetched {} role policy(ies)", policies.len()); + + // Verify policy content + let fetched_policy: serde_json::Value = serde_json::from_str(&policies[0]).unwrap(); + assert_eq!(fetched_policy, policy_doc); + println!("✓ Policy content matches"); + + // Non-existent role should return empty + let policies = catalog_store + .fetch_role_policies(&account_id, "nonexistent") + .await + .unwrap(); + assert!(policies.is_empty()); + println!("✓ Non-existent role returns empty policies"); + } + + #[tokio::test] + async fn test_fetch_role_boundary() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let account_id = unique_test_account(); + let account_name = format!("TestAccount_{}", unique_test_id()); + let role_name = format!("testrole_{}", unique_test_id()); + + catalog_store + .create_account(&account_id, &account_name) + .await + .unwrap(); + + let trust_policy = serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"Service": "ec2.amazonaws.com"}, + "Action": "sts:AssumeRole" + }] + }); + + catalog_store + .create_role(&account_id, &role_name, &trust_policy) + .await + .unwrap(); + + // Initially no boundary + let boundary = catalog_store + .fetch_role_boundary(&account_id, &role_name) + .await + .unwrap(); + assert!(boundary.is_none()); + println!("✓ Role has no permissions boundary initially"); + + // Set a boundary + let boundary_doc = serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": "s3:*", + "Resource": "*" + }] + }); + + catalog_store + .set_role_boundary(&account_id, &role_name, &boundary_doc) + .await + .unwrap(); + + // Fetch boundary + let boundary = catalog_store + .fetch_role_boundary(&account_id, &role_name) + .await + .unwrap(); + assert!(boundary.is_some()); + println!("✓ Role boundary set and fetched"); + + // Verify boundary content + let fetched_boundary: serde_json::Value = serde_json::from_str(&boundary.unwrap()).unwrap(); + assert_eq!(fetched_boundary, boundary_doc); + println!("✓ Boundary content matches"); + + // Non-existent role should return None + let boundary = catalog_store + .fetch_role_boundary(&account_id, "nonexistent") + .await + .unwrap(); + assert!(boundary.is_none()); + println!("✓ Non-existent role returns None boundary"); + } + + #[tokio::test] + async fn test_fetch_user_and_role_tags() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let account_id = unique_test_account(); + let account_name = format!("TestAccount_{}", unique_test_id()); + let user_name = format!("testuser_{}", unique_test_id()); + let role_name = format!("testrole_{}", unique_test_id()); + + catalog_store + .create_account(&account_id, &account_name) + .await + .unwrap(); + + catalog_store + .create_user(&account_id, &user_name, None) + .await + .unwrap(); + + let trust_policy = serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"Service": "lambda.amazonaws.com"}, + "Action": "sts:AssumeRole" + }] + }); + + catalog_store + .create_role(&account_id, &role_name, &trust_policy) + .await + .unwrap(); + + // Tag user + catalog_store + .tag_user( + &account_id, + &user_name, + &[ + ("Department".to_string(), "Engineering".to_string()), + ("Team".to_string(), "Platform".to_string()), + ], + ) + .await + .unwrap(); + + // Tag role + catalog_store + .tag_role( + &account_id, + &role_name, + &[ + ("Environment".to_string(), "Production".to_string()), + ("Owner".to_string(), "DevOps".to_string()), + ], + ) + .await + .unwrap(); + + // Fetch user tags + let user_tags = catalog_store + .fetch_user_tags(&account_id, &user_name) + .await + .unwrap(); + assert_eq!(user_tags.len(), 2); + println!("✓ Fetched {} user tag(s)", user_tags.len()); + + // Fetch role tags + let role_tags = catalog_store + .fetch_role_tags(&account_id, &role_name) + .await + .unwrap(); + assert_eq!(role_tags.len(), 2); + println!("✓ Fetched {} role tag(s)", role_tags.len()); + + // Non-existent resources should return empty + let user_tags = catalog_store + .fetch_user_tags(&account_id, "nonexistent") + .await + .unwrap(); + assert!(user_tags.is_empty()); + + let role_tags = catalog_store + .fetch_role_tags(&account_id, "nonexistent") + .await + .unwrap(); + assert!(role_tags.is_empty()); + println!("✓ Non-existent resources return empty tags"); + } +} diff --git a/crates/storage-cassandra/tests/direct/backup_engine.rs b/crates/storage-cassandra/tests/direct/backup_engine.rs new file mode 100644 index 00000000..44c9cc89 --- /dev/null +++ b/crates/storage-cassandra/tests/direct/backup_engine.rs @@ -0,0 +1,383 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Direct integration tests for Cassandra backup and restore operations. + +use std::collections::BTreeMap; +use std::time::Duration; + +use extenddb_core::types::{AttributeValue, DeleteTableInput, DescribeTableInput, TableStatus}; +use extenddb_storage::error::StorageError; +use extenddb_storage::{BackupEngine, DataEngine, TableEngine}; + +use crate::helpers::{TestAccount, TestTable, setup_engine, unique_test_account}; + +fn item(id: &str, sort: Option<&str>, value: &str) -> BTreeMap { + let mut item = BTreeMap::new(); + item.insert("id".to_owned(), AttributeValue::S(id.to_owned())); + if let Some(sort) = sort { + item.insert("sort".to_owned(), AttributeValue::S(sort.to_owned())); + } + item.insert("value".to_owned(), AttributeValue::S(value.to_owned())); + item +} + +fn key(id: &str, sort: Option<&str>) -> BTreeMap { + let mut key = BTreeMap::new(); + key.insert("id".to_owned(), AttributeValue::S(id.to_owned())); + if let Some(sort) = sort { + key.insert("sort".to_owned(), AttributeValue::S(sort.to_owned())); + } + key +} + +async fn activate_tables(engine: &extenddb_storage_cassandra::CassandraEngine) { + tokio::time::sleep(Duration::from_millis(350)).await; + engine + .process_control_plane_transitions() + .await + .expect("process table transitions"); +} + +#[tokio::test] +async fn test_backup_describe_list_delete_and_account_scope() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "BackupLifecycle", false).await; + activate_tables(&engine).await; + + for (id, value) in [("one", "first"), ("two", "second")] { + engine + .put_item( + &table.key_info, + item(id, None, value), + false, + None, + &Default::default(), + None, + ) + .await + .expect("seed item"); + } + + let details = engine + .create_backup( + &table.key_info.account_id, + &table.key_info.table_name, + "daily", + ) + .await + .expect("create backup"); + assert_eq!(details.backup_name, "daily"); + assert_eq!(details.backup_status, "AVAILABLE"); + assert_eq!(details.backup_type, "USER"); + assert!(details.backup_arn.contains(&format!( + ":{}:table/{}/backup/", + table.key_info.account_id, table.key_info.table_name + ))); + + let description = engine + .describe_backup(&table.key_info.account_id, &details.backup_arn) + .await + .expect("describe backup"); + assert_eq!( + description.source_table_details.table_id, + table.key_info.table_id + ); + assert_eq!(description.source_table_details.item_count, 2); + assert_eq!( + description.source_table_details.key_schema, + table.key_info.key_schema + ); + + let table_backups = engine + .list_backups(&table.key_info.account_id, Some(&table.key_info.table_name)) + .await + .expect("list table backups"); + assert_eq!(table_backups.len(), 1); + assert_eq!(table_backups[0].backup_arn, details.backup_arn); + assert_eq!( + engine + .list_backups(&table.key_info.account_id, None) + .await + .expect("list account backups") + .len(), + 1 + ); + + let foreign = unique_test_account(); + let foreign_error = engine + .describe_backup(&foreign, &details.backup_arn) + .await + .expect_err("another account must not resolve the backup"); + assert!( + matches!(foreign_error, StorageError::Validation(message) if message.contains("Backup not found")) + ); + + let deleted = engine + .delete_backup(&table.key_info.account_id, &details.backup_arn) + .await + .expect("delete backup"); + assert_eq!(deleted.backup_details.backup_status, "DELETED"); + assert!( + engine + .describe_backup(&table.key_info.account_id, &details.backup_arn) + .await + .is_err() + ); + assert!( + engine + .list_backups(&table.key_info.account_id, None) + .await + .expect("list after delete") + .is_empty() + ); +} + +#[tokio::test] +async fn test_restore_uses_immutable_snapshot_with_sort_key() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "BackupRestoreSource", true).await; + activate_tables(&engine).await; + + let original_one = item("partition", Some("one"), "before-backup"); + let original_two = item("partition", Some("two"), "preserved"); + for original in [&original_one, &original_two] { + engine + .put_item( + &table.key_info, + original.clone(), + false, + None, + &Default::default(), + None, + ) + .await + .expect("seed source item"); + } + + let backup = engine + .create_backup( + &table.key_info.account_id, + &table.key_info.table_name, + "immutable", + ) + .await + .expect("create backup"); + + engine + .put_item( + &table.key_info, + item("partition", Some("one"), "after-backup"), + false, + None, + &Default::default(), + None, + ) + .await + .expect("mutate source item"); + engine + .put_item( + &table.key_info, + item("partition", Some("three"), "new-after-backup"), + false, + None, + &Default::default(), + None, + ) + .await + .expect("add source item"); + + let target = "BackupRestoreTarget"; + let initial = engine + .restore_table_from_backup(&table.key_info.account_id, target, &backup.backup_arn) + .await + .expect("restore backup"); + assert!(matches!( + initial.table_status, + TableStatus::Creating | TableStatus::Active + )); + + let target_description = engine + .describe_table( + &table.key_info.account_id, + DescribeTableInput { + table_name: target.to_owned(), + }, + ) + .await + .expect("describe restored table"); + assert_eq!(target_description.table_status, TableStatus::Active); + + let restored_key_info = engine + .table_key_info(&table.key_info.account_id, target) + .await + .expect("restored table key info"); + let restored_one = engine + .get_item(&restored_key_info, &key("partition", Some("one"))) + .await + .expect("get first restored item") + .expect("first restored item exists"); + assert_eq!(restored_one.get("value"), original_one.get("value")); + let restored_two = engine + .get_item(&restored_key_info, &key("partition", Some("two"))) + .await + .expect("get second restored item") + .expect("second restored item exists"); + assert_eq!(restored_two.get("value"), original_two.get("value")); + assert!( + engine + .get_item(&restored_key_info, &key("partition", Some("three"))) + .await + .expect("get post-backup item") + .is_none() + ); + + engine + .delete_table( + &table.key_info.account_id, + DeleteTableInput { + table_name: target.to_owned(), + }, + ) + .await + .expect("delete restored table"); + engine + .delete_backup(&table.key_info.account_id, &backup.backup_arn) + .await + .expect("delete backup"); +} + +#[tokio::test] +async fn test_table_name_filter_spans_table_recreation() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let account = TestAccount::new(&engine, "extenddb_test").await; + let table_name = "BackupRecreatedSource"; + let source = std::mem::ManuallyDrop::new( + TestTable::with_account(&engine, &account.account_id, table_name, false).await, + ); + activate_tables(&engine).await; + + engine + .put_item( + &source.key_info, + item("original", None, "from-old-schema"), + false, + None, + &Default::default(), + None, + ) + .await + .expect("seed original table"); + let backup = engine + .create_backup(&account.account_id, table_name, "before-recreate") + .await + .expect("backup original table"); + let original_table_id = source.key_info.table_id.clone(); + + engine + .delete_table( + &account.account_id, + DeleteTableInput { + table_name: table_name.to_owned(), + }, + ) + .await + .expect("delete original table"); + activate_tables(&engine).await; + + // Reuse the source name with a different schema. ListBackups is name-based, + // but restore must continue to use the immutable schema stored in the backup. + let replacement = TestTable::with_account(&engine, &account.account_id, table_name, true).await; + activate_tables(&engine).await; + assert_ne!(replacement.key_info.table_id, original_table_id); + assert_eq!(replacement.key_info.key_schema.len(), 2); + + let listed = engine + .list_backups(&account.account_id, Some(table_name)) + .await + .expect("list backups across table recreation"); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].backup_arn, backup.backup_arn); + + let target = "BackupRecreatedRestore"; + engine + .restore_table_from_backup(&account.account_id, target, &backup.backup_arn) + .await + .expect("restore backup from original table incarnation"); + let restored_key_info = engine + .table_key_info(&account.account_id, target) + .await + .expect("restored table key info"); + assert_eq!(restored_key_info.key_schema.len(), 1); + let restored = engine + .get_item(&restored_key_info, &key("original", None)) + .await + .expect("read restored item") + .expect("restored item exists"); + assert_eq!( + restored.get("value"), + Some(&AttributeValue::S("from-old-schema".to_owned())) + ); + + engine + .delete_table( + &account.account_id, + DeleteTableInput { + table_name: target.to_owned(), + }, + ) + .await + .expect("delete restored table"); + engine + .delete_backup(&account.account_id, &backup.backup_arn) + .await + .expect("delete backup"); +} + +#[tokio::test] +async fn test_continuous_backup_state_and_pitr_restore_rejection() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "ContinuousBackupState", false).await; + + let initial = engine + .describe_continuous_backups(&table.key_info.account_id, &table.key_info.table_name) + .await + .expect("describe default continuous backup state"); + assert_eq!(initial.continuous_backups_status, "ENABLED"); + let initial_pitr = initial.point_in_time_recovery_description.unwrap(); + assert_eq!(initial_pitr.point_in_time_recovery_status, "DISABLED"); + assert!(initial_pitr.earliest_restorable_date_time.is_none()); + + let enabled = engine + .update_continuous_backups(&table.key_info.account_id, &table.key_info.table_name, true) + .await + .expect("enable PITR state"); + let enabled_pitr = enabled.point_in_time_recovery_description.unwrap(); + assert_eq!(enabled_pitr.point_in_time_recovery_status, "ENABLED"); + assert!(enabled_pitr.earliest_restorable_date_time.is_some()); + assert!(enabled_pitr.latest_restorable_date_time.is_some()); + + let restore_error = engine + .restore_table_to_point_in_time( + &table.key_info.account_id, + &table.key_info.table_name, + "UnsupportedPitrTarget", + ) + .await + .expect_err("PITR restore must not fake a current-time snapshot"); + assert!( + matches!(restore_error, StorageError::Validation(message) if message.contains("not yet supported")) + ); +} diff --git a/crates/storage-cassandra/tests/direct/cassandra_engine.rs b/crates/storage-cassandra/tests/direct/cassandra_engine.rs new file mode 100644 index 00000000..682129b1 --- /dev/null +++ b/crates/storage-cassandra/tests/direct/cassandra_engine.rs @@ -0,0 +1,303 @@ +// Copyright 2026 ExtendDB Contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Direct integration tests for CassandraEngine. +//! +//! Run with: cargo test -- --nocapture + +#[cfg(test)] +mod tests { + use crate::helpers::test_config; + use extenddb_storage_cassandra::engine::CassandraEngine; + + #[tokio::test] + async fn test_cassandra_connection() { + if crate::helpers::skip_without_cassandra() { + return; + } + let config = test_config(); + + let result = CassandraEngine::create_session(&config).await; + + match result { + Ok(_session) => { + println!("✓ Successfully connected to Cassandra"); + } + Err(e) => { + panic!("Failed to connect to Cassandra: {:?}", e); + } + } + } + + #[tokio::test] + async fn test_keyspace_operations() { + if crate::helpers::skip_without_cassandra() { + return; + } + let config = test_config(); + + let engine = CassandraEngine::new(&config, "us-east-1") + .await + .expect("Failed to create engine"); + + let test_keyspace = "test_connectivity_ks"; + + // Create keyspace + engine + .create_keyspace(test_keyspace) + .await + .expect("Failed to create keyspace"); + + println!("✓ Created keyspace: {}", test_keyspace); + + // Verify exists + let exists = engine + .keyspace_exists(test_keyspace) + .await + .expect("Failed to check keyspace existence"); + + assert!(exists, "Keyspace should exist after creation"); + println!("✓ Verified keyspace exists"); + + // Cleanup + engine + .drop_keyspace(test_keyspace) + .await + .expect("Failed to drop keyspace"); + + println!("✓ Dropped keyspace: {}", test_keyspace); + } + + /// Tests table_key_info() method. + #[tokio::test] + async fn test_table_key_info() { + if crate::helpers::skip_without_cassandra() { + return; + } + use extenddb_core::types::{ + AttributeDefinition, BillingMode, CreateTableInput, DeleteTableInput, KeySchemaElement, + KeyType, ScalarAttributeType, + }; + use extenddb_storage::TableEngine; + + let config = test_config(); + let engine = CassandraEngine::new(&config, "us-east-1") + .await + .expect("Failed to create engine"); + + let account_id = crate::helpers::test_account_id(&engine) + .await + .expect("Failed to get test account"); + let table_name = "TestKeyInfoTable"; + + // Create a test table + let create_input = CreateTableInput { + vector_indexes: None, + table_throughput_mode: None, + table_name: table_name.to_string(), + key_schema: vec![ + KeySchemaElement { + attribute_name: "pk".to_string(), + key_type: KeyType::Hash, + }, + KeySchemaElement { + attribute_name: "sk".to_string(), + key_type: KeyType::Range, + }, + ], + attribute_definitions: vec![ + AttributeDefinition { + attribute_name: "pk".to_string(), + attribute_type: ScalarAttributeType::S, + }, + AttributeDefinition { + attribute_name: "sk".to_string(), + attribute_type: ScalarAttributeType::N, + }, + ], + billing_mode: Some(BillingMode::PayPerRequest), + global_secondary_indexes: None, + local_secondary_indexes: None, + provisioned_throughput: None, + on_demand_throughput: None, + sse_specification: None, + stream_specification: None, + deletion_protection_enabled: None, + table_class: None, + tags: None, + }; + + // Create table + match engine.create_table(&account_id, create_input).await { + Ok(_) => println!("✓ Created test table"), + Err(extenddb_storage::error::StorageError::TableAlreadyExists(_)) => { + println!("✓ Table already exists, continuing") + } + Err(e) => panic!("Failed to create table: {:?}", e), + } + + // Manually update table status to ACTIVE for testing + // (bypass control plane delay mechanism) + let update_query = format!( + "UPDATE {}_catalog.tables SET table_status = 'ACTIVE' WHERE account_id = ? AND table_name = ?", + config.keyspace_prefix + ); + engine + .session_arc() + .query_with_values( + &update_query, + cdrs_tokio::query_values!(account_id.as_str(), table_name), + ) + .await + .expect("Failed to update table status"); + println!("✓ Table is ACTIVE"); + + // Test table_key_info + let key_info = engine + .table_key_info(&account_id, table_name) + .await + .expect("Failed to fetch table_key_info"); + + println!("✓ Fetched table_key_info"); + assert_eq!(key_info.table_name, table_name); + assert_eq!(key_info.account_id, account_id); + assert_eq!(key_info.key_schema.len(), 2); + assert_eq!(key_info.key_schema[0].attribute_name, "pk"); + assert_eq!(key_info.key_schema[0].key_type, KeyType::Hash); + assert_eq!(key_info.key_schema[1].attribute_name, "sk"); + assert_eq!(key_info.key_schema[1].key_type, KeyType::Range); + assert_eq!(key_info.attribute_definitions.len(), 2); + assert!(!key_info.table_id.is_empty()); + assert!(!key_info.has_lsi); + assert!(key_info.stream_specification.is_none()); + println!("✓ All assertions passed"); + + // Clean up + let _ = engine + .delete_table( + &account_id, + DeleteTableInput { + table_name: table_name.to_string(), + }, + ) + .await; + } + + /// Tests table_key_info() with non-existent table. + #[tokio::test] + async fn test_table_key_info_not_found() { + if crate::helpers::skip_without_cassandra() { + return; + } + use extenddb_storage::TableEngine; + + let config = test_config(); + let engine = CassandraEngine::new(&config, "us-east-1") + .await + .expect("Failed to create engine"); + + let account_id = crate::helpers::test_account_id(&engine) + .await + .expect("Failed to get test account"); + + let result = engine.table_key_info(&account_id, "NonExistentTable").await; + + match result { + Err(extenddb_storage::error::StorageError::TableNotFound(name)) => { + println!("✓ Correctly returned TableNotFound for: {}", name); + assert_eq!(name, "NonExistentTable"); + } + other => panic!("Expected TableNotFound, got: {:?}", other), + } + } + + #[tokio::test] + async fn test_verify_admin_password() { + if crate::helpers::skip_without_cassandra() { + return; + } + use extenddb_storage::management_store::AdminStore; + + let config = test_config(); + let engine = crate::helpers::setup_engine().await; + + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + // Create an admin user for testing + let test_admin = format!("testadmin_{}", crate::helpers::unique_test_id()); + let test_password = "test_password_123"; + + // Hash the password like bootstrapper does + let password_hash: String = tokio::task::spawn_blocking({ + let password = test_password.to_string(); + move || bcrypt::hash(password, bcrypt::DEFAULT_COST).unwrap() + }) + .await + .unwrap(); + + catalog_store + .create_admin(&test_admin, &password_hash) + .await + .expect("Failed to create test admin user"); + + // Test with correct password + let result = catalog_store + .verify_admin_password(&test_admin, test_password) + .await; + + match result { + Ok(Some(true)) => { + println!("✓ Admin password verification succeeded with correct password"); + } + Ok(Some(false)) => { + panic!("Should verify with correct password!"); + } + Ok(None) => { + panic!("Admin user '{}' should exist", test_admin); + } + Err(e) => { + panic!("verify_admin_password failed: {:?}", e); + } + } + + // Test with wrong password + let result = catalog_store + .verify_admin_password(&test_admin, "wrong_password") + .await; + + match result { + Ok(Some(false)) => { + println!( + "✓ Admin password verification correctly returned false for wrong password" + ); + } + Ok(Some(true)) => { + panic!("Should not verify with wrong password!"); + } + Ok(None) => { + panic!("Admin user '{}' should exist", test_admin); + } + Err(e) => { + panic!("verify_admin_password failed: {:?}", e); + } + } + + // Test with non-existent user + let result = catalog_store + .verify_admin_password("nonexistent", "password") + .await; + match result { + Ok(None) => { + println!( + "✓ Admin password verification correctly returned None for non-existent user" + ); + } + Ok(Some(_)) => { + panic!("Non-existent user should return None"); + } + Err(e) => { + panic!("verify_admin_password failed: {:?}", e); + } + } + } +} diff --git a/crates/storage-cassandra/tests/direct/delete_item.rs b/crates/storage-cassandra/tests/direct/delete_item.rs new file mode 100644 index 00000000..7f996a0c --- /dev/null +++ b/crates/storage-cassandra/tests/direct/delete_item.rs @@ -0,0 +1,747 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Direct integration tests for delete_item operation. + +use extenddb_storage::DataEngine; +use std::collections::BTreeMap; + +use crate::helpers::{TestTable, setup_engine}; + +#[tokio::test] +async fn test_delete_item_pk_only_exists() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "test_delete_pk", false).await; + + // Put an item + let mut item = BTreeMap::new(); + item.insert( + "id".to_string(), + extenddb_core::types::AttributeValue::S("test-id".to_string()), + ); + item.insert( + "data".to_string(), + extenddb_core::types::AttributeValue::S("test data".to_string()), + ); + + engine + .put_item( + &table.key_info, + item.clone(), + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item failed"); + + // Delete the item (return_old=true) + let mut key = BTreeMap::new(); + key.insert( + "id".to_string(), + extenddb_core::types::AttributeValue::S("test-id".to_string()), + ); + + let result = engine + .delete_item(&table.key_info, &key, true, None, &Default::default(), None) + .await + .expect("delete_item failed"); + + // Should return the old item + assert!(result.is_some()); + let old_item = result.unwrap(); + assert_eq!(old_item.get("id"), item.get("id")); + assert_eq!(old_item.get("data"), item.get("data")); + + // Verify item is deleted + let get_result = engine + .get_item(&table.key_info, &key) + .await + .expect("get_item failed"); + assert!(get_result.is_none()); +} + +#[tokio::test] +async fn test_delete_item_pk_only_not_exists() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "test_delete_pk_notexists", false).await; + + // Try to delete non-existent item + let mut key = BTreeMap::new(); + key.insert( + "id".to_string(), + extenddb_core::types::AttributeValue::S("nonexistent".to_string()), + ); + + let result = engine + .delete_item(&table.key_info, &key, true, None, &Default::default(), None) + .await + .expect("delete_item failed"); + + // Should return None when item doesn't exist + assert!(result.is_none()); +} + +#[tokio::test] +async fn test_delete_item_with_sk_exists() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "test_delete_sk", true).await; + + // Put an item + let mut item = BTreeMap::new(); + item.insert( + "id".to_string(), + extenddb_core::types::AttributeValue::S("user-123".to_string()), + ); + item.insert( + "sort".to_string(), + extenddb_core::types::AttributeValue::S("order-456".to_string()), + ); + item.insert( + "amount".to_string(), + extenddb_core::types::AttributeValue::N("99.99".to_string()), + ); + + engine + .put_item( + &table.key_info, + item.clone(), + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item failed"); + + // Delete the item + let mut key = BTreeMap::new(); + key.insert( + "id".to_string(), + extenddb_core::types::AttributeValue::S("user-123".to_string()), + ); + key.insert( + "sort".to_string(), + extenddb_core::types::AttributeValue::S("order-456".to_string()), + ); + + let result = engine + .delete_item(&table.key_info, &key, true, None, &Default::default(), None) + .await + .expect("delete_item failed"); + + assert!(result.is_some()); + let old_item = result.unwrap(); + assert_eq!(old_item.get("id"), item.get("id")); + assert_eq!(old_item.get("amount"), item.get("amount")); +} + +#[tokio::test] +async fn test_delete_item_with_sk_not_exists() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "test_delete_sk_notexists", true).await; + + let mut key = BTreeMap::new(); + key.insert( + "id".to_string(), + extenddb_core::types::AttributeValue::S("nonexistent".to_string()), + ); + key.insert( + "sort".to_string(), + extenddb_core::types::AttributeValue::S("missing".to_string()), + ); + + let result = engine + .delete_item(&table.key_info, &key, true, None, &Default::default(), None) + .await + .expect("delete_item failed"); + + assert!(result.is_none()); +} + +#[tokio::test] +async fn test_delete_item_return_old_false() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "test_delete_noreturn", false).await; + + // Put an item + let mut item = BTreeMap::new(); + item.insert( + "id".to_string(), + extenddb_core::types::AttributeValue::S("test-id".to_string()), + ); + + engine + .put_item( + &table.key_info, + item.clone(), + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item failed"); + + // Delete without returning old value + let mut key = BTreeMap::new(); + key.insert( + "id".to_string(), + extenddb_core::types::AttributeValue::S("test-id".to_string()), + ); + + let result = engine + .delete_item( + &table.key_info, + &key, + false, + None, + &Default::default(), + None, + ) + .await + .expect("delete_item failed"); + + assert!(result.is_none()); + + // Verify deletion + let get_result = engine + .get_item(&table.key_info, &key) + .await + .expect("get_item failed"); + assert!(get_result.is_none()); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Transaction protection tests (Phase 3, T3.1) +// ═══════════════════════════════════════════════════════════════════════════════ + +use crate::helpers::put_item_then_lock; +use extenddb_core::types::AttributeValue; + +#[tokio::test] +async fn test_delete_item_rejects_when_prepared_txn_id_set_pk_only() { + if crate::helpers::skip_without_cassandra() { + return; + } + use extenddb_storage::error::StorageError; + + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TxnProtDelPk", false).await; + + let mut item = BTreeMap::new(); + item.insert( + "id".to_string(), + AttributeValue::S("del-locked".to_string()), + ); + item.insert( + "data".to_string(), + AttributeValue::S("precious".to_string()), + ); + + put_item_then_lock(&engine, &table, &item).await; + + let mut key = BTreeMap::new(); + key.insert( + "id".to_string(), + AttributeValue::S("del-locked".to_string()), + ); + + let result = engine + .delete_item( + &table.key_info, + &key, + false, + None, + &Default::default(), + None, + ) + .await; + + match result { + Err(StorageError::TransactionCanceled(reasons)) => { + assert_eq!(reasons[0].code, "TransactionConflict"); + } + other => panic!("Expected TransactionCanceled, got: {:?}", other), + } +} + +#[tokio::test] +async fn test_delete_item_rejects_when_prepared_txn_id_set_with_sk() { + if crate::helpers::skip_without_cassandra() { + return; + } + use extenddb_storage::error::StorageError; + + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TxnProtDelSk", true).await; + + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S("dpk1".to_string())); + item.insert("sort".to_string(), AttributeValue::S("dsk1".to_string())); + item.insert( + "data".to_string(), + AttributeValue::S("important".to_string()), + ); + + put_item_then_lock(&engine, &table, &item).await; + + let mut key = BTreeMap::new(); + key.insert("id".to_string(), AttributeValue::S("dpk1".to_string())); + key.insert("sort".to_string(), AttributeValue::S("dsk1".to_string())); + + let result = engine + .delete_item( + &table.key_info, + &key, + false, + None, + &Default::default(), + None, + ) + .await; + + match result { + Err(StorageError::TransactionCanceled(reasons)) => { + assert_eq!(reasons[0].code, "TransactionConflict"); + } + other => panic!("Expected TransactionCanceled, got: {:?}", other), + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// partition_max_delete_timestamp tests (Phase 3, T3.2) +// ═══════════════════════════════════════════════════════════════════════════════ + +#[tokio::test] +async fn test_delete_item_sets_partition_max_delete_timestamp_pk_only() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TxnDelTsPk", false).await; + + // Put an item + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S("ts-item".to_string())); + item.insert("data".to_string(), AttributeValue::S("value".to_string())); + + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item should succeed"); + + // Delete it + let mut key = BTreeMap::new(); + key.insert("id".to_string(), AttributeValue::S("ts-item".to_string())); + + engine + .delete_item( + &table.key_info, + &key, + false, + None, + &Default::default(), + None, + ) + .await + .expect("delete_item should succeed"); + + // For PK-only tables the timestamp is a regular column and is deleted + // with the row — the persistence protection only exists for sort-key + // tables where it is STATIC. What this shape CAN assert: the delete + // actually removed the item. + assert!( + engine + .get_item(&table.key_info, &key) + .await + .expect("get after delete") + .is_none(), + "item still present after delete" + ); +} + +#[tokio::test] +async fn test_delete_item_sets_partition_max_delete_timestamp_with_sk() { + if crate::helpers::skip_without_cassandra() { + return; + } + use cdrs_tokio::types::IntoRustByName; + + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TxnDelTsSk", true).await; + + // Put two items in the same partition + let mut item1 = BTreeMap::new(); + item1.insert("id".to_string(), AttributeValue::S("pk1".to_string())); + item1.insert("sort".to_string(), AttributeValue::S("sk1".to_string())); + item1.insert("data".to_string(), AttributeValue::S("val1".to_string())); + + let mut item2 = BTreeMap::new(); + item2.insert("id".to_string(), AttributeValue::S("pk1".to_string())); + item2.insert("sort".to_string(), AttributeValue::S("sk2".to_string())); + item2.insert("data".to_string(), AttributeValue::S("val2".to_string())); + + engine + .put_item( + &table.key_info, + item1, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item 1 should succeed"); + engine + .put_item( + &table.key_info, + item2, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item 2 should succeed"); + + // Delete one item - this should set partition_max_delete_timestamp + let mut key = BTreeMap::new(); + key.insert("id".to_string(), AttributeValue::S("pk1".to_string())); + key.insert("sort".to_string(), AttributeValue::S("sk1".to_string())); + + engine + .delete_item( + &table.key_info, + &key, + false, + None, + &Default::default(), + None, + ) + .await + .expect("delete_item should succeed"); + + // Read partition_max_delete_timestamp from the remaining row (STATIC column + // is shared across all rows in the partition) + let account_keyspace = format!("extenddb_ttl_test_account_{}", table.key_info.account_id); + let data_table = format!("items_{}", table.key_info.table_id.replace("-", "_")); + + let query = format!( + "SELECT partition_max_delete_timestamp FROM {}.{} WHERE pk = ? LIMIT 1", + account_keyspace, data_table + ); + let result = engine + .session_arc() + .query_with_values(&query, cdrs_tokio::query_values!("pk1")) + .await + .expect("SELECT should succeed"); + + let body = result.response_body().expect("response_body"); + let rows = body.into_rows().expect("should have rows"); + let row = rows + .into_iter() + .next() + .expect("should have at least one row"); + + let max_ts: Option = row + .get_by_name("partition_max_delete_timestamp") + .ok() + .flatten(); + + assert!( + max_ts.is_some(), + "partition_max_delete_timestamp should be set after delete" + ); + assert!( + max_ts.unwrap() > 0, + "partition_max_delete_timestamp should be a positive timestamp" + ); +} + +#[tokio::test] +async fn test_delete_item_partition_max_timestamp_increases_on_subsequent_deletes() { + if crate::helpers::skip_without_cassandra() { + return; + } + use cdrs_tokio::types::IntoRustByName; + + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TxnDelTsInc", true).await; + + // Put two items in the same partition + let mut item1 = BTreeMap::new(); + item1.insert("id".to_string(), AttributeValue::S("pk1".to_string())); + item1.insert("sort".to_string(), AttributeValue::S("sk1".to_string())); + item1.insert("data".to_string(), AttributeValue::S("val1".to_string())); + + let mut item2 = BTreeMap::new(); + item2.insert("id".to_string(), AttributeValue::S("pk1".to_string())); + item2.insert("sort".to_string(), AttributeValue::S("sk2".to_string())); + item2.insert("data".to_string(), AttributeValue::S("val2".to_string())); + + engine + .put_item( + &table.key_info, + item1, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item 1"); + engine + .put_item( + &table.key_info, + item2, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item 2"); + + // Delete first item + let mut key1 = BTreeMap::new(); + key1.insert("id".to_string(), AttributeValue::S("pk1".to_string())); + key1.insert("sort".to_string(), AttributeValue::S("sk1".to_string())); + + engine + .delete_item( + &table.key_info, + &key1, + false, + None, + &Default::default(), + None, + ) + .await + .expect("delete_item 1"); + + // Read first timestamp + let account_keyspace = format!("extenddb_ttl_test_account_{}", table.key_info.account_id); + let data_table = format!("items_{}", table.key_info.table_id.replace("-", "_")); + + let query = format!( + "SELECT partition_max_delete_timestamp FROM {}.{} WHERE pk = ? LIMIT 1", + account_keyspace, data_table + ); + let result = engine + .session_arc() + .query_with_values(&query, cdrs_tokio::query_values!("pk1")) + .await + .unwrap(); + let body = result.response_body().unwrap(); + let rows = body.into_rows().unwrap(); + let row = rows.into_iter().next().unwrap(); + let ts1: i64 = row + .get_by_name("partition_max_delete_timestamp") + .ok() + .flatten() + .expect("ts1 should be set"); + + // Small delay to ensure different timestamp + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + + // Re-create and delete second item + let mut item2_again = BTreeMap::new(); + item2_again.insert("id".to_string(), AttributeValue::S("pk1".to_string())); + item2_again.insert("sort".to_string(), AttributeValue::S("sk2".to_string())); + item2_again.insert("data".to_string(), AttributeValue::S("val2b".to_string())); + + // sk2 still exists from initial put, so just delete it + let mut key2 = BTreeMap::new(); + key2.insert("id".to_string(), AttributeValue::S("pk1".to_string())); + key2.insert("sort".to_string(), AttributeValue::S("sk2".to_string())); + + engine + .delete_item( + &table.key_info, + &key2, + false, + None, + &Default::default(), + None, + ) + .await + .expect("delete_item 2"); + + // Re-insert an item so we can read the STATIC column + let mut item3 = BTreeMap::new(); + item3.insert("id".to_string(), AttributeValue::S("pk1".to_string())); + item3.insert("sort".to_string(), AttributeValue::S("sk3".to_string())); + item3.insert("data".to_string(), AttributeValue::S("val3".to_string())); + + engine + .put_item( + &table.key_info, + item3, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item 3"); + + let result2 = engine + .session_arc() + .query_with_values(&query, cdrs_tokio::query_values!("pk1")) + .await + .unwrap(); + let body2 = result2.response_body().unwrap(); + let rows2 = body2.into_rows().unwrap(); + let row2 = rows2.into_iter().next().unwrap(); + let ts2: i64 = row2 + .get_by_name("partition_max_delete_timestamp") + .ok() + .flatten() + .expect("ts2 should be set"); + + assert!( + ts2 >= ts1, + "partition_max_delete_timestamp should not decrease: ts1={}, ts2={}", + ts1, + ts2 + ); +} + +#[tokio::test] +async fn test_transaction_put_rejected_by_partition_max_delete_timestamp() { + if crate::helpers::skip_without_cassandra() { + return; + } + use extenddb_core::expression::ExpressionMaps; + use extenddb_core::types::ReturnValuesOnConditionCheckFailure; + use extenddb_storage::TransactWriteOp; + use extenddb_storage::error::StorageError; + + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TxnDelTsBlock", true).await; + + // Put an item in the partition so we have a row to hold the STATIC column + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S("pk1".to_string())); + item.insert("sort".to_string(), AttributeValue::S("sk-keep".to_string())); + item.insert("data".to_string(), AttributeValue::S("anchor".to_string())); + + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item anchor should succeed"); + + // Manually set partition_max_delete_timestamp to a far-future value. + // This simulates a delete that happened "after" any transaction that's + // currently in-flight would have started. + let account_keyspace = format!("extenddb_ttl_test_account_{}", table.key_info.account_id); + let data_table = format!("items_{}", table.key_info.table_id.replace("-", "_")); + + let far_future_ts: i64 = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64 + + 600_000; // 10 minutes in the future + + let update_query = format!( + "UPDATE {}.{} SET partition_max_delete_timestamp = ? WHERE pk = ?", + account_keyspace, data_table + ); + engine + .session_arc() + .query_with_values( + &update_query, + cdrs_tokio::query_values!(far_future_ts, "pk1"), + ) + .await + .expect("Setting partition_max_delete_timestamp should succeed"); + + // Now try a transaction that puts a NEW item in the same partition. + // The transaction's timestamp will be less than partition_max_delete_timestamp, + // so it should be rejected. + let mut new_item = BTreeMap::new(); + new_item.insert("id".to_string(), AttributeValue::S("pk1".to_string())); + new_item.insert("sort".to_string(), AttributeValue::S("sk-new".to_string())); + new_item.insert("data".to_string(), AttributeValue::S("stale".to_string())); + + let maps = ExpressionMaps::default(); + let ops = vec![TransactWriteOp::Put { + key_info: &table.key_info, + item: &new_item, + condition: None, + maps: &maps, + return_values_on_ccf: ReturnValuesOnConditionCheckFailure::None, + stream: None, + }]; + + let result = engine.transact_write_items(&ops, None).await; + + match result { + Err(StorageError::TransactionCanceled(reasons)) => { + // The cancellation reason should indicate the item was rejected + assert!( + !reasons.is_empty(), + "Should have at least one cancellation reason" + ); + // The specific message is "Item was deleted at a later timestamp" + assert!( + reasons[0] + .message + .as_deref() + .unwrap_or("") + .contains("deleted"), + "Expected deletion-related rejection, got: {:?}", + reasons[0] + ); + } + Ok(()) => { + panic!("Transaction should have been rejected due to partition_max_delete_timestamp") + } + Err(other) => panic!("Expected TransactionCanceled, got: {:?}", other), + } + + // Verify the new item was NOT created + let mut key = BTreeMap::new(); + key.insert("id".to_string(), AttributeValue::S("pk1".to_string())); + key.insert("sort".to_string(), AttributeValue::S("sk-new".to_string())); + + let retrieved = engine + .get_item(&table.key_info, &key) + .await + .expect("get_item should succeed"); + assert!( + retrieved.is_none(), + "Item should not exist - transaction was rejected" + ); +} diff --git a/crates/storage-cassandra/tests/direct/groups.rs b/crates/storage-cassandra/tests/direct/groups.rs new file mode 100644 index 00000000..53868936 --- /dev/null +++ b/crates/storage-cassandra/tests/direct/groups.rs @@ -0,0 +1,182 @@ +// Copyright 2026 ExtendDB Contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Integration tests for Group management operations for `CassandraCatalogStore`. + +#[cfg(test)] +mod tests { + use crate::helpers::{setup_engine, test_config, unique_test_account, unique_test_id}; + use extenddb_storage::management_store::ManagementStore; + + #[tokio::test] + async fn test_group_lifecycle() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let account_id = unique_test_account(); + let account_name = format!("TestAccount_{}", unique_test_id()); + let group_name = format!("testgroup_{}", unique_test_id()); + + catalog_store + .create_account(&account_id, &account_name) + .await + .expect("Failed to create account"); + + // Happy case: create group + catalog_store + .create_group(&account_id, &group_name) + .await + .expect("Failed to create group"); + + println!("✓ Group created successfully"); + + // Unhappy case: duplicate group + let result = catalog_store.create_group(&account_id, &group_name).await; + + match result { + Err(extenddb_storage::management_store::OpError::AlreadyExists(_)) => { + println!("✓ Duplicate group correctly rejected"); + } + other => panic!("Expected AlreadyExists, got: {:?}", other), + } + + // Happy case: list groups + let groups = catalog_store + .list_groups(&account_id) + .await + .expect("Failed to list groups"); + + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].1, group_name); + println!("✓ Group listed successfully"); + + // Happy case: delete group + catalog_store + .delete_group(&account_id, &group_name) + .await + .expect("Failed to delete group"); + + println!("✓ Group deleted successfully"); + + // Unhappy case: delete non-existent group + let result = catalog_store.delete_group(&account_id, &group_name).await; + + match result { + Err(extenddb_storage::management_store::OpError::NotFound(_)) => { + println!("✓ Delete non-existent group correctly rejected"); + } + other => panic!("Expected NotFound, got: {:?}", other), + } + } + + #[tokio::test] + async fn test_group_members() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let account_id = unique_test_account(); + let account_name = format!("TestAccount_{}", unique_test_id()); + let group_name = format!("testgroup_{}", unique_test_id()); + let user1 = format!("user1_{}", unique_test_id()); + let user2 = format!("user2_{}", unique_test_id()); + + // Setup + catalog_store + .create_account(&account_id, &account_name) + .await + .expect("Failed to create account"); + + catalog_store + .create_group(&account_id, &group_name) + .await + .expect("Failed to create group"); + + catalog_store + .create_user(&account_id, &user1, None) + .await + .expect("Failed to create user1"); + + catalog_store + .create_user(&account_id, &user2, None) + .await + .expect("Failed to create user2"); + + // Happy case: add member + catalog_store + .add_group_member(&account_id, &group_name, &user1) + .await + .expect("Failed to add member"); + + println!("✓ Member added successfully"); + + // Unhappy case: add duplicate member + let result = catalog_store + .add_group_member(&account_id, &group_name, &user1) + .await; + + match result { + Err(extenddb_storage::management_store::OpError::AlreadyExists(_)) => { + println!("✓ Duplicate member correctly rejected"); + } + other => panic!("Expected AlreadyExists, got: {:?}", other), + } + + // Add second member + catalog_store + .add_group_member(&account_id, &group_name, &user2) + .await + .expect("Failed to add second member"); + + // Happy case: get group detail + let detail = catalog_store + .get_group_detail(&account_id, &group_name) + .await + .expect("Failed to get group detail") + .expect("Group detail should exist"); + + assert_eq!(detail.members.len(), 2); + assert!(detail.members.contains(&user1)); + assert!(detail.members.contains(&user2)); + assert_eq!(detail.all_users.len(), 2); + println!("✓ Group detail retrieved successfully"); + + // Happy case: remove member + catalog_store + .remove_group_member(&account_id, &group_name, &user1) + .await + .expect("Failed to remove member"); + + println!("✓ Member removed successfully"); + + // Unhappy case: remove non-existent membership + let result = catalog_store + .remove_group_member(&account_id, &group_name, &user1) + .await; + + match result { + Err(extenddb_storage::management_store::OpError::NotFound(_)) => { + println!("✓ Remove non-existent membership correctly rejected"); + } + other => panic!("Expected NotFound, got: {:?}", other), + } + + // Verify only one member remains + let detail = catalog_store + .get_group_detail(&account_id, &group_name) + .await + .expect("Failed to get group detail") + .expect("Group detail should exist"); + + assert_eq!(detail.members.len(), 1); + assert!(detail.members.contains(&user2)); + println!("✓ Membership update verified"); + } +} diff --git a/crates/storage-cassandra/tests/direct/index.rs b/crates/storage-cassandra/tests/direct/index.rs new file mode 100644 index 00000000..ca2e0b53 --- /dev/null +++ b/crates/storage-cassandra/tests/direct/index.rs @@ -0,0 +1,848 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Integration tests for GSI/LSI index operations. + +use extenddb_core::types::{ + AttributeDefinition, AttributeValue, Item, KeySchemaElement, KeyType, Projection, + ProjectionType, ScalarAttributeType, +}; +use extenddb_storage_cassandra::CassandraEngine; + +use crate::helpers::{ensure_test_account, test_config, unique_test_account, unique_test_id}; + +#[tokio::test] +async fn test_create_and_drop_index_table() { + if crate::helpers::skip_without_cassandra() { + return; + } + let config = test_config(); + let engine = CassandraEngine::new(&config, "us-east-1").await.unwrap(); + let account_id = unique_test_account(); + let index_id = unique_test_id(); + + ensure_test_account(&engine, &account_id).await.unwrap(); + + let account_keyspace = engine.account_keyspace(&account_id); + + // Base table key schema: pk (HASH), sk (RANGE) + let base_key_schema = vec![ + KeySchemaElement { + attribute_name: "pk".to_owned(), + key_type: KeyType::Hash, + }, + KeySchemaElement { + attribute_name: "sk".to_owned(), + key_type: KeyType::Range, + }, + ]; + + let base_attr_defs = vec![ + AttributeDefinition { + attribute_name: "pk".to_owned(), + attribute_type: ScalarAttributeType::S, + }, + AttributeDefinition { + attribute_name: "sk".to_owned(), + attribute_type: ScalarAttributeType::N, + }, + ]; + + // Index key schema: gsi_pk (HASH), gsi_sk (RANGE) + let index_key_schema = vec![ + KeySchemaElement { + attribute_name: "gsi_pk".to_owned(), + key_type: KeyType::Hash, + }, + KeySchemaElement { + attribute_name: "gsi_sk".to_owned(), + key_type: KeyType::Range, + }, + ]; + + let index_attr_defs = vec![ + AttributeDefinition { + attribute_name: "gsi_pk".to_owned(), + attribute_type: ScalarAttributeType::S, + }, + AttributeDefinition { + attribute_name: "gsi_sk".to_owned(), + attribute_type: ScalarAttributeType::S, + }, + ]; + + // Create index table + engine + .create_index_data_table( + &account_keyspace, + &index_id, + &index_key_schema, + &index_attr_defs, + &base_key_schema, + &base_attr_defs, + ) + .await + .unwrap(); + + // Verify table exists by querying system schema + let table_name = format!("index_{}", index_id.replace("-", "_")); + let query = + "SELECT table_name FROM system_schema.tables WHERE keyspace_name = ? AND table_name = ?" + .to_string(); + let result = engine + .session() + .query_with_values( + &query, + cdrs_tokio::query_values!(account_keyspace.as_str(), table_name.as_str()), + ) + .await + .unwrap(); + + let body = result.response_body().unwrap(); + let rows = body.into_rows().unwrap(); + assert_eq!(rows.len(), 1); + + // Drop index table + engine + .drop_index_data_table(&account_keyspace, &index_id) + .await + .unwrap(); + + // Verify table is gone + let result = engine + .session() + .query_with_values( + &query, + cdrs_tokio::query_values!(account_keyspace.as_str(), table_name.as_str()), + ) + .await + .unwrap(); + + let body = result.response_body().unwrap(); + let rows = body.into_rows().unwrap_or_default(); + assert_eq!(rows.len(), 0); +} + +#[tokio::test] +async fn test_fetch_indexes_for_table() { + if crate::helpers::skip_without_cassandra() { + return; + } + let config = test_config(); + let engine = CassandraEngine::new(&config, "us-east-1").await.unwrap(); + let account_id = unique_test_account(); + let table_id = unique_test_id(); + + ensure_test_account(&engine, &account_id).await.unwrap(); + + let catalog_keyspace = engine.catalog_keyspace(); + + // Insert test index metadata into catalog + let index_id = unique_test_id(); + let key_schema = serde_json::json!([ + {"AttributeName": "gsi_pk", "KeyType": "HASH"}, + {"AttributeName": "gsi_sk", "KeyType": "RANGE"} + ]) + .to_string(); + + let projection = serde_json::json!({ + "ProjectionType": "ALL" + }) + .to_string(); + + let insert_query = format!( + "INSERT INTO {}.indexes (table_id, index_name, index_id, index_type, key_schema, projection, index_status, propagation_delay_ms) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + catalog_keyspace + ); + + engine + .session() + .query_with_values( + &insert_query, + cdrs_tokio::query_values!( + table_id.as_str(), + "test_gsi", + index_id.as_str(), + "GSI", + key_schema.as_str(), + projection.as_str(), + "ACTIVE", + 1000 + ), + ) + .await + .unwrap(); + + // Fetch indexes + let indexes = extenddb_storage_cassandra::data::index::fetch_indexes_for_table( + &table_id, + &engine.session_arc(), + &catalog_keyspace, + ) + .await + .unwrap(); + + assert_eq!(indexes.len(), 1); + assert_eq!(indexes[0].index_name, "test_gsi"); + assert_eq!(indexes[0].index_id, index_id); + assert_eq!(indexes[0].index_type, "GSI"); + assert_eq!(indexes[0].key_schema.len(), 2); + assert_eq!(indexes[0].propagation_delay_ms, Some(1000)); + assert_eq!(indexes[0].projection.projection_type, ProjectionType::All); + + // Cleanup + let delete_query = format!( + "DELETE FROM {}.indexes WHERE table_id = ? AND index_name = ?", + catalog_keyspace + ); + engine + .session() + .query_with_values( + &delete_query, + cdrs_tokio::query_values!(table_id.as_str(), "test_gsi"), + ) + .await + .ok(); +} + +#[tokio::test] +async fn test_index_table_primary_key_structure() { + if crate::helpers::skip_without_cassandra() { + return; + } + let config = test_config(); + let engine = CassandraEngine::new(&config, "us-east-1").await.unwrap(); + let account_id = unique_test_account(); + let index_id = unique_test_id(); + + ensure_test_account(&engine, &account_id).await.unwrap(); + + let account_keyspace = engine.account_keyspace(&account_id); + + // Base table: pk (S), sk (N) + let base_key_schema = vec![ + KeySchemaElement { + attribute_name: "pk".to_owned(), + key_type: KeyType::Hash, + }, + KeySchemaElement { + attribute_name: "sk".to_owned(), + key_type: KeyType::Range, + }, + ]; + + let base_attr_defs = vec![ + AttributeDefinition { + attribute_name: "pk".to_owned(), + attribute_type: ScalarAttributeType::S, + }, + AttributeDefinition { + attribute_name: "sk".to_owned(), + attribute_type: ScalarAttributeType::N, + }, + ]; + + // GSI: gsi_pk (S), gsi_sk (S) + let index_key_schema = vec![ + KeySchemaElement { + attribute_name: "gsi_pk".to_owned(), + key_type: KeyType::Hash, + }, + KeySchemaElement { + attribute_name: "gsi_sk".to_owned(), + key_type: KeyType::Range, + }, + ]; + + let index_attr_defs = vec![ + AttributeDefinition { + attribute_name: "gsi_pk".to_owned(), + attribute_type: ScalarAttributeType::S, + }, + AttributeDefinition { + attribute_name: "gsi_sk".to_owned(), + attribute_type: ScalarAttributeType::S, + }, + ]; + + engine + .create_index_data_table( + &account_keyspace, + &index_id, + &index_key_schema, + &index_attr_defs, + &base_key_schema, + &base_attr_defs, + ) + .await + .unwrap(); + + let table_name = format!("index_{}", index_id.replace("-", "_")); + + // Query system schema to verify PRIMARY KEY structure + let query = "SELECT column_name, kind, position FROM system_schema.columns \ + WHERE keyspace_name = ? AND table_name = ?" + .to_string(); + + let result = engine + .session() + .query_with_values( + &query, + cdrs_tokio::query_values!(account_keyspace.as_str(), table_name.as_str()), + ) + .await + .unwrap(); + + let body = result.response_body().unwrap(); + let mut rows = body.into_rows().unwrap(); + + // Sort by position in application (can't ORDER BY non-clustering column) + rows.sort_by_key(|row| { + use cdrs_tokio::types::IntoRustByName; + let pos: i32 = row.get_r_by_name("position").unwrap_or(0); + pos + }); + + // Verify PRIMARY KEY order: (pk) as partition key, then sk_s, base_pk, base_sk_n as clustering + let mut partition_keys = Vec::new(); + let mut clustering_keys = Vec::new(); + + for row in rows { + use cdrs_tokio::types::IntoRustByName; + let col_name: String = row.get_r_by_name("column_name").unwrap(); + let kind: String = row.get_r_by_name("kind").unwrap(); + + match kind.as_str() { + "partition_key" => partition_keys.push(col_name), + "clustering" => clustering_keys.push(col_name), + _ => {} + } + } + + // Verify structure + assert_eq!(partition_keys, vec!["pk"]); + assert_eq!( + clustering_keys, + vec!["sk_s", "base_pk", "base_sk_n"], + "Clustering keys should be: index SK (sk_s), then base keys (base_pk, base_sk_n)" + ); + + // Cleanup + engine + .drop_index_data_table(&account_keyspace, &index_id) + .await + .unwrap(); +} + +#[tokio::test] +async fn test_sync_indexes_insert_and_delete() { + if crate::helpers::skip_without_cassandra() { + return; + } + let config = test_config(); + let engine = CassandraEngine::new(&config, "us-east-1").await.unwrap(); + let account_id = unique_test_account(); + let _table_id = unique_test_id(); + let index_id = unique_test_id(); + + ensure_test_account(&engine, &account_id).await.unwrap(); + + let account_keyspace = engine.account_keyspace(&account_id); + + // Base table: pk (S), sk (N) + let base_key_schema = vec![ + KeySchemaElement { + attribute_name: "pk".to_owned(), + key_type: KeyType::Hash, + }, + KeySchemaElement { + attribute_name: "sk".to_owned(), + key_type: KeyType::Range, + }, + ]; + + let base_attr_defs = vec![ + AttributeDefinition { + attribute_name: "pk".to_owned(), + attribute_type: ScalarAttributeType::S, + }, + AttributeDefinition { + attribute_name: "sk".to_owned(), + attribute_type: ScalarAttributeType::N, + }, + AttributeDefinition { + attribute_name: "gsi_pk".to_owned(), + attribute_type: ScalarAttributeType::S, + }, + ]; + + // GSI: gsi_pk (S) with sync delay + let index_key_schema = vec![KeySchemaElement { + attribute_name: "gsi_pk".to_owned(), + key_type: KeyType::Hash, + }]; + + let index_attr_defs = vec![AttributeDefinition { + attribute_name: "gsi_pk".to_owned(), + attribute_type: ScalarAttributeType::S, + }]; + + // Create index table + engine + .create_index_data_table( + &account_keyspace, + &index_id, + &index_key_schema, + &index_attr_defs, + &base_key_schema, + &base_attr_defs, + ) + .await + .unwrap(); + + // Create an index metadata entry + let indexes = vec![extenddb_storage_cassandra::data::index::IndexMeta { + index_name: "test_gsi".to_owned(), + index_id: index_id.clone(), + index_type: "GSI".to_owned(), + key_schema: index_key_schema.clone(), + projection: Projection { + projection_type: ProjectionType::All, + non_key_attributes: None, + }, + propagation_delay_ms: Some(0), // Sync + }]; + + // Create a test item + let mut item = Item::new(); + item.insert("pk".to_owned(), AttributeValue::S("test_pk".to_owned())); + item.insert("sk".to_owned(), AttributeValue::N("123".to_owned())); + item.insert( + "gsi_pk".to_owned(), + AttributeValue::S("gsi_value".to_owned()), + ); + item.insert("data".to_owned(), AttributeValue::S("some_data".to_owned())); + + // Test sync_indexes for INSERT + let mut batch = cdrs_tokio::query::BatchQueryBuilder::new(); + extenddb_storage_cassandra::data::index::sync_indexes( + &mut batch, + &account_keyspace, + &base_key_schema, + &base_attr_defs, + &indexes, + None, + Some(&item), + 1000, + ) + .unwrap(); + + let built = batch.build().unwrap(); + assert_eq!( + built.request.queries.len(), + 1, + "Expected 1 INSERT statement" + ); + + // Execute the batch + engine.session().batch(built).await.unwrap(); + + // Verify the row was inserted + let idx_table = format!("index_{}", index_id.replace("-", "_")); + let query = format!( + "SELECT item_data FROM {}.{} WHERE pk = 'gsi_value' AND base_pk = 'test_pk' AND base_sk_n = 123", + account_keyspace, idx_table + ); + println!("SELECT query: {}", query); + let result = engine.session().query(&query).await.unwrap(); + let body = result.response_body().unwrap(); + let rows = body.into_rows().unwrap(); + assert_eq!(rows.len(), 1); + + // Test sync_indexes for DELETE + let mut batch = cdrs_tokio::query::BatchQueryBuilder::new(); + extenddb_storage_cassandra::data::index::sync_indexes( + &mut batch, + &account_keyspace, + &base_key_schema, + &base_attr_defs, + &indexes, + Some(&item), + None, + 1000, + ) + .unwrap(); + + let built = batch.build().unwrap(); + assert_eq!( + built.request.queries.len(), + 1, + "Expected 1 DELETE statement" + ); + + // Execute the batch + engine.session().batch(built).await.unwrap(); + + // Verify the row was deleted + let result = engine.session().query(&query).await.unwrap(); + let body = result.response_body().unwrap(); + let rows = body.into_rows().unwrap_or_default(); + assert_eq!(rows.len(), 0); + + // Cleanup + engine + .drop_index_data_table(&account_keyspace, &index_id) + .await + .unwrap(); +} + +#[tokio::test] +async fn test_sync_indexes_skips_async_gsi() { + if crate::helpers::skip_without_cassandra() { + return; + } + let config = test_config(); + let engine = CassandraEngine::new(&config, "us-east-1").await.unwrap(); + let account_id = unique_test_account(); + let _table_id = unique_test_id(); + + ensure_test_account(&engine, &account_id).await.unwrap(); + + let account_keyspace = engine.account_keyspace(&account_id); + + let base_key_schema = vec![KeySchemaElement { + attribute_name: "pk".to_owned(), + key_type: KeyType::Hash, + }]; + + let base_attr_defs = vec![ + AttributeDefinition { + attribute_name: "pk".to_owned(), + attribute_type: ScalarAttributeType::S, + }, + AttributeDefinition { + attribute_name: "gsi_pk".to_owned(), + attribute_type: ScalarAttributeType::S, + }, + ]; + + // Async GSI with delay > 0 + let indexes = vec![extenddb_storage_cassandra::data::index::IndexMeta { + index_name: "async_gsi".to_owned(), + index_id: unique_test_id(), + index_type: "GSI".to_owned(), + key_schema: vec![KeySchemaElement { + attribute_name: "gsi_pk".to_owned(), + key_type: KeyType::Hash, + }], + projection: Projection { + projection_type: ProjectionType::KeysOnly, + non_key_attributes: None, + }, + propagation_delay_ms: Some(1000), // Async + }]; + + let mut item = Item::new(); + item.insert("pk".to_owned(), AttributeValue::S("test".to_owned())); + item.insert("gsi_pk".to_owned(), AttributeValue::S("value".to_owned())); + + // sync_indexes should NOT add any statements for async GSI + let mut batch = cdrs_tokio::query::BatchQueryBuilder::new(); + extenddb_storage_cassandra::data::index::sync_indexes( + &mut batch, + &account_keyspace, + &base_key_schema, + &base_attr_defs, + &indexes, + None, + Some(&item), + 500, // System default < GSI delay + ) + .unwrap(); + + let built = batch.build().unwrap(); + assert_eq!( + built.request.queries.len(), + 0, + "Async GSI should not generate sync statements" + ); +} + +// ── Async GSI queue integration tests ──────────────────────────────────────── + +use extenddb_core::expression::ExpressionMaps; +use extenddb_storage::DataEngine as _; +use std::sync::Arc; + +/// Set the propagation delay for a GSI in the catalog. +async fn set_gsi_delay(engine: &CassandraEngine, table_id: &str, gsi_name: &str, delay_ms: i32) { + let catalog_keyspace = engine.catalog_keyspace(); + let cql = format!( + "UPDATE {catalog_keyspace}.indexes SET propagation_delay_ms = ? \ + WHERE table_id = ? AND index_name = ?" + ); + engine + .session() + .query_with_values( + &cql, + cdrs_tokio::query_values!(delay_ms, table_id, gsi_name), + ) + .await + .expect("set_gsi_delay"); +} + +/// Count rows in `gsi_pending` for a given account keyspace. +async fn gsi_pending_count(engine: &CassandraEngine, account_keyspace: &str) -> usize { + // Filter out static-column-only rows (ready_at=null) which persist after + // all clustering rows are deleted but last_ready_at remains set. + let cql = format!("SELECT id FROM {account_keyspace}.gsi_pending"); + engine + .session() + .query(&cql) + .await + .ok() + .and_then(|f| f.response_body().ok()) + .and_then(|b| b.into_rows()) + .map(|rows| { + use cdrs_tokio::types::IntoRustByName as _; + rows.iter() + .filter(|row| { + let id: Result = row.get_r_by_name("id"); + id.is_ok() + }) + .count() + }) + .unwrap_or(0) +} + +/// Query a GSI and return the count of matching items. +async fn gsi_query_count( + engine: &CassandraEngine, + table: &crate::helpers::TestTable, + gsi_name: &str, + gsi_pk_attr: &str, + gsi_pk_value: &str, +) -> usize { + use extenddb_core::expression::{Expr, KeyCondition, PathElement}; + let key_condition = KeyCondition { + pk_path: vec![PathElement::Attribute(gsi_pk_attr.to_string())], + pk_value: Expr::Placeholder(":v".to_string()), + sk_condition: None, + extra_pk_conditions: vec![], + extra_sk_conditions: vec![], + }; + let mut maps = ExpressionMaps::default(); + maps.values.insert( + ":v".to_string(), + AttributeValue::S(gsi_pk_value.to_string()), + ); + engine + .query( + &table.key_info, + &key_condition, + &maps, + true, + None, + None, + Some(gsi_name), + ) + .await + .map(|(items, _)| items.len()) + .unwrap_or(0) +} + +#[tokio::test] +async fn test_async_gsi_enqueues_row_atomically() { + if crate::helpers::skip_without_cassandra() { + return; + } + // A put_item with an async GSI must write a gsi_pending row in the same + // batch as the base write — visible immediately, before the worker runs. + let config = test_config(); + let engine = CassandraEngine::new(&config, "us-east-1").await.unwrap(); + let table = + crate::helpers::TestTable::with_gsi(&engine, "AsyncGsiEnqueueTable", "GsiIdx", "gpk").await; + + // Long delay: worker will not apply before we check. + set_gsi_delay(&engine, &table.key_info.table_id, "GsiIdx", 30_000).await; + + let account_keyspace = engine.account_keyspace(&table.key_info.account_id); + let maps = ExpressionMaps::default(); + + let mut item = Item::new(); + item.insert("id".to_string(), AttributeValue::S("x".to_string())); + item.insert("gpk".to_string(), AttributeValue::S("g1".to_string())); + + engine + .put_item(&table.key_info, item, false, None, &maps, None) + .await + .expect("put_item"); + + // Row must be in gsi_pending immediately (same batch as base write). + let pending = gsi_pending_count(&engine, &account_keyspace).await; + assert_eq!( + pending, 1, + "gsi_pending row not written atomically with base write" + ); + + // GSI must not yet be visible (worker hasn't run). + let visible = gsi_query_count(&engine, &table, "GsiIdx", "gpk", "g1").await; + assert_eq!(visible, 0, "GSI entry visible before worker ran"); +} + +#[tokio::test] +async fn test_async_gsi_worker_convergence() { + if crate::helpers::skip_without_cassandra() { + return; + } + // Successive writes to the same base item must converge to the latest GSI + // entry — no stale entries from earlier writes. + let config = test_config(); + let engine = Arc::new(CassandraEngine::new(&config, "us-east-1").await.unwrap()); + let table = + crate::helpers::TestTable::with_gsi(&engine, "AsyncGsiConvergeTable", "GsiIdx", "gpk") + .await; + + // Short delay so the worker drains quickly. + set_gsi_delay(&engine, &table.key_info.table_id, "GsiIdx", 50).await; + + let account_keyspace = engine.account_keyspace(&table.key_info.account_id); + let maps = ExpressionMaps::default(); + + // Write the same item 5 times, changing its GSI key each time. + let values = ["v0", "v1", "v2", "v3", "v4"]; + for v in &values { + let mut item = Item::new(); + item.insert("id".to_string(), AttributeValue::S("X".to_string())); + item.insert("gpk".to_string(), AttributeValue::S(v.to_string())); + engine + .put_item(&table.key_info, item, false, None, &maps, None) + .await + .expect("put_item"); + } + + // Spawn workers and wait for the queue to drain. + // Guard is held until end of scope — workers stop when it drops. + let _worker_guard = extenddb_storage_cassandra::workers::spawn_gsi_workers(engine.clone()); + + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); + loop { + let count = gsi_pending_count(&engine, &account_keyspace).await; + println!("gsi_pending count: {count}"); + if count == 0 { + break; + } + if tokio::time::Instant::now() >= deadline { + // Print what's actually in the table before panicking + let cql = format!( + "SELECT worker_partition, ready_at, toTimestamp(now()) as now, table_id FROM {account_keyspace}.gsi_pending" + ); + if let Ok(frame) = engine.session().query(&cql).await + && let Ok(body) = frame.response_body() + && let Some(rows) = body.into_rows() + { + use cdrs_tokio::types::IntoRustByName as _; + for row in &rows { + let wp: i32 = row.get_r_by_name("worker_partition").unwrap_or(-1); + let ready_at: i64 = row.get_r_by_name("ready_at").unwrap_or(0); + let now: i64 = row.get_r_by_name("now").unwrap_or(0); + let tid: String = row.get_r_by_name("table_id").unwrap_or_default(); + println!( + " row: partition={wp} ready_at={ready_at} now={now} diff={}ms table={tid}", + now - ready_at + ); + } + } + panic!("gsi_pending did not drain within timeout"); + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + + // Only the latest GSI key should be present; all earlier ones must be gone. + let latest = values.last().unwrap(); + assert_eq!( + gsi_query_count(&engine, &table, "GsiIdx", "gpk", latest).await, + 1, + "latest GSI entry missing after convergence" + ); + for stale in &values[..values.len() - 1] { + assert_eq!( + gsi_query_count(&engine, &table, "GsiIdx", "gpk", stale).await, + 0, + "stale GSI entry for {stale} survived — updates applied out of order" + ); + } +} + +#[tokio::test] +async fn test_async_gsi_worker_skips_dropped_index() { + if crate::helpers::skip_without_cassandra() { + return; + } + // If the index table is gone (table-deletion race), the worker must consume + // the row (skip + delete) rather than retrying forever. + let config = test_config(); + let engine = Arc::new(CassandraEngine::new(&config, "us-east-1").await.unwrap()); + let table = + crate::helpers::TestTable::with_gsi(&engine, "AsyncGsiDroppedTable", "GsiIdx", "gpk").await; + + set_gsi_delay(&engine, &table.key_info.table_id, "GsiIdx", 50).await; + + let account_keyspace = engine.account_keyspace(&table.key_info.account_id); + let maps = ExpressionMaps::default(); + + // Enqueue a few rows. + for i in 0..3u32 { + let mut item = Item::new(); + item.insert("id".to_string(), AttributeValue::S(format!("pk{i}"))); + item.insert("gpk".to_string(), AttributeValue::S(format!("g{i}"))); + engine + .put_item(&table.key_info, item, false, None, &maps, None) + .await + .expect("put_item"); + } + assert_eq!(gsi_pending_count(&engine, &account_keyspace).await, 3); + + // Look up the index_id and drop the index table. + let catalog_keyspace = engine.catalog_keyspace(); + let cql = format!( + "SELECT index_id FROM {catalog_keyspace}.indexes WHERE table_id = ? AND index_name = ?" + ); + let session = engine.session_arc(); + let rows = extenddb_storage_cassandra::cassandra_util::query_rows::< + extenddb_storage::error::StorageError, + >( + &session, + &cql, + cdrs_tokio::query_values!(table.key_info.table_id.as_str(), "GsiIdx"), + "test_dropped_index", + ) + .await + .unwrap(); + let index_id: String = extenddb_storage_cassandra::cassandra_util::get_column::< + String, + extenddb_storage::error::StorageError, + >(&rows[0], "index_id", "test_dropped_index") + .unwrap(); + + let drop_cql = format!( + "DROP TABLE IF EXISTS {account_keyspace}.{}", + extenddb_storage_cassandra::data::ddl::index_table_name(&index_id) + ); + engine.session().query(&drop_cql).await.unwrap(); + + // Spawn workers — they must drain the queue without looping. + let _worker_guard = extenddb_storage_cassandra::workers::spawn_gsi_workers(engine.clone()); + + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); + loop { + if gsi_pending_count(&engine, &account_keyspace).await == 0 { + break; + } + if tokio::time::Instant::now() >= deadline { + panic!("gsi_pending did not drain after index table was dropped"); + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } +} diff --git a/crates/storage-cassandra/tests/direct/metadata_engine.rs b/crates/storage-cassandra/tests/direct/metadata_engine.rs new file mode 100644 index 00000000..53ea8672 --- /dev/null +++ b/crates/storage-cassandra/tests/direct/metadata_engine.rs @@ -0,0 +1,152 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Integration tests for MetadataEngine tag operations. + +use extenddb_core::types::Tag; +use extenddb_storage::MetadataEngine; + +use crate::helpers::setup_engine; + +#[tokio::test] +async fn test_tag_and_list_tags() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let arn = format!( + "arn:aws:dynamodb:us-east-1:123456789012:table/test-{}", + uuid::Uuid::new_v4().simple() + ); + + let tags = vec![ + Tag { + key: "env".to_string(), + value: "test".to_string(), + }, + Tag { + key: "owner".to_string(), + value: "alice".to_string(), + }, + ]; + + engine + .tag_resource(&arn, &tags) + .await + .expect("tag_resource should succeed"); + + let result = engine + .list_tags(&arn) + .await + .expect("list_tags should succeed"); + assert_eq!(result.len(), 2); + // Cassandra returns in clustering key order + assert_eq!(result[0].key, "env"); + assert_eq!(result[0].value, "test"); + assert_eq!(result[1].key, "owner"); + assert_eq!(result[1].value, "alice"); +} + +#[tokio::test] +async fn test_tag_resource_upserts() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let arn = format!( + "arn:aws:dynamodb:us-east-1:123456789012:table/test-{}", + uuid::Uuid::new_v4().simple() + ); + + engine + .tag_resource( + &arn, + &[Tag { + key: "env".to_string(), + value: "staging".to_string(), + }], + ) + .await + .expect("first tag_resource should succeed"); + + // Overwrite with new value + engine + .tag_resource( + &arn, + &[Tag { + key: "env".to_string(), + value: "prod".to_string(), + }], + ) + .await + .expect("second tag_resource should succeed"); + + let result = engine + .list_tags(&arn) + .await + .expect("list_tags should succeed"); + assert_eq!(result.len(), 1); + assert_eq!(result[0].value, "prod"); +} + +#[tokio::test] +async fn test_untag_resource() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let arn = format!( + "arn:aws:dynamodb:us-east-1:123456789012:table/test-{}", + uuid::Uuid::new_v4().simple() + ); + + let tags = vec![ + Tag { + key: "a".to_string(), + value: "1".to_string(), + }, + Tag { + key: "b".to_string(), + value: "2".to_string(), + }, + Tag { + key: "c".to_string(), + value: "3".to_string(), + }, + ]; + engine + .tag_resource(&arn, &tags) + .await + .expect("tag_resource should succeed"); + + engine + .untag_resource(&arn, &["a".to_string(), "c".to_string()]) + .await + .expect("untag_resource should succeed"); + + let result = engine + .list_tags(&arn) + .await + .expect("list_tags should succeed"); + assert_eq!(result.len(), 1); + assert_eq!(result[0].key, "b"); + assert_eq!(result[0].value, "2"); +} + +#[tokio::test] +async fn test_list_tags_empty() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let arn = format!( + "arn:aws:dynamodb:us-east-1:123456789012:table/test-{}", + uuid::Uuid::new_v4().simple() + ); + + let result = engine + .list_tags(&arn) + .await + .expect("list_tags should succeed"); + assert!(result.is_empty()); +} diff --git a/crates/storage-cassandra/tests/direct/policies.rs b/crates/storage-cassandra/tests/direct/policies.rs new file mode 100644 index 00000000..38136924 --- /dev/null +++ b/crates/storage-cassandra/tests/direct/policies.rs @@ -0,0 +1,84 @@ +// Copyright 2026 ExtendDB Contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Integration tests for Policies management operations for `CassandraCatalogStore`. + +#[cfg(test)] +mod tests { + use crate::helpers::{setup_engine, test_config, unique_test_account, unique_test_id}; + use extenddb_storage::management_store::ManagementStore; + + #[tokio::test] + async fn test_put_policy() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let account_id = unique_test_account(); + let account_name = format!("TestAccount_{}", unique_test_id()); + let user_name = format!("testuser_{}", unique_test_id()); + + catalog_store + .create_account(&account_id, &account_name) + .await + .expect("Failed to create account"); + + catalog_store + .create_user(&account_id, &user_name, None) + .await + .expect("Failed to create user"); + + let policy_doc = serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": "dynamodb:*", + "Resource": "*" + }] + }); + + catalog_store + .put_policy(&account_id, "user", &user_name, "TestPolicy", &policy_doc) + .await + .expect("Failed to put policy"); + + println!("✓ Policy created successfully"); + + let updated_policy = serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": "dynamodb:GetItem", + "Resource": "*" + }] + }); + + catalog_store + .put_policy( + &account_id, + "user", + &user_name, + "TestPolicy", + &updated_policy, + ) + .await + .expect("Failed to update policy"); + + // Read back: the update must be visible, not just accepted. + let policies = catalog_store + .list_policies(&account_id, "user", &user_name) + .await + .expect("Failed to list policies"); + let (_, stored_document, _) = policies + .iter() + .find(|(name, _, _)| name == "TestPolicy") + .expect("updated policy missing from list"); + assert!( + stored_document.to_string().contains("dynamodb:GetItem"), + "policy read-back does not reflect the update: {stored_document}" + ); + } +} diff --git a/crates/storage-cassandra/tests/direct/put_get_item.rs b/crates/storage-cassandra/tests/direct/put_get_item.rs new file mode 100644 index 00000000..df918654 --- /dev/null +++ b/crates/storage-cassandra/tests/direct/put_get_item.rs @@ -0,0 +1,708 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Direct integration tests for put_item and get_item operations. + +use extenddb_core::types::{AttributeValue, ScalarAttributeType}; +use extenddb_storage::DataEngine; +use std::collections::BTreeMap; + +use crate::helpers::{TestTable, setup_engine}; + +#[tokio::test] +async fn test_put_and_get_item_pk_only() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TestPkOnlyTable", false).await; + + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S("test-id-1".to_string())); + item.insert( + "name".to_string(), + AttributeValue::S("Test Item".to_string()), + ); + item.insert("count".to_string(), AttributeValue::N("42".to_string())); + + engine + .put_item( + &table.key_info, + item.clone(), + false, + None, + &Default::default(), + None, + ) + .await + .expect("Put failed"); + + let mut key = BTreeMap::new(); + key.insert("id".to_string(), AttributeValue::S("test-id-1".to_string())); + + let retrieved = engine + .get_item(&table.key_info, &key) + .await + .expect("Get failed") + .expect("Item should exist"); + + assert_eq!(retrieved.get("id"), item.get("id")); + assert_eq!(retrieved.get("name"), item.get("name")); + assert_eq!(retrieved.get("count"), item.get("count")); +} + +#[tokio::test] +async fn test_put_and_get_item_with_string_sk() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TestStringSkTable", true).await; + + let mut item = BTreeMap::new(); + item.insert( + "id".to_string(), + AttributeValue::S("partition1".to_string()), + ); + item.insert("sort".to_string(), AttributeValue::S("sort1".to_string())); + item.insert( + "data".to_string(), + AttributeValue::S("test data".to_string()), + ); + + engine + .put_item( + &table.key_info, + item.clone(), + false, + None, + &Default::default(), + None, + ) + .await + .expect("Put failed"); + + let mut key = BTreeMap::new(); + key.insert( + "id".to_string(), + AttributeValue::S("partition1".to_string()), + ); + key.insert("sort".to_string(), AttributeValue::S("sort1".to_string())); + + let retrieved = engine + .get_item(&table.key_info, &key) + .await + .expect("Get failed") + .expect("Item should exist"); + + assert_eq!( + retrieved.get("data"), + Some(&AttributeValue::S("test data".to_string())) + ); +} + +#[tokio::test] +async fn test_put_and_get_item_with_number_sk() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = + TestTable::with_sort_key_type(&engine, "TestNumberSkTable", ScalarAttributeType::N).await; + + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S("user-1".to_string())); + item.insert("sort".to_string(), AttributeValue::N("100".to_string())); + item.insert("value".to_string(), AttributeValue::S("data".to_string())); + + engine + .put_item( + &table.key_info, + item.clone(), + false, + None, + &Default::default(), + None, + ) + .await + .expect("Put failed"); + + let mut key = BTreeMap::new(); + key.insert("id".to_string(), AttributeValue::S("user-1".to_string())); + key.insert("sort".to_string(), AttributeValue::N("100".to_string())); + + let retrieved = engine + .get_item(&table.key_info, &key) + .await + .expect("Get failed") + .expect("Item should exist"); + + assert_eq!(retrieved.get("value"), item.get("value")); +} + +#[tokio::test] +async fn test_put_and_get_item_with_decimal_sk() { + if crate::helpers::skip_without_cassandra() { + return; + } + // Regression test for the numeric-sort-key decimal support (Technical + // Debt #1). DynamoDB's N type is an arbitrary-precision decimal; the + // `sk_n` column is now `decimal` and N values bind as a real Cassandra + // decimal, so fractional/high-precision sort keys must round-trip exactly. + let engine = setup_engine().await; + let table = + TestTable::with_sort_key_type(&engine, "TestDecimalSkTable", ScalarAttributeType::N).await; + + // A range of values that a varint column or string binding could not + // represent: fractions, negatives, and a high-precision value. + let decimal_keys = [ + "123.456", + "0.0000000001", + "-42.5", + "3.14159265358979323846", + "1000000000000.000001", + ]; + + for (i, sk) in decimal_keys.iter().enumerate() { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S("dec-pk".to_string())); + item.insert("sort".to_string(), AttributeValue::N((*sk).to_string())); + item.insert( + "value".to_string(), + AttributeValue::S(format!("payload-{i}")), + ); + + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item with decimal sort key should succeed"); + } + + // Each decimal sort key must fetch its exact item back. + for (i, sk) in decimal_keys.iter().enumerate() { + let mut key = BTreeMap::new(); + key.insert("id".to_string(), AttributeValue::S("dec-pk".to_string())); + key.insert("sort".to_string(), AttributeValue::N((*sk).to_string())); + + let retrieved = engine + .get_item(&table.key_info, &key) + .await + .expect("get_item should succeed") + .unwrap_or_else(|| panic!("item with decimal sort key {sk} should exist")); + + assert_eq!( + retrieved.get("value"), + Some(&AttributeValue::S(format!("payload-{i}"))), + "decimal sort key {sk} did not round-trip to the correct item" + ); + // The sort key attribute itself is stored in item_data and must be + // byte-identical to what was written (no precision loss). + assert_eq!( + retrieved.get("sort"), + Some(&AttributeValue::N((*sk).to_string())), + "decimal sort key {sk} lost precision on round-trip" + ); + } +} + +#[tokio::test] +async fn test_put_and_get_item_with_binary_sk() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = + TestTable::with_sort_key_type(&engine, "TestBinarySkTable", ScalarAttributeType::B).await; + + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S("record-1".to_string())); + item.insert("sort".to_string(), AttributeValue::B(vec![1, 2, 3, 4])); + item.insert( + "info".to_string(), + AttributeValue::S("binary key test".to_string()), + ); + + engine + .put_item( + &table.key_info, + item.clone(), + false, + None, + &Default::default(), + None, + ) + .await + .expect("Put failed"); + + let mut key = BTreeMap::new(); + key.insert("id".to_string(), AttributeValue::S("record-1".to_string())); + key.insert("sort".to_string(), AttributeValue::B(vec![1, 2, 3, 4])); + + let retrieved = engine + .get_item(&table.key_info, &key) + .await + .expect("Get failed") + .expect("Item should exist"); + + assert_eq!(retrieved.get("info"), item.get("info")); +} + +#[tokio::test] +async fn test_put_item_update_existing() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TestUpdateTable", false).await; + + let mut item = BTreeMap::new(); + item.insert( + "id".to_string(), + AttributeValue::S("update-test".to_string()), + ); + item.insert("value".to_string(), AttributeValue::N("1".to_string())); + + engine + .put_item( + &table.key_info, + item.clone(), + false, + None, + &Default::default(), + None, + ) + .await + .expect("First put failed"); + + item.insert("value".to_string(), AttributeValue::N("2".to_string())); + + engine + .put_item( + &table.key_info, + item.clone(), + false, + None, + &Default::default(), + None, + ) + .await + .expect("Second put failed"); + + let mut key = BTreeMap::new(); + key.insert( + "id".to_string(), + AttributeValue::S("update-test".to_string()), + ); + + let retrieved = engine + .get_item(&table.key_info, &key) + .await + .expect("Get failed") + .expect("Item should exist"); + + assert_eq!( + retrieved.get("value"), + Some(&AttributeValue::N("2".to_string())) + ); +} + +#[tokio::test] +async fn test_get_item_not_found() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TestNotFoundTable", false).await; + + let mut key = BTreeMap::new(); + key.insert( + "id".to_string(), + AttributeValue::S("nonexistent".to_string()), + ); + + let result = engine + .get_item(&table.key_info, &key) + .await + .expect("Get should succeed"); + + assert!(result.is_none()); +} + +#[tokio::test] +async fn test_put_item_with_return_old() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TestReturnOldTable", false).await; + + let mut item = BTreeMap::new(); + item.insert( + "id".to_string(), + AttributeValue::S("return-test".to_string()), + ); + item.insert("version".to_string(), AttributeValue::N("1".to_string())); + + let first_result = engine + .put_item( + &table.key_info, + item.clone(), + true, + None, + &Default::default(), + None, + ) + .await + .expect("First put failed"); + + assert!(first_result.is_none()); + + item.insert("version".to_string(), AttributeValue::N("2".to_string())); + + let second_result = engine + .put_item( + &table.key_info, + item.clone(), + true, + None, + &Default::default(), + None, + ) + .await + .expect("Second put failed"); + + assert!(second_result.is_some()); + let old = second_result.unwrap(); + assert_eq!( + old.get("version"), + Some(&AttributeValue::N("1".to_string())) + ); +} + +#[tokio::test] +async fn test_put_item_with_sync_gsi() { + if crate::helpers::skip_without_cassandra() { + return; + } + use crate::helpers::TestTable; + + let engine = setup_engine().await; + let table = TestTable::with_gsi(&engine, "TestGSITable", "TestGSI", "gsi_pk").await; + + // The in-tree default GSI propagation is asynchronous (10ms via the GSI + // queue, drained by a worker that does not run in these tests). This test + // is about the SYNCHRONOUS write path, so pin the index's delay to 0 — + // the same knob the async/sync routing reads in production. + let pin_sync = format!( + "UPDATE extenddb_ttl_test_catalog.indexes SET propagation_delay_ms = 0 \ + WHERE table_id = '{}' AND index_name = 'TestGSI'", + table.key_info.table_id + ); + engine + .session_arc() + .query(pin_sync) + .await + .expect("pin GSI to synchronous propagation"); + + // Put an item with the GSI key + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S("item-1".to_string())); + item.insert( + "gsi_pk".to_string(), + AttributeValue::S("gsi-value-1".to_string()), + ); + item.insert( + "data".to_string(), + AttributeValue::S("test data".to_string()), + ); + + engine + .put_item( + &table.key_info, + item.clone(), + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item with GSI failed"); + + // Verify item exists in base table + let mut key = BTreeMap::new(); + key.insert("id".to_string(), AttributeValue::S("item-1".to_string())); + + let retrieved = engine + .get_item(&table.key_info, &key) + .await + .expect("get_item failed") + .expect("Item should exist"); + + assert_eq!(retrieved.get("gsi_pk"), item.get("gsi_pk")); + assert_eq!(retrieved.get("data"), item.get("data")); + + // Verify the index row exists via the public index-scan path (the write + // is synchronous, so the row must be visible immediately). + let (index_rows, _) = engine + .scan(&table.key_info, None, None, None, None, Some("TestGSI")) + .await + .expect("index scan failed"); + assert!( + index_rows.iter().any(|row| { + row.get("gsi_pk") == Some(&AttributeValue::S("gsi-value-1".to_string())) + && row.get("id") == Some(&AttributeValue::S("item-1".to_string())) + }), + "synchronously-written GSI row missing from index scan: {index_rows:?}" + ); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Transaction protection tests (Phase 3, T3.1) +// ═══════════════════════════════════════════════════════════════════════════════ + +use crate::helpers::put_item_then_lock; + +#[tokio::test] +async fn test_put_item_rejects_when_prepared_txn_id_set_pk_only() { + if crate::helpers::skip_without_cassandra() { + return; + } + use extenddb_storage::error::StorageError; + + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TxnProtPutPk", false).await; + + let mut item = BTreeMap::new(); + item.insert( + "id".to_string(), + AttributeValue::S("locked-item".to_string()), + ); + item.insert("value".to_string(), AttributeValue::N("1".to_string())); + + put_item_then_lock(&engine, &table, &item).await; + + // Try to overwrite the locked item + let mut new_item = BTreeMap::new(); + new_item.insert( + "id".to_string(), + AttributeValue::S("locked-item".to_string()), + ); + new_item.insert("value".to_string(), AttributeValue::N("2".to_string())); + + let result = engine + .put_item( + &table.key_info, + new_item, + false, + None, + &Default::default(), + None, + ) + .await; + + match result { + Err(StorageError::TransactionCanceled(reasons)) => { + assert_eq!(reasons[0].code, "TransactionConflict"); + } + other => panic!("Expected TransactionCanceled, got: {:?}", other), + } +} + +#[tokio::test] +async fn test_put_item_rejects_when_prepared_txn_id_set_with_sk() { + if crate::helpers::skip_without_cassandra() { + return; + } + use extenddb_storage::error::StorageError; + + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TxnProtPutSk", true).await; + + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S("pk1".to_string())); + item.insert("sort".to_string(), AttributeValue::S("sk1".to_string())); + item.insert("value".to_string(), AttributeValue::N("1".to_string())); + + put_item_then_lock(&engine, &table, &item).await; + + // Try to overwrite the locked item + let mut new_item = BTreeMap::new(); + new_item.insert("id".to_string(), AttributeValue::S("pk1".to_string())); + new_item.insert("sort".to_string(), AttributeValue::S("sk1".to_string())); + new_item.insert("value".to_string(), AttributeValue::N("99".to_string())); + + let result = engine + .put_item( + &table.key_info, + new_item, + false, + None, + &Default::default(), + None, + ) + .await; + + match result { + Err(StorageError::TransactionCanceled(reasons)) => { + assert_eq!(reasons[0].code, "TransactionConflict"); + } + other => panic!("Expected TransactionCanceled, got: {:?}", other), + } +} + +#[tokio::test] +async fn test_put_item_succeeds_when_prepared_txn_id_is_null() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TxnProtPutOk", false).await; + + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S("unlocked".to_string())); + item.insert("value".to_string(), AttributeValue::N("1".to_string())); + + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("First put should succeed"); + + // Overwrite should also succeed (no transaction lock) + let mut item2 = BTreeMap::new(); + item2.insert("id".to_string(), AttributeValue::S("unlocked".to_string())); + item2.insert("value".to_string(), AttributeValue::N("2".to_string())); + + engine + .put_item( + &table.key_info, + item2, + false, + None, + &Default::default(), + None, + ) + .await + .expect("Overwrite of unlocked item should succeed"); +} + +#[tokio::test] +async fn test_update_item_rejects_when_prepared_txn_id_set_pk_only() { + if crate::helpers::skip_without_cassandra() { + return; + } + use extenddb_core::expression::{Expr, ExpressionMaps, PathElement, UpdateAction}; + use extenddb_storage::error::StorageError; + + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TxnProtUpdPk", false).await; + + let mut item = BTreeMap::new(); + item.insert( + "id".to_string(), + AttributeValue::S("upd-locked".to_string()), + ); + item.insert("counter".to_string(), AttributeValue::N("10".to_string())); + + put_item_then_lock(&engine, &table, &item).await; + + let mut key = BTreeMap::new(); + key.insert( + "id".to_string(), + AttributeValue::S("upd-locked".to_string()), + ); + + let actions = vec![UpdateAction::Set { + path: vec![PathElement::Attribute("counter".to_string())], + value: Expr::Placeholder("val".to_string()), + }]; + + let mut values = std::collections::HashMap::new(); + values.insert("val".to_string(), AttributeValue::N("20".to_string())); + let maps = ExpressionMaps::new(std::collections::HashMap::new(), values); + + let result = engine + .update_item( + &table.key_info, + &key, + &actions, + false, + false, + None, + &maps, + None, + ) + .await; + + match result { + Err(StorageError::TransactionCanceled(reasons)) => { + assert_eq!(reasons[0].code, "TransactionConflict"); + } + other => panic!("Expected TransactionCanceled, got: {:?}", other), + } +} + +#[tokio::test] +async fn test_update_item_rejects_when_prepared_txn_id_set_with_sk() { + if crate::helpers::skip_without_cassandra() { + return; + } + use extenddb_core::expression::{Expr, ExpressionMaps, PathElement, UpdateAction}; + use extenddb_storage::error::StorageError; + + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TxnProtUpdSk", true).await; + + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S("upk1".to_string())); + item.insert("sort".to_string(), AttributeValue::S("usk1".to_string())); + item.insert("counter".to_string(), AttributeValue::N("5".to_string())); + + put_item_then_lock(&engine, &table, &item).await; + + let mut key = BTreeMap::new(); + key.insert("id".to_string(), AttributeValue::S("upk1".to_string())); + key.insert("sort".to_string(), AttributeValue::S("usk1".to_string())); + + let actions = vec![UpdateAction::Set { + path: vec![PathElement::Attribute("counter".to_string())], + value: Expr::Placeholder("val".to_string()), + }]; + + let mut values = std::collections::HashMap::new(); + values.insert("val".to_string(), AttributeValue::N("99".to_string())); + let maps = ExpressionMaps::new(std::collections::HashMap::new(), values); + + let result = engine + .update_item( + &table.key_info, + &key, + &actions, + false, + false, + None, + &maps, + None, + ) + .await; + + match result { + Err(StorageError::TransactionCanceled(reasons)) => { + assert_eq!(reasons[0].code, "TransactionConflict"); + } + other => panic!("Expected TransactionCanceled, got: {:?}", other), + } +} diff --git a/crates/storage-cassandra/tests/direct/query.rs b/crates/storage-cassandra/tests/direct/query.rs new file mode 100644 index 00000000..05baa9e9 --- /dev/null +++ b/crates/storage-cassandra/tests/direct/query.rs @@ -0,0 +1,1509 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Direct integration tests for Query operation. + +use extenddb_core::expression::{CompareOp, Expr, ExpressionMaps, KeyCondition}; +use extenddb_core::types::AttributeValue; +use extenddb_storage::DataEngine; +use std::collections::BTreeMap; + +use crate::helpers::{TestTable, setup_engine}; + +#[tokio::test] +async fn test_query_pk_only() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "QueryTestTable", false).await; + + // Put three items with the same partition key (PK-only table, so overwrites) + let pk_value = AttributeValue::S("user123".to_string()); + + for i in 1..=3 { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), pk_value.clone()); + item.insert("name".to_string(), AttributeValue::S(format!("Item {}", i))); + item.insert("value".to_string(), AttributeValue::N(i.to_string())); + + let maps = ExpressionMaps::default(); + engine + .put_item(&table.key_info, item, false, None, &maps, None) + .await + .expect("put_item should succeed"); + } + + // Query by partition key only + let key_condition = KeyCondition { + pk_path: vec![extenddb_core::expression::PathElement::Attribute( + "id".to_string(), + )], + pk_value: Expr::Placeholder(":pk".to_string()), + sk_condition: None, + extra_pk_conditions: vec![], + extra_sk_conditions: vec![], + }; + + let mut maps = ExpressionMaps::default(); + maps.values.insert(":pk".to_string(), pk_value.clone()); + + let (items, last_key) = engine + .query( + &table.key_info, + &key_condition, + &maps, + true, + None, + None, + None, + ) + .await + .expect("query should succeed"); + + // For PK-only table, we should get back just one item (the last write wins) + assert_eq!( + items.len(), + 1, + "PK-only table should return single item per partition key" + ); + + // Verify the item contains our partition key and last written data + assert_eq!(items[0].get("id"), Some(&pk_value)); + assert_eq!( + items[0].get("value"), + Some(&AttributeValue::N("3".to_string())) + ); + + // No pagination for single item + assert!(last_key.is_none(), "Should not have pagination key"); + + println!("✓ PK-only query test passed"); +} + +#[tokio::test] +async fn test_query_with_sk_equals() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "QuerySkTable", true).await; + + // Put multiple items with same PK but different SKs + let pk_value = AttributeValue::S("user123".to_string()); + + for i in 1..=5 { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), pk_value.clone()); + item.insert( + "sort".to_string(), + AttributeValue::S(format!("item-{:02}", i)), + ); + item.insert("data".to_string(), AttributeValue::N(i.to_string())); + + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item should succeed"); + } + + // Query for specific PK + SK combination + let key_condition = KeyCondition { + pk_path: vec![extenddb_core::expression::PathElement::Attribute( + "id".to_string(), + )], + pk_value: Expr::Placeholder(":pk".to_string()), + sk_condition: Some(extenddb_core::expression::SortKeyCondition::Compare { + path: vec![extenddb_core::expression::PathElement::Attribute( + "sort".to_string(), + )], + op: extenddb_core::expression::CompareOp::Eq, + value: Expr::Placeholder(":sk".to_string()), + }), + extra_pk_conditions: vec![], + extra_sk_conditions: vec![], + }; + + let mut maps = ExpressionMaps::default(); + maps.values.insert(":pk".to_string(), pk_value.clone()); + maps.values + .insert(":sk".to_string(), AttributeValue::S("item-03".to_string())); + + let (items, last_key) = engine + .query( + &table.key_info, + &key_condition, + &maps, + true, + None, + None, + None, + ) + .await + .expect("query should succeed"); + + // Should get exactly one item + assert_eq!(items.len(), 1, "Should return exactly one item"); + assert_eq!(items[0].get("id"), Some(&pk_value)); + assert_eq!( + items[0].get("sort"), + Some(&AttributeValue::S("item-03".to_string())) + ); + assert_eq!( + items[0].get("data"), + Some(&AttributeValue::N("3".to_string())) + ); + assert!(last_key.is_none(), "Should not have pagination key"); + + println!("✓ SK equality query test passed"); +} + +#[tokio::test] +async fn test_query_with_sk_comparison() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "QuerySkCompareTable", true).await; + + // Put items with numeric sort keys + let pk_value = AttributeValue::S("partition1".to_string()); + + for i in 1..=10 { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), pk_value.clone()); + item.insert("sort".to_string(), AttributeValue::S(i.to_string())); + item.insert("value".to_string(), AttributeValue::N((i * 10).to_string())); + + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item should succeed"); + } + + // Test GT (greater than) - should get items > 5 + let key_condition = KeyCondition { + pk_path: vec![extenddb_core::expression::PathElement::Attribute( + "id".to_string(), + )], + pk_value: Expr::Placeholder(":pk".to_string()), + sk_condition: Some(extenddb_core::expression::SortKeyCondition::Compare { + path: vec![extenddb_core::expression::PathElement::Attribute( + "sort".to_string(), + )], + op: extenddb_core::expression::CompareOp::Gt, + value: Expr::Placeholder(":sk".to_string()), + }), + extra_pk_conditions: vec![], + extra_sk_conditions: vec![], + }; + + let mut maps = ExpressionMaps::default(); + maps.values.insert(":pk".to_string(), pk_value.clone()); + maps.values + .insert(":sk".to_string(), AttributeValue::S("5".to_string())); + + let (items, _) = engine + .query( + &table.key_info, + &key_condition, + &maps, + true, + None, + None, + None, + ) + .await + .expect("query should succeed"); + + // String comparison: "6", "7", "8", "9" are > "5", but "10" is not (lexicographic order) + // So we should get 4 items + println!("Got {} items", items.len()); + for item in &items { + if let Some(AttributeValue::S(s)) = item.get("sort") { + println!(" sort: {}", s); + } + } + + assert_eq!( + items.len(), + 4, + "Should return 4 items with sort > '5' (string comparison)" + ); + + // Verify all returned items have sort > "5" + for item in &items { + let sort_val = item.get("sort").expect("item should have sort key"); + if let AttributeValue::S(s) = sort_val { + assert!(s.as_str() > "5", "Item sort key '{}' should be > '5'", s); + } + } + + println!("✓ SK comparison query test passed"); +} +#[tokio::test] +async fn test_query_with_numeric_sk() { + if crate::helpers::skip_without_cassandra() { + return; + } + use crate::helpers::TestTable; + + let engine = setup_engine().await; + + // Create table with numeric sort key + let table = TestTable::with_sort_key_type( + &engine, + "QueryNumericSkTable", + extenddb_core::types::ScalarAttributeType::N, + ) + .await; + + // Put items with numeric sort keys 1-10 + let pk_value = AttributeValue::S("sensor1".to_string()); + + for i in 1..=10 { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), pk_value.clone()); + item.insert("sort".to_string(), AttributeValue::N(i.to_string())); + item.insert( + "reading".to_string(), + AttributeValue::N((i * 100).to_string()), + ); + + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item should succeed"); + } + + // Query for sort > 5 (numeric comparison) + let key_condition = KeyCondition { + pk_path: vec![extenddb_core::expression::PathElement::Attribute( + "id".to_string(), + )], + pk_value: Expr::Placeholder(":pk".to_string()), + sk_condition: Some(extenddb_core::expression::SortKeyCondition::Compare { + path: vec![extenddb_core::expression::PathElement::Attribute( + "sort".to_string(), + )], + op: extenddb_core::expression::CompareOp::Gt, + value: Expr::Placeholder(":sk".to_string()), + }), + extra_pk_conditions: vec![], + extra_sk_conditions: vec![], + }; + + let mut maps = ExpressionMaps::default(); + maps.values.insert(":pk".to_string(), pk_value.clone()); + maps.values + .insert(":sk".to_string(), AttributeValue::N("5".to_string())); + + let (items, _) = engine + .query( + &table.key_info, + &key_condition, + &maps, + true, + None, + None, + None, + ) + .await + .expect("query should succeed"); + + // With numeric comparison: 6, 7, 8, 9, 10 are all > 5, so we should get 5 items + println!("Got {} items with numeric sort keys", items.len()); + for item in &items { + if let Some(AttributeValue::N(n)) = item.get("sort") { + println!(" sort: {}", n); + } + } + + assert_eq!( + items.len(), + 5, + "Should return 5 items with sort > 5 (numeric comparison)" + ); + + // Verify all returned items have sort > 5 + for item in &items { + let sk_val = item.get("sort").expect("item should have sort key"); + if let AttributeValue::N(n) = sk_val { + let num: i32 = n.parse().expect("should be valid number"); + assert!(num > 5, "Item sort {} should be > 5", num); + } + } + + println!("✓ Numeric SK comparison query test passed"); +} + +#[tokio::test] +async fn test_query_with_decimal_sk_range_and_order() { + if crate::helpers::skip_without_cassandra() { + return; + } + // Verifies that fractional decimal sort keys compare and order correctly at + // the column level (Technical Debt #1). The previous varint column + string + // binding could not represent fractions and broke `sk_n` comparisons; this + // exercises a real numeric range predicate over decimals plus ascending + // clustering order. + let engine = setup_engine().await; + let table = TestTable::with_sort_key_type( + &engine, + "QueryDecimalSkTable", + extenddb_core::types::ScalarAttributeType::N, + ) + .await; + + let pk_value = AttributeValue::S("sensor-d".to_string()); + + // Insert out of order to prove ordering comes from the decimal column, not + // insertion order. Mix of fractions and integers. + let sort_values = ["10.5", "0.25", "2.5", "2.05", "100", "2.500001"]; + for sk in sort_values { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), pk_value.clone()); + item.insert("sort".to_string(), AttributeValue::N(sk.to_string())); + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item should succeed"); + } + + // Query: sort > 2.5 → expect 2.500001, 10.5, 100 (NOT 2.5, 2.05, 0.25). + let key_condition = KeyCondition { + pk_path: vec![extenddb_core::expression::PathElement::Attribute( + "id".to_string(), + )], + pk_value: Expr::Placeholder(":pk".to_string()), + sk_condition: Some(extenddb_core::expression::SortKeyCondition::Compare { + path: vec![extenddb_core::expression::PathElement::Attribute( + "sort".to_string(), + )], + op: extenddb_core::expression::CompareOp::Gt, + value: Expr::Placeholder(":sk".to_string()), + }), + extra_pk_conditions: vec![], + extra_sk_conditions: vec![], + }; + + let mut maps = ExpressionMaps::default(); + maps.values.insert(":pk".to_string(), pk_value.clone()); + maps.values + .insert(":sk".to_string(), AttributeValue::N("2.5".to_string())); + + let (items, _) = engine + .query( + &table.key_info, + &key_condition, + &maps, + true, // ascending + None, + None, + None, + ) + .await + .expect("decimal range query should succeed"); + + let got: Vec = items + .iter() + .map(|item| match item.get("sort") { + Some(AttributeValue::N(n)) => n.clone(), + other => panic!("expected numeric sort, got {other:?}"), + }) + .collect(); + + // Strictly greater than 2.5, returned in ascending decimal order. + assert_eq!( + got, + vec![ + "2.500001".to_string(), + "10.5".to_string(), + "100".to_string() + ], + "decimal range filter + ordering incorrect" + ); + + println!("✓ Decimal SK range + ordering query test passed"); +} + +#[tokio::test] +async fn test_query_ordering() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "QueryOrderTable", true).await; + + // Put items out of order + let pk_value = AttributeValue::S("partition1".to_string()); + let sort_values = vec!["apple", "zebra", "banana", "mango", "cherry"]; + + for sort in &sort_values { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), pk_value.clone()); + item.insert("sort".to_string(), AttributeValue::S(sort.to_string())); + item.insert( + "data".to_string(), + AttributeValue::S(format!("Item {}", sort)), + ); + + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item should succeed"); + } + + // Query with forward=true (ascending order) + let key_condition = KeyCondition { + pk_path: vec![extenddb_core::expression::PathElement::Attribute( + "id".to_string(), + )], + pk_value: Expr::Placeholder(":pk".to_string()), + sk_condition: None, + extra_pk_conditions: vec![], + extra_sk_conditions: vec![], + }; + + let mut maps = ExpressionMaps::default(); + maps.values.insert(":pk".to_string(), pk_value.clone()); + + let (items_asc, _) = engine + .query( + &table.key_info, + &key_condition, + &maps, + true, + None, + None, + None, + ) + .await + .expect("forward query should succeed"); + + // Verify ascending order + let sort_keys_asc: Vec = items_asc + .iter() + .map(|item| { + if let Some(AttributeValue::S(s)) = item.get("sort") { + s.clone() + } else { + panic!("Expected string sort key") + } + }) + .collect(); + + println!("Ascending order: {:?}", sort_keys_asc); + assert_eq!( + sort_keys_asc, + vec!["apple", "banana", "cherry", "mango", "zebra"] + ); + + // Query with forward=false (descending order) + let (items_desc, _) = engine + .query( + &table.key_info, + &key_condition, + &maps, + false, + None, + None, + None, + ) + .await + .expect("reverse query should succeed"); + + // Verify descending order + let sort_keys_desc: Vec = items_desc + .iter() + .map(|item| { + if let Some(AttributeValue::S(s)) = item.get("sort") { + s.clone() + } else { + panic!("Expected string sort key") + } + }) + .collect(); + + println!("Descending order: {:?}", sort_keys_desc); + assert_eq!( + sort_keys_desc, + vec!["zebra", "mango", "cherry", "banana", "apple"] + ); + + println!("✓ Query ordering test passed"); +} + +#[tokio::test] +async fn test_query_limit() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "QueryLimitTable", true).await; + + // Put 10 items + let pk_value = AttributeValue::S("partition1".to_string()); + + for i in 1..=10 { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), pk_value.clone()); + item.insert( + "sort".to_string(), + AttributeValue::S(format!("item-{:02}", i)), + ); + item.insert("data".to_string(), AttributeValue::N(i.to_string())); + + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item should succeed"); + } + + // Query with limit=3 + let key_condition = KeyCondition { + pk_path: vec![extenddb_core::expression::PathElement::Attribute( + "id".to_string(), + )], + pk_value: Expr::Placeholder(":pk".to_string()), + sk_condition: None, + extra_pk_conditions: vec![], + extra_sk_conditions: vec![], + }; + + let mut maps = ExpressionMaps::default(); + maps.values.insert(":pk".to_string(), pk_value.clone()); + + let (items, last_key) = engine + .query( + &table.key_info, + &key_condition, + &maps, + true, + Some(3), + None, + None, + ) + .await + .expect("query should succeed"); + + println!("Got {} items with limit=3", items.len()); + assert_eq!(items.len(), 3, "Should return exactly 3 items"); + + // Should have last_key since there are more items + assert!(last_key.is_some(), "Should have LastEvaluatedKey"); + + // Verify we got the first 3 items + assert_eq!( + items[0].get("sort"), + Some(&AttributeValue::S("item-01".to_string())) + ); + assert_eq!( + items[1].get("sort"), + Some(&AttributeValue::S("item-02".to_string())) + ); + assert_eq!( + items[2].get("sort"), + Some(&AttributeValue::S("item-03".to_string())) + ); + + println!("✓ Query limit test passed"); +} + +#[tokio::test] +async fn test_query_pagination() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "QueryPaginationTable", true).await; + + // Put 10 items + let pk_value = AttributeValue::S("partition1".to_string()); + + for i in 1..=10 { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), pk_value.clone()); + item.insert( + "sort".to_string(), + AttributeValue::S(format!("item-{:02}", i)), + ); + item.insert("data".to_string(), AttributeValue::N(i.to_string())); + + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item should succeed"); + } + + let key_condition = KeyCondition { + pk_path: vec![extenddb_core::expression::PathElement::Attribute( + "id".to_string(), + )], + pk_value: Expr::Placeholder(":pk".to_string()), + sk_condition: None, + extra_pk_conditions: vec![], + extra_sk_conditions: vec![], + }; + + let mut maps = ExpressionMaps::default(); + maps.values.insert(":pk".to_string(), pk_value.clone()); + + // First page: get 3 items + let (page1, last_key1) = engine + .query( + &table.key_info, + &key_condition, + &maps, + true, + Some(3), + None, + None, + ) + .await + .expect("first query should succeed"); + + assert_eq!(page1.len(), 3); + assert!( + last_key1.is_some(), + "Should have LastEvaluatedKey after page 1" + ); + println!("Page 1: {} items", page1.len()); + + // Second page: use last_key as exclusive start + let (page2, last_key2) = engine + .query( + &table.key_info, + &key_condition, + &maps, + true, + Some(3), + last_key1.as_ref(), + None, + ) + .await + .expect("second query should succeed"); + + assert_eq!(page2.len(), 3); + assert!( + last_key2.is_some(), + "Should have LastEvaluatedKey after page 2" + ); + println!("Page 2: {} items", page2.len()); + + // Third page + let (page3, last_key3) = engine + .query( + &table.key_info, + &key_condition, + &maps, + true, + Some(3), + last_key2.as_ref(), + None, + ) + .await + .expect("third query should succeed"); + + assert_eq!(page3.len(), 3); + assert!( + last_key3.is_some(), + "Should have LastEvaluatedKey after page 3" + ); + println!("Page 3: {} items", page3.len()); + + // Fourth page: should get remaining item + let (page4, last_key4) = engine + .query( + &table.key_info, + &key_condition, + &maps, + true, + Some(3), + last_key3.as_ref(), + None, + ) + .await + .expect("fourth query should succeed"); + + assert_eq!(page4.len(), 1); + assert!( + last_key4.is_none(), + "Should NOT have LastEvaluatedKey after last page" + ); + println!("Page 4: {} items (final)", page4.len()); + + // Verify no duplicates and correct ordering + let all_items: Vec = [page1, page2, page3, page4] + .concat() + .iter() + .map(|item| { + if let Some(AttributeValue::S(s)) = item.get("sort") { + s.clone() + } else { + panic!("Expected string sort key") + } + }) + .collect(); + + assert_eq!(all_items.len(), 10, "Should have retrieved all 10 items"); + assert_eq!( + all_items, + vec![ + "item-01", "item-02", "item-03", "item-04", "item-05", "item-06", "item-07", "item-08", + "item-09", "item-10" + ] + ); + + println!("✓ Query pagination test passed"); +} + +#[tokio::test] +async fn test_query_between() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "QueryBetweenTable", true).await; + + // Put 10 items + let pk_value = AttributeValue::S("partition1".to_string()); + + for i in 1..=10 { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), pk_value.clone()); + item.insert( + "sort".to_string(), + AttributeValue::S(format!("item-{:02}", i)), + ); + item.insert("data".to_string(), AttributeValue::N(i.to_string())); + + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item should succeed"); + } + + // Query for items with sort BETWEEN "item-03" AND "item-07" + let key_condition = KeyCondition { + pk_path: vec![extenddb_core::expression::PathElement::Attribute( + "id".to_string(), + )], + pk_value: Expr::Placeholder(":pk".to_string()), + sk_condition: Some(extenddb_core::expression::SortKeyCondition::Between { + path: vec![extenddb_core::expression::PathElement::Attribute( + "sort".to_string(), + )], + low: Expr::Placeholder(":low".to_string()), + high: Expr::Placeholder(":high".to_string()), + }), + extra_pk_conditions: vec![], + extra_sk_conditions: vec![], + }; + + let mut maps = ExpressionMaps::default(); + maps.values.insert(":pk".to_string(), pk_value.clone()); + maps.values + .insert(":low".to_string(), AttributeValue::S("item-03".to_string())); + maps.values.insert( + ":high".to_string(), + AttributeValue::S("item-07".to_string()), + ); + + let (items, _) = engine + .query( + &table.key_info, + &key_condition, + &maps, + true, + None, + None, + None, + ) + .await + .expect("query should succeed"); + + // Should get items 03, 04, 05, 06, 07 (5 items) + println!("Got {} items with BETWEEN", items.len()); + assert_eq!( + items.len(), + 5, + "Should return 5 items between item-03 and item-07" + ); + + let sort_keys: Vec = items + .iter() + .map(|item| { + if let Some(AttributeValue::S(s)) = item.get("sort") { + s.clone() + } else { + panic!("Expected string sort key") + } + }) + .collect(); + + assert_eq!( + sort_keys, + vec!["item-03", "item-04", "item-05", "item-06", "item-07"] + ); + + println!("✓ Query BETWEEN test passed"); +} + +#[tokio::test] +async fn test_query_begins_with() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "QueryBeginsWithTable", true).await; + + // Put items with various prefixes + let pk_value = AttributeValue::S("partition1".to_string()); + let items = vec!["apple", "apricot", "application", "banana", "berry", "cat"]; + + for item_name in &items { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), pk_value.clone()); + item.insert("sort".to_string(), AttributeValue::S(item_name.to_string())); + item.insert( + "data".to_string(), + AttributeValue::S(format!("Item {}", item_name)), + ); + + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item should succeed"); + } + + // Query for items that begin with "app" + let key_condition = KeyCondition { + pk_path: vec![extenddb_core::expression::PathElement::Attribute( + "id".to_string(), + )], + pk_value: Expr::Placeholder(":pk".to_string()), + sk_condition: Some(extenddb_core::expression::SortKeyCondition::BeginsWith { + path: vec![extenddb_core::expression::PathElement::Attribute( + "sort".to_string(), + )], + prefix: Expr::Placeholder(":prefix".to_string()), + }), + extra_pk_conditions: vec![], + extra_sk_conditions: vec![], + }; + + let mut maps = ExpressionMaps::default(); + maps.values.insert(":pk".to_string(), pk_value.clone()); + maps.values + .insert(":prefix".to_string(), AttributeValue::S("app".to_string())); + + let (items_result, _) = engine + .query( + &table.key_info, + &key_condition, + &maps, + true, + None, + None, + None, + ) + .await + .expect("query should succeed"); + + // Should get apple, application (both start with "app") + println!("Got {} items with begins_with('app')", items_result.len()); + assert_eq!( + items_result.len(), + 2, + "Should return 2 items beginning with 'app'" + ); + + let sort_keys: Vec = items_result + .iter() + .map(|item| { + if let Some(AttributeValue::S(s)) = item.get("sort") { + s.clone() + } else { + panic!("Expected string sort key") + } + }) + .collect(); + + assert_eq!(sort_keys, vec!["apple", "application"]); + + println!("✓ Query begins_with test passed"); +} + +#[tokio::test] +async fn test_query_gsi() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::with_gsi(&engine, "QueryGsiTable", "StatusIndex", "status").await; + + // Set GSI to synchronous (propagation_delay_ms = 0) for testing + let catalog_keyspace = engine.catalog_keyspace(); + let update_delay = format!( + "UPDATE {}.indexes SET propagation_delay_ms = 0 WHERE table_id = ? AND index_name = ?", + catalog_keyspace + ); + engine + .session() + .query_with_values( + &update_delay, + cdrs_tokio::query_values!(table.key_info.table_id.as_str(), "StatusIndex"), + ) + .await + .expect("Failed to set GSI to synchronous"); + + println!("✓ Table created with synchronous GSI: StatusIndex"); + println!(" Table ID: {}", table.key_info.table_id); + println!(" Account ID: {}", table.key_info.account_id); + + // Put items with different status values + let items_data = vec![ + ("item1", "active"), + ("item2", "pending"), + ("item3", "active"), + ("item4", "inactive"), + ]; + + let maps = ExpressionMaps::default(); + for (id, status) in items_data { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S(id.to_string())); + item.insert("status".to_string(), AttributeValue::S(status.to_string())); + item.insert( + "data".to_string(), + AttributeValue::S(format!("Data for {}", id)), + ); + + engine + .put_item(&table.key_info, item, false, None, &maps, None) + .await + .expect("put_item should succeed"); + + println!("✓ Put item: {} with status={}", id, status); + } + + // Query GSI by status = "active" + let key_condition = KeyCondition { + pk_path: vec![extenddb_core::expression::PathElement::Attribute( + "status".to_string(), + )], + pk_value: Expr::Placeholder(":status".to_string()), + sk_condition: None, + extra_pk_conditions: vec![], + extra_sk_conditions: vec![], + }; + + let mut maps = ExpressionMaps::default(); + maps.values.insert( + ":status".to_string(), + AttributeValue::S("active".to_string()), + ); + + let (items, _) = engine + .query( + &table.key_info, + &key_condition, + &maps, + true, + None, + None, + Some("StatusIndex"), + ) + .await + .expect("query GSI should succeed"); + + assert_eq!(items.len(), 2, "Should return 2 items with status=active"); + + let ids: Vec = items + .iter() + .map(|item| { + if let Some(AttributeValue::S(s)) = item.get("id") { + s.clone() + } else { + panic!("Expected string id") + } + }) + .collect(); + + assert!(ids.contains(&"item1".to_string())); + assert!(ids.contains(&"item3".to_string())); + + println!("✓ Query GSI test passed"); +} + +#[tokio::test] +async fn test_query_gsi_pagination() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = + TestTable::with_gsi(&engine, "QueryGsiPaginationTable", "StatusIndex", "status").await; + + // Set GSI to synchronous for testing + let catalog_keyspace = engine.catalog_keyspace(); + let update_delay = format!( + "UPDATE {}.indexes SET propagation_delay_ms = 0 WHERE table_id = ? AND index_name = ?", + catalog_keyspace + ); + engine + .session() + .query_with_values( + &update_delay, + cdrs_tokio::query_values!(table.key_info.table_id.as_str(), "StatusIndex"), + ) + .await + .expect("Failed to set GSI to synchronous"); + + println!("✓ Table created with synchronous GSI for pagination test"); + + // Put 10 items all with status="active" to test pagination with same index PK + // This forces the two-query pagination logic (base table keys as tie-breakers) + let maps = ExpressionMaps::default(); + for i in 1..=10 { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S(format!("item{:02}", i))); + item.insert( + "status".to_string(), + AttributeValue::S("active".to_string()), + ); + item.insert("data".to_string(), AttributeValue::N(i.to_string())); + + engine + .put_item(&table.key_info, item, false, None, &maps, None) + .await + .expect("put_item should succeed"); + } + + println!("✓ Put 10 items all with status=active"); + + // Query GSI with pagination + let key_condition = KeyCondition { + pk_path: vec![extenddb_core::expression::PathElement::Attribute( + "status".to_string(), + )], + pk_value: Expr::Placeholder(":status".to_string()), + sk_condition: None, + extra_pk_conditions: vec![], + extra_sk_conditions: vec![], + }; + + let mut maps = ExpressionMaps::default(); + maps.values.insert( + ":status".to_string(), + AttributeValue::S("active".to_string()), + ); + + // Page 1: Get 3 items + let (page1, last_key1) = engine + .query( + &table.key_info, + &key_condition, + &maps, + true, + Some(3), + None, + Some("StatusIndex"), + ) + .await + .expect("first GSI query should succeed"); + + assert_eq!(page1.len(), 3, "Page 1 should have 3 items"); + assert!(last_key1.is_some(), "Should have LastEvaluatedKey"); + println!("✓ Page 1: {} items", page1.len()); + + // Page 2: Continue pagination + let (page2, last_key2) = engine + .query( + &table.key_info, + &key_condition, + &maps, + true, + Some(3), + last_key1.as_ref(), + Some("StatusIndex"), + ) + .await + .expect("second GSI query should succeed"); + + assert_eq!(page2.len(), 3, "Page 2 should have 3 items"); + assert!(last_key2.is_some(), "Should have LastEvaluatedKey"); + println!("✓ Page 2: {} items", page2.len()); + + // Page 3: Continue pagination + let (page3, last_key3) = engine + .query( + &table.key_info, + &key_condition, + &maps, + true, + Some(3), + last_key2.as_ref(), + Some("StatusIndex"), + ) + .await + .expect("third GSI query should succeed"); + + assert_eq!(page3.len(), 3, "Page 3 should have 3 items"); + assert!(last_key3.is_some(), "Should have LastEvaluatedKey"); + println!("✓ Page 3: {} items", page3.len()); + + // Page 4: Get remaining item + let (page4, last_key4) = engine + .query( + &table.key_info, + &key_condition, + &maps, + true, + Some(3), + last_key3.as_ref(), + Some("StatusIndex"), + ) + .await + .expect("fourth GSI query should succeed"); + + assert_eq!(page4.len(), 1, "Page 4 should have 1 remaining item"); + assert!(last_key4.is_none(), "Should not have more pages"); + println!("✓ Page 4: {} items (final page)", page4.len()); + + // Verify all items are unique (no duplicates from pagination) + let mut all_ids: Vec = Vec::new(); + for items in &[&page1, &page2, &page3, &page4] { + for item in items.iter() { + if let Some(AttributeValue::S(id)) = item.get("id") { + all_ids.push(id.clone()); + } + } + } + + all_ids.sort(); + let unique_count = all_ids + .iter() + .collect::>() + .len(); + assert_eq!( + unique_count, 10, + "Should have 10 unique items across all pages" + ); + assert_eq!(all_ids.len(), 10, "Should have exactly 10 items total"); + + println!("✓ GSI pagination test passed - all 10 items retrieved without duplicates"); +} + +#[tokio::test] +async fn test_query_lsi() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::with_lsi(&engine, "QueryLsiTable", "LsiPriority", "priority").await; + + println!("✓ Table created with LSI: LsiPriority"); + + // Put items with same id but different sort keys and priorities + let items_data = vec![ + ("user1", 1000, 3), // sort=1000, priority=3 + ("user1", 2000, 1), // sort=2000, priority=1 (highest priority) + ("user1", 3000, 5), // sort=3000, priority=5 (lowest priority) + ("user1", 4000, 2), // sort=4000, priority=2 + ]; + + let maps = ExpressionMaps::default(); + for (id, sort, priority) in items_data { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S(id.to_string())); + item.insert("sort".to_string(), AttributeValue::N(sort.to_string())); + item.insert( + "priority".to_string(), + AttributeValue::N(priority.to_string()), + ); + + engine + .put_item(&table.key_info, item, false, None, &maps, None) + .await + .expect("put_item should succeed"); + } + + println!("✓ Put 4 items with different priorities"); + + // Query LSI by id, ordered by priority + let key_condition = KeyCondition { + pk_path: vec![extenddb_core::expression::PathElement::Attribute( + "id".to_string(), + )], + pk_value: Expr::Placeholder(":id".to_string()), + sk_condition: None, + extra_pk_conditions: vec![], + extra_sk_conditions: vec![], + }; + + let mut maps = ExpressionMaps::default(); + maps.values + .insert(":id".to_string(), AttributeValue::S("user1".to_string())); + + let (items, _) = engine + .query( + &table.key_info, + &key_condition, + &maps, + true, + None, + None, + Some("LsiPriority"), + ) + .await + .expect("query LSI should succeed"); + + assert_eq!(items.len(), 4, "Should return 4 items"); + + // Verify items are ordered by priority + let priorities: Vec = items + .iter() + .map(|item| { + if let Some(AttributeValue::N(n)) = item.get("priority") { + n.parse().unwrap() + } else { + panic!("Expected numeric priority") + } + }) + .collect(); + + assert_eq!( + priorities, + vec![1, 2, 3, 5], + "Should be ordered by priority ASC" + ); + + println!("✓ LSI query test passed - items ordered by priority"); +} + +#[tokio::test] +async fn test_query_gsi_with_sort_key() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::with_gsi_with_sk( + &engine, + "QueryGsiSkTable", + "CategoryTimeIndex", + "category", + "created_at", + ) + .await; + + // Set GSI to synchronous + let catalog_keyspace = engine.catalog_keyspace(); + let update_delay = format!( + "UPDATE {}.indexes SET propagation_delay_ms = 0 WHERE table_id = ? AND index_name = ?", + catalog_keyspace + ); + engine + .session() + .query_with_values( + &update_delay, + cdrs_tokio::query_values!(table.key_info.table_id.as_str(), "CategoryTimeIndex"), + ) + .await + .expect("Failed to set GSI to synchronous"); + + println!("✓ Table created with GSI with sort key: CategoryTimeIndex"); + + // Put items with same category but different timestamps + let items_data = vec![ + ("item1", "books", 1000), + ("item2", "books", 2000), + ("item3", "books", 3000), + ("item4", "books", 4000), + ("item5", "electronics", 1500), + ]; + + let maps = ExpressionMaps::default(); + for (id, category, timestamp) in items_data { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S(id.to_string())); + item.insert( + "category".to_string(), + AttributeValue::S(category.to_string()), + ); + item.insert( + "created_at".to_string(), + AttributeValue::N(timestamp.to_string()), + ); + + engine + .put_item(&table.key_info, item, false, None, &maps, None) + .await + .expect("put_item should succeed"); + } + + println!("✓ Put 5 items with different categories and timestamps"); + + // Query GSI without SK condition first to see if items are in the index + let key_condition_simple = KeyCondition { + pk_path: vec![extenddb_core::expression::PathElement::Attribute( + "category".to_string(), + )], + pk_value: Expr::Placeholder(":category".to_string()), + sk_condition: None, + extra_pk_conditions: vec![], + extra_sk_conditions: vec![], + }; + + let mut maps_simple = ExpressionMaps::default(); + maps_simple.values.insert( + ":category".to_string(), + AttributeValue::S("books".to_string()), + ); + + let (items_simple, _) = engine + .query( + &table.key_info, + &key_condition_simple, + &maps_simple, + true, + None, + None, + Some("CategoryTimeIndex"), + ) + .await + .expect("simple GSI query should succeed"); + + println!("✓ Simple query returned {} items", items_simple.len()); + assert_eq!( + items_simple.len(), + 4, + "Should have 4 items with category=books" + ); + + // Query GSI: category = "books" AND created_at >= 2000 + let key_condition = KeyCondition { + pk_path: vec![extenddb_core::expression::PathElement::Attribute( + "category".to_string(), + )], + pk_value: Expr::Placeholder(":category".to_string()), + sk_condition: Some(extenddb_core::expression::SortKeyCondition::Compare { + path: vec![extenddb_core::expression::PathElement::Attribute( + "created_at".to_string(), + )], + op: CompareOp::Ge, + value: Expr::Placeholder(":min_time".to_string()), + }), + extra_pk_conditions: vec![], + extra_sk_conditions: vec![], + }; + + let mut maps = ExpressionMaps::default(); + maps.values.insert( + ":category".to_string(), + AttributeValue::S("books".to_string()), + ); + maps.values.insert( + ":min_time".to_string(), + AttributeValue::N("2000".to_string()), + ); + + let (items, _) = engine + .query( + &table.key_info, + &key_condition, + &maps, + true, + None, + None, + Some("CategoryTimeIndex"), + ) + .await + .expect("query GSI with SK condition should succeed"); + + assert_eq!(items.len(), 3, "Should return 3 items (2000, 3000, 4000)"); + + // Verify items are in the correct range + let timestamps: Vec = items + .iter() + .map(|item| { + if let Some(AttributeValue::N(n)) = item.get("created_at") { + n.parse().unwrap() + } else { + panic!("Expected numeric created_at") + } + }) + .collect(); + + assert_eq!( + timestamps, + vec![2000, 3000, 4000], + "Should have timestamps >= 2000 in order" + ); + + println!("✓ GSI with sort key range query test passed"); +} diff --git a/crates/storage-cassandra/tests/direct/roles.rs b/crates/storage-cassandra/tests/direct/roles.rs new file mode 100644 index 00000000..5ec8d607 --- /dev/null +++ b/crates/storage-cassandra/tests/direct/roles.rs @@ -0,0 +1,286 @@ +// Copyright 2026 ExtendDB Contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Integration tests for Roles management operations for `CassandraCatalogStore`. + +#[cfg(test)] +mod tests { + use crate::helpers::{setup_engine, test_config, unique_test_account, unique_test_id}; + use extenddb_storage::management_store::ManagementStore; + + #[tokio::test] + async fn test_role_lifecycle() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let account_id = unique_test_account(); + let account_name = format!("TestAccount_{}", unique_test_id()); + + catalog_store + .create_account(&account_id, &account_name) + .await + .expect("Failed to create account"); + + // Create a role + let role_name = "test-role"; + let trust_policy = serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"Service": "lambda.amazonaws.com"}, + "Action": "sts:AssumeRole" + }] + }); + + catalog_store + .create_role(&account_id, role_name, &trust_policy) + .await + .expect("Failed to create role"); + println!("✓ Role created"); + + // Duplicate create should fail + let result = catalog_store + .create_role(&account_id, role_name, &trust_policy) + .await; + assert!(matches!( + result, + Err(extenddb_storage::management_store::OpError::AlreadyExists( + _ + )) + )); + println!("✓ Duplicate role rejected"); + + // List roles (should have 1) + let roles = catalog_store + .list_roles(&account_id) + .await + .expect("Failed to list roles"); + assert_eq!(roles.len(), 1); + assert_eq!(roles[0].1, role_name); + println!("✓ List roles: {} role(s)", roles.len()); + + // Get role trust policy + let retrieved_policy = catalog_store + .get_role_trust_policy(&account_id, role_name) + .await + .expect("Failed to get trust policy") + .expect("Trust policy should exist"); + assert_eq!(retrieved_policy, trust_policy); + println!("✓ Trust policy retrieved"); + + // Delete role + catalog_store + .delete_role(&account_id, role_name) + .await + .expect("Failed to delete role"); + println!("✓ Role deleted"); + + // Delete non-existent should fail + let result = catalog_store.delete_role(&account_id, role_name).await; + assert!(matches!( + result, + Err(extenddb_storage::management_store::OpError::NotFound(_)) + )); + println!("✓ Delete non-existent role rejected"); + + // List should be empty + let roles = catalog_store + .list_roles(&account_id) + .await + .expect("Failed to list roles"); + assert_eq!(roles.len(), 0); + println!("✓ Role list empty after deletion"); + } + + #[tokio::test] + async fn test_role_tags() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let account_id = unique_test_account(); + let account_name = format!("TestAccount_{}", unique_test_id()); + + catalog_store + .create_account(&account_id, &account_name) + .await + .expect("Failed to create account"); + + // Create role + let role_name = "tagged-role"; + let trust_policy = serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"Service": "ec2.amazonaws.com"}, + "Action": "sts:AssumeRole" + }] + }); + + catalog_store + .create_role(&account_id, role_name, &trust_policy) + .await + .expect("Failed to create role"); + + // Tag role (should fail for non-existent) + let result = catalog_store + .tag_role( + &account_id, + "nonexistent", + &[("key".to_owned(), "value".to_owned())], + ) + .await; + assert!(matches!( + result, + Err(extenddb_storage::management_store::OpError::NotFound(_)) + )); + println!("✓ Tag non-existent role rejected"); + + // Tag role + catalog_store + .tag_role( + &account_id, + role_name, + &[ + ("Environment".to_owned(), "Production".to_owned()), + ("Team".to_owned(), "Platform".to_owned()), + ], + ) + .await + .expect("Failed to tag role"); + println!("✓ Role tagged"); + + // List tags + let tags = catalog_store + .list_role_tags(&account_id, role_name) + .await + .expect("Failed to list tags"); + assert_eq!(tags.len(), 2); + assert_eq!(tags[0], ("Environment".to_owned(), "Production".to_owned())); + assert_eq!(tags[1], ("Team".to_owned(), "Platform".to_owned())); + println!("✓ Tags listed: {:?}", tags); + + // Update tag (upsert) + catalog_store + .tag_role( + &account_id, + role_name, + &[("Environment".to_owned(), "Staging".to_owned())], + ) + .await + .expect("Failed to update tag"); + + let tags = catalog_store + .list_role_tags(&account_id, role_name) + .await + .expect("Failed to list tags"); + assert_eq!(tags[0].1, "Staging"); + println!("✓ Tag updated"); + + // Untag role + catalog_store + .untag_role(&account_id, role_name, &["Team".to_owned()]) + .await + .expect("Failed to untag role"); + + let tags = catalog_store + .list_role_tags(&account_id, role_name) + .await + .expect("Failed to list tags"); + assert_eq!(tags.len(), 1); + println!("✓ Role untagged"); + } + + #[tokio::test] + async fn test_role_detail() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let account_id = unique_test_account(); + let account_name = format!("TestAccount_{}", unique_test_id()); + + catalog_store + .create_account(&account_id, &account_name) + .await + .expect("Failed to create account"); + + // Create role + let role_name = "detailed-role"; + let trust_policy = serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"Service": "s3.amazonaws.com"}, + "Action": "sts:AssumeRole" + }] + }); + + catalog_store + .create_role(&account_id, role_name, &trust_policy) + .await + .expect("Failed to create role"); + + // Add tags + catalog_store + .tag_role( + &account_id, + role_name, + &[("Owner".to_owned(), "Engineering".to_owned())], + ) + .await + .expect("Failed to tag role"); + + // Add policy + let policy_name = "test-policy"; + let policy_doc = serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": "s3:GetObject", + "Resource": "*" + }] + }); + + catalog_store + .put_policy(&account_id, "role", role_name, policy_name, &policy_doc) + .await + .expect("Failed to put policy"); + + // Get role detail + let detail = catalog_store + .get_role_detail(&account_id, role_name) + .await + .expect("Failed to get role detail") + .expect("Role detail should exist"); + + assert_eq!(detail.trust_policy, trust_policy); + assert_eq!(detail.policies.len(), 1); + assert_eq!(detail.policies[0], policy_name); + assert_eq!(detail.tags.len(), 1); + assert_eq!(detail.tags[0].0, "Owner"); + println!( + "✓ Role detail retrieved: {} policies, {} tags", + detail.policies.len(), + detail.tags.len() + ); + + // Non-existent role + let detail = catalog_store + .get_role_detail(&account_id, "nonexistent") + .await + .expect("Failed to get role detail"); + assert!(detail.is_none()); + println!("✓ Non-existent role detail returns None"); + } +} diff --git a/crates/storage-cassandra/tests/direct/scan.rs b/crates/storage-cassandra/tests/direct/scan.rs new file mode 100644 index 00000000..cb5d2a6c --- /dev/null +++ b/crates/storage-cassandra/tests/direct/scan.rs @@ -0,0 +1,782 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Direct integration tests for the Scan operation. +//! +//! These exercise `DataEngine::scan` against the Cassandra backend directly: +//! full base-table scans (hash-only and composite key), token-based key +//! pagination, parallel-scan token-range segments, and index scans. +//! +//! Scan order in Cassandra follows the token ring (not attribute value order), +//! so pagination/segment tests assert on the *set* of returned items (coverage +//! and absence of duplicates) rather than a specific ordering. + +use extenddb_core::types::AttributeValue; +use extenddb_storage::DataEngine; +use std::collections::BTreeMap; +use std::collections::HashSet; + +use crate::helpers::{TestTable, setup_engine}; + +/// Insert `count` items into a hash-only table with ids `item-000..`. +async fn put_pk_only_items( + engine: &extenddb_storage_cassandra::CassandraEngine, + table: &TestTable, + count: usize, +) { + for i in 0..count { + let mut item = BTreeMap::new(); + item.insert( + "id".to_string(), + AttributeValue::S(format!("item-{:03}", i)), + ); + item.insert("value".to_string(), AttributeValue::N(i.to_string())); + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item should succeed"); + } +} + +/// Collect the `id` attribute (string) from a list of items. +fn ids(items: &[BTreeMap]) -> Vec { + items + .iter() + .map(|item| match item.get("id") { + Some(AttributeValue::S(s)) => s.clone(), + other => panic!("expected string id, got {other:?}"), + }) + .collect() +} + +#[tokio::test] +async fn test_scan_empty_table() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "ScanEmptyTable", false).await; + + let (items, last_key) = engine + .scan(&table.key_info, None, None, None, None, None) + .await + .expect("scan should succeed"); + + assert!(items.is_empty(), "empty table should return no items"); + assert!( + last_key.is_none(), + "empty table should have no pagination key" + ); +} + +#[tokio::test] +async fn test_scan_pk_only_all() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "ScanPkOnlyTable", false).await; + + put_pk_only_items(&engine, &table, 10).await; + + let (items, last_key) = engine + .scan(&table.key_info, None, None, None, None, None) + .await + .expect("scan should succeed"); + + assert_eq!(items.len(), 10, "should return all 10 items"); + assert!( + last_key.is_none(), + "no pagination key when all items returned" + ); + + let unique: HashSet = ids(&items).into_iter().collect(); + assert_eq!(unique.len(), 10, "all returned ids should be unique"); +} + +#[tokio::test] +async fn test_scan_composite_key_all() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "ScanCompositeTable", true).await; + + // 3 partitions, 4 sort keys each = 12 items. + for p in 0..3 { + for s in 0..4 { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S(format!("pk-{p}"))); + item.insert("sort".to_string(), AttributeValue::S(format!("sk-{s:02}"))); + item.insert( + "data".to_string(), + AttributeValue::N((p * 10 + s).to_string()), + ); + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item should succeed"); + } + } + + let (items, last_key) = engine + .scan(&table.key_info, None, None, None, None, None) + .await + .expect("scan should succeed"); + + assert_eq!(items.len(), 12, "should return all 12 items"); + assert!(last_key.is_none()); + + // Every (pk, sk) pair should appear exactly once. + let pairs: HashSet<(String, String)> = items + .iter() + .map(|item| { + let id = match item.get("id") { + Some(AttributeValue::S(s)) => s.clone(), + _ => panic!("expected id"), + }; + let sort = match item.get("sort") { + Some(AttributeValue::S(s)) => s.clone(), + _ => panic!("expected sort"), + }; + (id, sort) + }) + .collect(); + assert_eq!(pairs.len(), 12, "all 12 (pk, sk) pairs should be present"); +} + +#[tokio::test] +async fn test_scan_limit_returns_last_key() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "ScanLimitTable", false).await; + + put_pk_only_items(&engine, &table, 10).await; + + let (items, last_key) = engine + .scan(&table.key_info, Some(4), None, None, None, None) + .await + .expect("scan should succeed"); + + assert_eq!(items.len(), 4, "should return exactly the limit"); + assert!( + last_key.is_some(), + "more items remain, so a LastEvaluatedKey is expected" + ); +} + +#[tokio::test] +async fn test_scan_pagination_pk_only() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "ScanPagePkTable", false).await; + + let total = 10usize; + put_pk_only_items(&engine, &table, total).await; + + // Walk all pages with a small limit and verify full, duplicate-free coverage. + let mut seen: HashSet = HashSet::new(); + let mut start_key: Option> = None; + let mut pages = 0; + + loop { + let (items, last_key) = engine + .scan( + &table.key_info, + Some(3), + start_key.as_ref(), + None, + None, + None, + ) + .await + .expect("scan page should succeed"); + + for id in ids(&items) { + assert!(seen.insert(id.clone()), "duplicate id across pages: {id}"); + } + + pages += 1; + assert!(pages <= total + 2, "pagination did not terminate"); + + match last_key { + Some(k) => start_key = Some(k), + None => break, + } + } + + assert_eq!(seen.len(), total, "all items should be seen exactly once"); +} + +#[tokio::test] +async fn test_scan_pagination_composite_key() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "ScanPageCompositeTable", true).await; + + // 4 partitions x 5 sort keys = 20 items, forcing both pagination queries + // (finish-current-partition and next-partitions). + let mut expected: HashSet<(String, String)> = HashSet::new(); + for p in 0..4 { + for s in 0..5 { + let id = format!("pk-{p}"); + let sort = format!("sk-{s:02}"); + expected.insert((id.clone(), sort.clone())); + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S(id)); + item.insert("sort".to_string(), AttributeValue::S(sort)); + item.insert( + "data".to_string(), + AttributeValue::N((p * 10 + s).to_string()), + ); + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item should succeed"); + } + } + + let mut seen: HashSet<(String, String)> = HashSet::new(); + let mut start_key: Option> = None; + let mut pages = 0; + + loop { + let (items, last_key) = engine + .scan( + &table.key_info, + Some(3), + start_key.as_ref(), + None, + None, + None, + ) + .await + .expect("scan page should succeed"); + + for item in &items { + let id = match item.get("id") { + Some(AttributeValue::S(s)) => s.clone(), + _ => panic!("expected id"), + }; + let sort = match item.get("sort") { + Some(AttributeValue::S(s)) => s.clone(), + _ => panic!("expected sort"), + }; + assert!( + seen.insert((id.clone(), sort.clone())), + "duplicate (pk, sk) across pages: {id}/{sort}" + ); + } + + pages += 1; + assert!(pages <= 30, "pagination did not terminate"); + + match last_key { + Some(k) => start_key = Some(k), + None => break, + } + } + + assert_eq!(seen, expected, "all (pk, sk) pairs covered exactly once"); +} + +#[tokio::test] +async fn test_scan_parallel_segments() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "ScanSegmentsTable", false).await; + + let total_items = 30usize; + put_pk_only_items(&engine, &table, total_items).await; + + let total_segments = 4i64; + let mut seen: HashSet = HashSet::new(); + + for segment in 0..total_segments { + let (items, last_key) = engine + .scan( + &table.key_info, + None, + None, + Some(segment), + Some(total_segments), + None, + ) + .await + .expect("segment scan should succeed"); + + // No limit was set, so each segment should be fully drained in one call. + assert!( + last_key.is_none(), + "segment {segment} should be fully drained" + ); + + for id in ids(&items) { + assert!( + seen.insert(id.clone()), + "id {id} appeared in more than one segment" + ); + } + } + + assert_eq!( + seen.len(), + total_items, + "union of all segments should cover every item exactly once" + ); +} + +#[tokio::test] +async fn test_scan_gsi() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::with_gsi(&engine, "ScanGsiTable", "StatusIndex", "status").await; + + // Make the GSI synchronous so writes land in the index immediately. + let catalog_keyspace = engine.catalog_keyspace(); + let update_delay = format!( + "UPDATE {}.indexes SET propagation_delay_ms = 0 WHERE table_id = ? AND index_name = ?", + catalog_keyspace + ); + engine + .session() + .query_with_values( + &update_delay, + cdrs_tokio::query_values!(table.key_info.table_id.as_str(), "StatusIndex"), + ) + .await + .expect("failed to set GSI to synchronous"); + + let items_data = vec![ + ("item1", "active"), + ("item2", "pending"), + ("item3", "active"), + ("item4", "inactive"), + ]; + for (id, status) in items_data { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S(id.to_string())); + item.insert("status".to_string(), AttributeValue::S(status.to_string())); + item.insert("data".to_string(), AttributeValue::S(format!("data-{id}"))); + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item should succeed"); + } + + // Scan the index table itself (all index entries). + let (items, last_key) = engine + .scan(&table.key_info, None, None, None, None, Some("StatusIndex")) + .await + .expect("index scan should succeed"); + + assert_eq!( + items.len(), + 4, + "index scan should return all 4 projected items" + ); + assert!(last_key.is_none()); + + let unique: HashSet = ids(&items).into_iter().collect(); + assert_eq!(unique.len(), 4, "all 4 index entries should be unique"); +} + +#[tokio::test] +async fn test_scan_gsi_pagination() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::with_gsi(&engine, "ScanGsiPageTable", "StatusIndex", "status").await; + + let catalog_keyspace = engine.catalog_keyspace(); + let update_delay = format!( + "UPDATE {}.indexes SET propagation_delay_ms = 0 WHERE table_id = ? AND index_name = ?", + catalog_keyspace + ); + engine + .session() + .query_with_values( + &update_delay, + cdrs_tokio::query_values!(table.key_info.table_id.as_str(), "StatusIndex"), + ) + .await + .expect("failed to set GSI to synchronous"); + + // Several distinct status values (distinct index partitions) plus repeats + // (same index partition, distinct base keys) to exercise both pagination + // queries on the index table. + let total = 12usize; + let statuses = [ + "active", "pending", "active", "inactive", "active", "pending", + ]; + for i in 0..total { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S(format!("item-{i:02}"))); + item.insert( + "status".to_string(), + AttributeValue::S(statuses[i % statuses.len()].to_string()), + ); + item.insert("data".to_string(), AttributeValue::N(i.to_string())); + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item should succeed"); + } + + let mut seen: HashSet = HashSet::new(); + let mut start_key: Option> = None; + let mut pages = 0; + + loop { + let (items, last_key) = engine + .scan( + &table.key_info, + Some(5), + start_key.as_ref(), + None, + None, + Some("StatusIndex"), + ) + .await + .expect("index scan page should succeed"); + + for id in ids(&items) { + assert!( + seen.insert(id.clone()), + "duplicate id across index pages: {id}" + ); + } + + pages += 1; + assert!(pages <= total + 2, "index pagination did not terminate"); + + // The storage layer returns an index-keys-only LastEvaluatedKey; the + // engine normally enriches it with the base table key. Mimic that here + // so the next page can resume within the correct index partition. + match last_key { + Some(mut k) => { + let last = items.last().expect("non-empty page has a last item"); + if let Some(id) = last.get("id") { + k.insert("id".to_string(), id.clone()); + } + start_key = Some(k); + } + None => break, + } + } + + assert_eq!(seen.len(), total, "all index items seen exactly once"); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Sort-key type coverage (N, B) and LSI scans. +// +// The base-table scan tests above use String sort keys. These exercise the +// numeric (`sk_n`) and binary (`sk_b`) clustering columns — including the +// numeric literal path used by pagination's "finish current partition" query — +// plus a scan over a Local Secondary Index table. +// ───────────────────────────────────────────────────────────────────────────── + +use extenddb_core::types::ScalarAttributeType; + +#[tokio::test] +async fn test_scan_numeric_sort_key_all() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = + TestTable::with_sort_key_type(&engine, "ScanNumSkTable", ScalarAttributeType::N).await; + + // 3 partitions x 4 numeric sort keys = 12 items. + let mut expected: HashSet<(String, String)> = HashSet::new(); + for p in 0..3 { + for s in 0..4 { + let id = format!("pk-{p}"); + let sort = (s * 100).to_string(); + expected.insert((id.clone(), sort.clone())); + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S(id)); + item.insert("sort".to_string(), AttributeValue::N(sort)); + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item should succeed"); + } + } + + let (items, last_key) = engine + .scan(&table.key_info, None, None, None, None, None) + .await + .expect("scan should succeed"); + + assert_eq!(items.len(), 12, "should return all 12 numeric-SK items"); + assert!(last_key.is_none()); + + let pairs: HashSet<(String, String)> = items + .iter() + .map(|item| { + let id = match item.get("id") { + Some(AttributeValue::S(s)) => s.clone(), + _ => panic!("expected id"), + }; + let sort = match item.get("sort") { + Some(AttributeValue::N(n)) => n.clone(), + _ => panic!("expected numeric sort"), + }; + (id, sort) + }) + .collect(); + assert_eq!(pairs, expected, "all numeric (pk, sk) pairs present"); +} + +#[tokio::test] +async fn test_scan_numeric_sort_key_pagination() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = + TestTable::with_sort_key_type(&engine, "ScanNumSkPageTable", ScalarAttributeType::N).await; + + // 4 partitions x 5 numeric sort keys = 20 items, forcing the finish-partition + // (`sk_n > `) and next-partitions queries during pagination. + let mut expected: HashSet<(String, String)> = HashSet::new(); + for p in 0..4 { + for s in 0..5 { + let id = format!("pk-{p}"); + let sort = (s * 10).to_string(); + expected.insert((id.clone(), sort.clone())); + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S(id)); + item.insert("sort".to_string(), AttributeValue::N(sort)); + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item should succeed"); + } + } + + let mut seen: HashSet<(String, String)> = HashSet::new(); + let mut start_key: Option> = None; + let mut pages = 0; + + loop { + let (items, last_key) = engine + .scan( + &table.key_info, + Some(3), + start_key.as_ref(), + None, + None, + None, + ) + .await + .expect("scan page should succeed"); + + for item in &items { + let id = match item.get("id") { + Some(AttributeValue::S(s)) => s.clone(), + _ => panic!("expected id"), + }; + let sort = match item.get("sort") { + Some(AttributeValue::N(n)) => n.clone(), + _ => panic!("expected numeric sort"), + }; + assert!( + seen.insert((id.clone(), sort.clone())), + "duplicate (pk, sk) across pages: {id}/{sort}" + ); + } + + pages += 1; + assert!(pages <= 30, "pagination did not terminate"); + + match last_key { + Some(k) => start_key = Some(k), + None => break, + } + } + + assert_eq!( + seen, expected, + "all numeric (pk, sk) pairs covered exactly once" + ); +} + +#[tokio::test] +async fn test_scan_binary_sort_key_all() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = + TestTable::with_sort_key_type(&engine, "ScanBinSkTable", ScalarAttributeType::B).await; + + // 2 partitions x 3 binary sort keys = 6 items. + let mut expected: HashSet<(String, Vec)> = HashSet::new(); + for p in 0..2u8 { + for s in 0..3u8 { + let id = format!("pk-{p}"); + let sort = vec![p, s, 0xAB]; + expected.insert((id.clone(), sort.clone())); + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S(id)); + item.insert("sort".to_string(), AttributeValue::B(sort)); + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item should succeed"); + } + } + + let (items, last_key) = engine + .scan(&table.key_info, None, None, None, None, None) + .await + .expect("scan should succeed"); + + assert_eq!(items.len(), 6, "should return all 6 binary-SK items"); + assert!(last_key.is_none()); + + let pairs: HashSet<(String, Vec)> = items + .iter() + .map(|item| { + let id = match item.get("id") { + Some(AttributeValue::S(s)) => s.clone(), + _ => panic!("expected id"), + }; + let sort = match item.get("sort") { + Some(AttributeValue::B(b)) => b.clone(), + _ => panic!("expected binary sort"), + }; + (id, sort) + }) + .collect(); + assert_eq!(pairs, expected, "all binary (pk, sk) pairs present"); +} + +#[tokio::test] +async fn test_scan_lsi() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + // Base key: (id S, sort N); LSI key: (id S, priority N). LSIs are always + // synchronous, so index rows are written on put_item. + let table = TestTable::with_lsi(&engine, "ScanLsiTable", "LsiPriority", "priority").await; + + let rows = vec![ + ("user1", 1000, 3), + ("user1", 2000, 1), + ("user2", 1500, 2), + ("user2", 2500, 5), + ]; + for (id, sort, priority) in &rows { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S((*id).to_string())); + item.insert("sort".to_string(), AttributeValue::N(sort.to_string())); + item.insert( + "priority".to_string(), + AttributeValue::N(priority.to_string()), + ); + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put_item should succeed"); + } + + let (items, last_key) = engine + .scan(&table.key_info, None, None, None, None, Some("LsiPriority")) + .await + .expect("LSI scan should succeed"); + + assert_eq!( + items.len(), + 4, + "LSI scan should return all 4 projected items" + ); + assert!(last_key.is_none()); + + // Each item should carry the base + index key attributes. + for item in &items { + assert!(item.contains_key("id"), "projected item missing id"); + assert!( + item.contains_key("priority"), + "projected item missing priority" + ); + } +} diff --git a/crates/storage-cassandra/tests/direct/settings_store.rs b/crates/storage-cassandra/tests/direct/settings_store.rs new file mode 100644 index 00000000..7284f3ec --- /dev/null +++ b/crates/storage-cassandra/tests/direct/settings_store.rs @@ -0,0 +1,110 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Direct integration tests for SettingsStore. +//! +//! Run with: cargo test -- --nocapture + +#[cfg(test)] +mod tests { + use crate::helpers::test_config; + use extenddb_storage::management_store::SettingsStore; + use extenddb_storage_cassandra::CassandraEngine; + + #[tokio::test] + async fn test_settings_store_direct() { + if crate::helpers::skip_without_cassandra() { + return; + } + let config = test_config(); + + let region = "us-east-1"; + + // Create engine directly + let engine = CassandraEngine::new(&config, region) + .await + .expect("Failed to create engine"); + + // Create catalog store directly + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + // This key is a live control-plane knob in the shared catalog, so + // remember what was there and restore it — the mutation must not + // outlive the test. + let original = catalog_store + .get_setting("control_plane_delay_seconds") + .await + .expect("get_setting failed"); + + catalog_store + .set_setting("control_plane_delay_seconds", "0.05") + .await + .expect("set_setting failed"); + + let value = catalog_store + .get_setting("control_plane_delay_seconds") + .await + .expect("get_setting after set failed") + .expect("setting missing after set"); + assert_eq!(value, "0.05"); + + // No delete_setting on the trait; when the key was absent, restore + // the value the readers default to when unset (0.25 in + // read_control_plane_delay) rather than leaving the test value. + let restore = original.unwrap_or_else(|| "0.25".to_string()); + catalog_store + .set_setting("control_plane_delay_seconds", &restore) + .await + .expect("restore setting failed"); + } + + #[tokio::test] + async fn test_list_settings() { + if crate::helpers::skip_without_cassandra() { + return; + } + let config = test_config(); + let engine = CassandraEngine::new(&config, "us-east-1") + .await + .expect("Failed to create engine"); + + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + // Set a few test settings + let _ = catalog_store.set_setting("test_key_1", "value1").await; + let _ = catalog_store.set_setting("test_key_2", "value2").await; + let _ = catalog_store.set_setting("test_key_3", "value3").await; + + // List all settings + match catalog_store.list_settings().await { + Ok(settings) => { + println!( + "✓ list_settings succeeded: {} settings found", + settings.len() + ); + for (key, value) in &settings { + println!(" {} = {}", key, value); + } + + // Verify our test settings exist + assert!( + settings + .iter() + .any(|(k, v)| k == "test_key_1" && v == "value1") + ); + assert!( + settings + .iter() + .any(|(k, v)| k == "test_key_2" && v == "value2") + ); + assert!( + settings + .iter() + .any(|(k, v)| k == "test_key_3" && v == "value3") + ); + println!("✓ Verified test settings in list"); + } + Err(e) => panic!("list_settings failed: {:?}", e), + } + } +} diff --git a/crates/storage-cassandra/tests/direct/streams.rs b/crates/storage-cassandra/tests/direct/streams.rs new file mode 100644 index 00000000..865774d3 --- /dev/null +++ b/crates/storage-cassandra/tests/direct/streams.rs @@ -0,0 +1,637 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Direct integration tests for stream record injection into write batches. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use cdrs_tokio::types::IntoRustByName; +use extenddb_core::types::{AttributeValue, StreamViewType}; +use extenddb_storage::{DataEngine, StreamCapture}; +use extenddb_storage_cassandra::CassandraEngine; + +use crate::helpers::{TestTable, setup_engine}; + +fn capture(view_type: StreamViewType) -> StreamCapture { + StreamCapture { + view_type, + user_identity: None, + region: Arc::from("us-east-1"), + } +} + +/// Create a table with streams enabled and return its key_info + stream_label. +async fn setup_stream_table( + engine: &CassandraEngine, + table_name: &str, +) -> (extenddb_core::types::TableKeyInfo, String) { + use extenddb_core::types::{ + AttributeDefinition, CreateTableInput, KeySchemaElement, KeyType, ScalarAttributeType, + StreamSpecification, TableKeyInfo, + }; + use extenddb_storage::TableEngine; + + let account_id = crate::helpers::unique_test_account(); + crate::helpers::ensure_test_account(engine, &account_id) + .await + .unwrap(); + + let key_schema = vec![KeySchemaElement { + attribute_name: "id".to_string(), + key_type: KeyType::Hash, + }]; + let attribute_definitions = vec![AttributeDefinition { + attribute_name: "id".to_string(), + attribute_type: ScalarAttributeType::S, + }]; + + let input = CreateTableInput { + vector_indexes: None, + table_throughput_mode: None, + table_name: table_name.to_string(), + key_schema: key_schema.clone(), + attribute_definitions: attribute_definitions.clone(), + stream_specification: Some(StreamSpecification { + stream_enabled: true, + stream_view_type: Some(StreamViewType::NewAndOldImages), + }), + local_secondary_indexes: None, + global_secondary_indexes: None, + billing_mode: None, + provisioned_throughput: None, + on_demand_throughput: None, + sse_specification: None, + tags: None, + table_class: None, + deletion_protection_enabled: None, + }; + + let desc = engine.create_table(&account_id, input).await.unwrap(); + + // Fetch stream_label from catalog + let catalog_keyspace = engine.catalog_keyspace(); + let query = format!( + "SELECT stream_label FROM {catalog_keyspace}.tables WHERE account_id = ? AND table_name = ?" + ); + use cdrs_tokio::types::IntoRustByName; + let result = engine + .session_arc() + .query_with_values( + &query, + cdrs_tokio::query_values!(account_id.as_str(), table_name), + ) + .await + .unwrap(); + let stream_label: String = result + .response_body() + .unwrap() + .into_rows() + .unwrap() + .into_iter() + .next() + .unwrap() + .get_r_by_name("stream_label") + .unwrap(); + + let key_info = TableKeyInfo { + vector_indexes: Vec::new(), + table_name: table_name.to_string(), + account_id, + table_id: desc.table_id, + key_schema: key_schema.clone(), + base_key_schema: key_schema, + attribute_definitions, + has_lsi: false, + global_secondary_indexes: Vec::new(), + local_secondary_indexes: Vec::new(), + stream_specification: desc.stream_specification, + }; + + (key_info, stream_label) +} + +/// Query stream_records for a given shard and return (event_name, record_data) rows. +async fn fetch_stream_records( + engine: &CassandraEngine, + account_id: &str, + shard_id: &str, +) -> Vec<(String, String)> { + let keyspace = format!("extenddb_ttl_test_account_{}", account_id); + let query = format!( + "SELECT event_name, record_data FROM {}.stream_records WHERE shard_id = ?", + keyspace + ); + let result = engine + .session_arc() + .query_with_values(&query, cdrs_tokio::query_values!(shard_id)) + .await + .unwrap(); + let body = result.response_body().unwrap(); + body.into_rows() + .unwrap_or_default() + .into_iter() + .map(|row| { + let event: String = row.get_r_by_name("event_name").unwrap(); + let data: String = row.get_r_by_name("record_data").unwrap(); + (event, data) + }) + .collect() +} + +fn shard_for(pk: &str, table_id: &str) -> String { + extenddb_storage_cassandra::stream_util::assign_shard_id(pk, table_id) +} + +#[tokio::test] +async fn test_put_item_insert_writes_stream_record() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "StreamPutInsert", false).await; + + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S("pk-1".to_string())); + item.insert("val".to_string(), AttributeValue::S("hello".to_string())); + + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + Some(&capture(StreamViewType::NewAndOldImages)), + ) + .await + .unwrap(); + + let shard_id = shard_for("pk-1", &table.key_info.table_id); + let records = fetch_stream_records(&engine, &table.key_info.account_id, &shard_id).await; + + assert_eq!(records.len(), 1); + assert_eq!(records[0].0, "Insert"); + let data: serde_json::Value = serde_json::from_str(&records[0].1).unwrap(); + assert_eq!(data["eventName"], "INSERT"); + assert!(data["dynamodb"]["NewImage"].is_object()); + assert!(data["dynamodb"].get("OldImage").is_none()); +} + +#[tokio::test] +async fn test_put_item_overwrite_writes_modify_stream_record() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "StreamPutModify", false).await; + + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S("pk-2".to_string())); + item.insert("val".to_string(), AttributeValue::S("v1".to_string())); + + // First write — no stream capture + engine + .put_item( + &table.key_info, + item.clone(), + false, + None, + &Default::default(), + None, + ) + .await + .unwrap(); + + // Second write — with stream capture + item.insert("val".to_string(), AttributeValue::S("v2".to_string())); + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + Some(&capture(StreamViewType::NewAndOldImages)), + ) + .await + .unwrap(); + + let shard_id = shard_for("pk-2", &table.key_info.table_id); + let records = fetch_stream_records(&engine, &table.key_info.account_id, &shard_id).await; + + assert_eq!(records.len(), 1); + assert_eq!(records[0].0, "Modify"); + let data: serde_json::Value = serde_json::from_str(&records[0].1).unwrap(); + assert_eq!(data["eventName"], "MODIFY"); + assert!(data["dynamodb"]["OldImage"].is_object()); + assert!(data["dynamodb"]["NewImage"].is_object()); +} + +#[tokio::test] +async fn test_delete_item_writes_remove_stream_record() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "StreamDelete", false).await; + + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S("pk-3".to_string())); + item.insert( + "val".to_string(), + AttributeValue::S("to-delete".to_string()), + ); + + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .unwrap(); + + let mut key = BTreeMap::new(); + key.insert("id".to_string(), AttributeValue::S("pk-3".to_string())); + + engine + .delete_item( + &table.key_info, + &key, + false, + None, + &Default::default(), + Some(&capture(StreamViewType::OldImage)), + ) + .await + .unwrap(); + + let shard_id = shard_for("pk-3", &table.key_info.table_id); + let records = fetch_stream_records(&engine, &table.key_info.account_id, &shard_id).await; + + assert_eq!(records.len(), 1); + assert_eq!(records[0].0, "Remove"); + let data: serde_json::Value = serde_json::from_str(&records[0].1).unwrap(); + assert_eq!(data["eventName"], "REMOVE"); + assert!(data["dynamodb"]["OldImage"].is_object()); +} + +#[tokio::test] +async fn test_delete_nonexistent_item_writes_no_stream_record() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "StreamDeleteMissing", false).await; + + let mut key = BTreeMap::new(); + key.insert("id".to_string(), AttributeValue::S("pk-ghost".to_string())); + + engine + .delete_item( + &table.key_info, + &key, + false, + None, + &Default::default(), + Some(&capture(StreamViewType::OldImage)), + ) + .await + .unwrap(); + + let shard_id = shard_for("pk-ghost", &table.key_info.table_id); + let records = fetch_stream_records(&engine, &table.key_info.account_id, &shard_id).await; + assert!(records.is_empty()); +} + +#[tokio::test] +async fn test_keys_only_view_type_omits_images() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "StreamKeysOnly", false).await; + + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S("pk-keys".to_string())); + item.insert("val".to_string(), AttributeValue::S("secret".to_string())); + + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + Some(&capture(StreamViewType::KeysOnly)), + ) + .await + .unwrap(); + + let shard_id = shard_for("pk-keys", &table.key_info.table_id); + let records = fetch_stream_records(&engine, &table.key_info.account_id, &shard_id).await; + + assert_eq!(records.len(), 1); + let data: serde_json::Value = serde_json::from_str(&records[0].1).unwrap(); + assert!(data["dynamodb"].get("NewImage").is_none()); + assert!(data["dynamodb"].get("OldImage").is_none()); + assert!(data["dynamodb"]["Keys"].is_object()); +} + +#[tokio::test] +async fn test_same_pk_records_land_in_same_shard_with_ordered_sequences() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "StreamShardConsistency", false).await; + + for i in 0..3u32 { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S("same-pk".to_string())); + item.insert("val".to_string(), AttributeValue::N(i.to_string())); + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + Some(&capture(StreamViewType::NewImage)), + ) + .await + .unwrap(); + } + + let shard_id = shard_for("same-pk", &table.key_info.table_id); + let records = fetch_stream_records(&engine, &table.key_info.account_id, &shard_id).await; + assert_eq!(records.len(), 3); + + let seqs: Vec = records + .iter() + .map(|(_, data)| { + let v: serde_json::Value = serde_json::from_str(data).unwrap(); + v["dynamodb"]["SequenceNumber"] + .as_str() + .unwrap() + .to_string() + }) + .collect(); + let sorted = { + let mut s = seqs.clone(); + s.sort(); + s + }; + assert_eq!(seqs, sorted, "sequence numbers must be in ascending order"); +} + +// --- Read-side tests --- + +#[tokio::test] +async fn test_validate_shard_ok() { + if crate::helpers::skip_without_cassandra() { + return; + } + use extenddb_storage::StreamEngine; + + let engine = setup_engine().await; + let (key_info, stream_label) = setup_stream_table(&engine, "ValidateShardOk").await; + let arn = extenddb_storage::util::stream_arn( + "us-east-1", + &key_info.account_id, + &key_info.table_name, + &stream_label, + ); + let shard_id = shard_for("any-pk", &key_info.table_id); + + engine + .validate_shard(&key_info.account_id, &arn, &shard_id) + .await + .unwrap(); +} + +#[tokio::test] +async fn test_validate_shard_wrong_arn_fails() { + if crate::helpers::skip_without_cassandra() { + return; + } + use extenddb_storage::StreamEngine; + use extenddb_storage::error::StorageError; + + let engine = setup_engine().await; + let (key_info, _) = setup_stream_table(&engine, "ValidateShardBadArn").await; + let bad_arn = extenddb_storage::util::stream_arn( + "us-east-1", + &key_info.account_id, + &key_info.table_name, + "1970-01-01T00:00:00", + ); + let shard_id = shard_for("any-pk", &key_info.table_id); + + let err = engine + .validate_shard(&key_info.account_id, &bad_arn, &shard_id) + .await + .unwrap_err(); + assert!(matches!(err, StorageError::TableNotFound(_))); +} + +#[tokio::test] +async fn test_latest_sequence_number_empty_shard() { + if crate::helpers::skip_without_cassandra() { + return; + } + use extenddb_storage::StreamEngine; + + let engine = setup_engine().await; + let (key_info, _) = setup_stream_table(&engine, "LatestSeqEmpty").await; + let shard_id = shard_for("any-pk", &key_info.table_id); + + let seq = engine.latest_sequence_number(&shard_id).await.unwrap(); + assert!(seq.is_none()); +} + +#[tokio::test] +async fn test_latest_sequence_number_after_write() { + if crate::helpers::skip_without_cassandra() { + return; + } + use extenddb_storage::StreamEngine; + + let engine = setup_engine().await; + let (key_info, _) = setup_stream_table(&engine, "LatestSeqAfterWrite").await; + + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S("pk-seq".to_string())); + engine + .put_item( + &key_info, + item, + false, + None, + &Default::default(), + Some(&capture(StreamViewType::NewImage)), + ) + .await + .unwrap(); + + let shard_id = shard_for("pk-seq", &key_info.table_id); + let seq = engine.latest_sequence_number(&shard_id).await.unwrap(); + assert!(seq.is_some()); + assert_eq!(seq.unwrap().len(), 23); +} + +#[tokio::test] +async fn test_get_stream_records_returns_written_records() { + if crate::helpers::skip_without_cassandra() { + return; + } + use extenddb_storage::StreamEngine; + + let engine = setup_engine().await; + let (key_info, _) = setup_stream_table(&engine, "GetStreamRecords").await; + + for i in 0..3u32 { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S("pk-read".to_string())); + item.insert("val".to_string(), AttributeValue::N(i.to_string())); + engine + .put_item( + &key_info, + item, + false, + None, + &Default::default(), + Some(&capture(StreamViewType::NewImage)), + ) + .await + .unwrap(); + } + + let shard_id = shard_for("pk-read", &key_info.table_id); + let (records, last_seq) = engine + .get_stream_records(&key_info.account_id, &shard_id, None, 10) + .await + .unwrap(); + + assert_eq!(records.len(), 3); + assert!(last_seq.is_some()); + // All records are for the same PK + for r in &records { + assert!(r.dynamodb.keys.contains_key("id")); + } +} + +#[tokio::test] +async fn test_get_stream_records_after_sequence_paginates() { + if crate::helpers::skip_without_cassandra() { + return; + } + use extenddb_storage::StreamEngine; + + let engine = setup_engine().await; + let (key_info, _) = setup_stream_table(&engine, "GetStreamRecordsPaginate").await; + + for i in 0..4u32 { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S("pk-page".to_string())); + item.insert("val".to_string(), AttributeValue::N(i.to_string())); + engine + .put_item( + &key_info, + item, + false, + None, + &Default::default(), + Some(&capture(StreamViewType::NewImage)), + ) + .await + .unwrap(); + } + + let shard_id = shard_for("pk-page", &key_info.table_id); + let (first_page, last_seq) = engine + .get_stream_records(&key_info.account_id, &shard_id, None, 2) + .await + .unwrap(); + assert_eq!(first_page.len(), 2); + + let (second_page, _) = engine + .get_stream_records(&key_info.account_id, &shard_id, last_seq.as_deref(), 10) + .await + .unwrap(); + assert_eq!(second_page.len(), 2); + + // No overlap + let first_seqs: Vec<_> = first_page + .iter() + .map(|r| &r.dynamodb.sequence_number) + .collect(); + let second_seqs: Vec<_> = second_page + .iter() + .map(|r| &r.dynamodb.sequence_number) + .collect(); + assert!(first_seqs.iter().all(|s| !second_seqs.contains(s))); +} + +#[tokio::test] +async fn test_describe_stream_returns_shards() { + if crate::helpers::skip_without_cassandra() { + return; + } + use extenddb_core::types::DescribeStreamInput; + use extenddb_storage::StreamEngine; + + let engine = setup_engine().await; + let (key_info, stream_label) = setup_stream_table(&engine, "DescribeStream").await; + let arn = extenddb_storage::util::stream_arn( + "us-east-1", + &key_info.account_id, + &key_info.table_name, + &stream_label, + ); + + let desc = engine + .describe_stream( + &key_info.account_id, + &DescribeStreamInput { + stream_arn: arn.clone(), + limit: None, + exclusive_start_shard_id: None, + }, + ) + .await + .unwrap(); + + assert_eq!(desc.stream_arn, arn); + assert_eq!(desc.table_name, key_info.table_name); + assert_eq!(desc.shards.len(), 4); // SHARDS_PER_STREAM + assert!(desc.last_evaluated_shard_id.is_none()); +} + +#[tokio::test] +async fn test_list_streams_includes_stream_enabled_table() { + if crate::helpers::skip_without_cassandra() { + return; + } + use extenddb_storage::StreamEngine; + + let engine = setup_engine().await; + let (key_info, stream_label) = setup_stream_table(&engine, "ListStreams").await; + let expected_arn = extenddb_storage::util::stream_arn( + "us-east-1", + &key_info.account_id, + &key_info.table_name, + &stream_label, + ); + + let (streams, _) = engine + .list_streams(&key_info.account_id, None, 100, None) + .await + .unwrap(); + + assert!(streams.iter().any(|s| s.stream_arn == expected_arn)); +} diff --git a/crates/storage-cassandra/tests/direct/table_engine.rs b/crates/storage-cassandra/tests/direct/table_engine.rs new file mode 100644 index 00000000..1005f113 --- /dev/null +++ b/crates/storage-cassandra/tests/direct/table_engine.rs @@ -0,0 +1,152 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Direct integration tests for TableEngine. +//! +//! Run with: cargo test -- --nocapture + +#[cfg(test)] +mod tests { + use extenddb_storage_cassandra::CassandraEngine; + + /// Tests table lifecycle operations. + /// + /// The test automatically provisions a test account and adjusts replication factors + /// for single-node testing. No manual setup required beyond running Cassandra and + /// `extenddb init --backend cassandra --keyspace-prefix extenddb_test`. + #[tokio::test] + async fn test_table_lifecycle() { + if crate::helpers::skip_without_cassandra() { + return; + } + use crate::helpers::test_config; + use extenddb_core::types::{ + AttributeDefinition, BillingMode, CreateTableInput, DeleteTableInput, + DescribeTableInput, KeySchemaElement, KeyType, ListTablesInput, ScalarAttributeType, + }; + use extenddb_storage::TableEngine; + + let config = test_config(); + let engine = CassandraEngine::new(&config, "us-east-1") + .await + .expect("Failed to create engine"); + + let account_id = crate::helpers::test_account_id(&engine) + .await + .expect("Failed to get test account"); + let table_name = "DirectTestTable"; + + println!("=== Testing Table Lifecycle ==="); + + // Test create_table + let create_input = CreateTableInput { + vector_indexes: None, + table_throughput_mode: None, + table_name: table_name.to_string(), + key_schema: vec![KeySchemaElement { + attribute_name: "pk".to_string(), + key_type: KeyType::Hash, + }], + attribute_definitions: vec![AttributeDefinition { + attribute_name: "pk".to_string(), + attribute_type: ScalarAttributeType::S, + }], + billing_mode: Some(BillingMode::PayPerRequest), + global_secondary_indexes: None, + local_secondary_indexes: None, + provisioned_throughput: None, + on_demand_throughput: None, + sse_specification: None, + stream_specification: None, + tags: None, + deletion_protection_enabled: None, + table_class: None, + }; + + engine + .create_table(&account_id, create_input) + .await + .expect("create_table failed"); + + // Test describe_table + let describe_input = DescribeTableInput { + table_name: table_name.to_string(), + }; + let desc = engine + .describe_table(&account_id, describe_input) + .await + .expect("describe_table failed"); + assert_eq!(desc.table_name, table_name); + + // Test list_tables + let list_input = ListTablesInput { + exclusive_start_table_name: None, + limit: None, + }; + let listed = engine + .list_tables(&account_id, list_input) + .await + .expect("list_tables failed"); + assert!( + listed.table_names.iter().any(|t| t == table_name), + "created table missing from list_tables" + ); + + // Test delete_table + let delete_input = DeleteTableInput { + table_name: table_name.to_string(), + }; + engine + .delete_table(&account_id, delete_input) + .await + .expect("delete_table failed"); + + // Verify deletion + let describe_input = DescribeTableInput { + table_name: table_name.to_string(), + }; + assert!( + engine + .describe_table(&account_id, describe_input) + .await + .is_err(), + "table still describable after deletion" + ); + } + + /// Tests index_info_by_table_id against a table with a GSI. + /// + /// Uses the table_id path (the engine's hot path for index Query/Scan + /// routing) because it doesn't require the table to be ACTIVE — in these + /// direct tests there is no control-plane worker to transition the table + /// out of CREATING, which index_info()-by-name would require via + /// fetch_table_key_info. The by-name wrapper shares this logic and is + /// covered by the Python integration suite (where tables are ACTIVE). + #[tokio::test] + async fn test_index_info() { + if crate::helpers::skip_without_cassandra() { + return; + } + use crate::helpers::{TestTable, setup_engine}; + use extenddb_core::types::IndexType; + use extenddb_storage::TableEngine; + + let engine = setup_engine().await; + let table = TestTable::with_gsi(&engine, "IndexInfoTable", "StatusIndex", "status").await; + + // Known index resolves to its metadata. + let info = engine + .index_info_by_table_id(&table.key_info.table_id, "StatusIndex") + .await + .expect("index_info_by_table_id should succeed"); + assert_eq!(info.index_name, "StatusIndex"); + assert!(matches!(info.index_type, IndexType::Gsi)); + assert_eq!(info.key_schema[0].attribute_name, "status"); + + // Unknown index returns an error (not the old "not yet implemented" stub). + let missing = engine + .index_info_by_table_id(&table.key_info.table_id, "NoSuchIndex") + .await; + assert!(missing.is_err(), "unknown index should return an error"); + } +} diff --git a/crates/storage-cassandra/tests/direct/transact_get_items.rs b/crates/storage-cassandra/tests/direct/transact_get_items.rs new file mode 100644 index 00000000..77ea0dd0 --- /dev/null +++ b/crates/storage-cassandra/tests/direct/transact_get_items.rs @@ -0,0 +1,448 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Direct integration tests for TransactGetItems. + +use extenddb_core::types::AttributeValue; +use extenddb_storage::error::StorageError; +use extenddb_storage::{DataEngine, TransactGetOp}; +use std::collections::BTreeMap; + +use crate::helpers::{TestTable, setup_engine}; + +#[tokio::test] +async fn test_transact_get_items_single_item() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TransactGetSingleTable", false).await; + + // Put an item + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S("item-1".to_string())); + item.insert( + "name".to_string(), + AttributeValue::S("Test Item".to_string()), + ); + item.insert("count".to_string(), AttributeValue::N("42".to_string())); + + engine + .put_item( + &table.key_info, + item.clone(), + false, + None, + &Default::default(), + None, + ) + .await + .expect("Put item failed"); + + // TransactGetItems with single item + let mut key = BTreeMap::new(); + key.insert("id".to_string(), AttributeValue::S("item-1".to_string())); + + let ops = vec![TransactGetOp { + key_info: &table.key_info, + key: &key, + }]; + + let results = engine + .transact_get_items(&ops) + .await + .expect("TransactGetItems failed"); + + assert_eq!(results.len(), 1); + let retrieved = results[0].as_ref().expect("Item should exist"); + assert_eq!(retrieved.get("id"), item.get("id")); + assert_eq!(retrieved.get("name"), item.get("name")); + assert_eq!(retrieved.get("count"), item.get("count")); +} + +#[tokio::test] +async fn test_transact_get_items_multiple_items() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TransactGetMultiTable", false).await; + + // Put multiple items + for i in 1..=5 { + let mut item = BTreeMap::new(); + item.insert("id".to_string(), AttributeValue::S(format!("item-{}", i))); + item.insert("value".to_string(), AttributeValue::N(i.to_string())); + + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("Put item failed"); + } + + // TransactGetItems with multiple items + let ops: Vec = (1..=5) + .map(|i| { + let mut key = BTreeMap::new(); + key.insert("id".to_string(), AttributeValue::S(format!("item-{}", i))); + TransactGetOp { + key_info: &table.key_info, + key: Box::leak(Box::new(key)), + } + }) + .collect(); + + let results = engine + .transact_get_items(&ops) + .await + .expect("TransactGetItems failed"); + + assert_eq!(results.len(), 5); + for (i, result) in results.iter().enumerate() { + let item = result.as_ref().expect("Item should exist"); + let expected_id = format!("item-{}", i + 1); + assert_eq!( + item.get("id"), + Some(&AttributeValue::S(expected_id.clone())) + ); + assert_eq!( + item.get("value"), + Some(&AttributeValue::N((i + 1).to_string())) + ); + } +} + +#[tokio::test] +async fn test_transact_get_items_nonexistent_item() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TransactGetNonexistentTable", false).await; + + // Put one item + let mut item1 = BTreeMap::new(); + item1.insert("id".to_string(), AttributeValue::S("exists".to_string())); + item1.insert( + "data".to_string(), + AttributeValue::S("some data".to_string()), + ); + + engine + .put_item( + &table.key_info, + item1, + false, + None, + &Default::default(), + None, + ) + .await + .unwrap(); + + // TransactGetItems with mix of existent and non-existent + let mut key1 = BTreeMap::new(); + key1.insert("id".to_string(), AttributeValue::S("exists".to_string())); + + let mut key2 = BTreeMap::new(); + key2.insert( + "id".to_string(), + AttributeValue::S("does-not-exist".to_string()), + ); + + let ops = vec![ + TransactGetOp { + key_info: &table.key_info, + key: &key1, + }, + TransactGetOp { + key_info: &table.key_info, + key: &key2, + }, + ]; + + let results = engine + .transact_get_items(&ops) + .await + .expect("TransactGetItems failed"); + + assert_eq!(results.len(), 2); + assert!(results[0].is_some(), "First item should exist"); + assert!(results[1].is_none(), "Second item should not exist"); +} + +#[tokio::test] +async fn test_transact_get_items_with_sort_key() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TransactGetWithSKTable", true).await; + + // Put items with sort keys + for i in 1..=3 { + let mut item = BTreeMap::new(); + item.insert( + "id".to_string(), + AttributeValue::S("partition-1".to_string()), + ); + item.insert("sort".to_string(), AttributeValue::S(format!("sort-{}", i))); + item.insert("data".to_string(), AttributeValue::N(i.to_string())); + + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .unwrap(); + } + + // TransactGetItems with composite keys + let ops: Vec = (1..=3) + .map(|i| { + let mut key = BTreeMap::new(); + key.insert( + "id".to_string(), + AttributeValue::S("partition-1".to_string()), + ); + key.insert("sort".to_string(), AttributeValue::S(format!("sort-{}", i))); + TransactGetOp { + key_info: &table.key_info, + key: Box::leak(Box::new(key)), + } + }) + .collect(); + + let results = engine + .transact_get_items(&ops) + .await + .expect("TransactGetItems failed"); + + assert_eq!(results.len(), 3); + for (i, result) in results.iter().enumerate() { + let item = result.as_ref().expect("Item should exist"); + assert_eq!( + item.get("sort"), + Some(&AttributeValue::S(format!("sort-{}", i + 1))) + ); + assert_eq!( + item.get("data"), + Some(&AttributeValue::N((i + 1).to_string())) + ); + } +} + +#[tokio::test] +async fn test_transact_get_items_cross_table() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + + // Create both tables in the same account + let account_id = crate::helpers::unique_test_account(); + let table1 = + TestTable::with_account(&engine, &account_id, "TransactGetCrossTable1", false).await; + let table2 = + TestTable::with_account(&engine, &account_id, "TransactGetCrossTable2", false).await; + + // Put items in different tables + let mut item1 = BTreeMap::new(); + item1.insert( + "id".to_string(), + AttributeValue::S("table1-item".to_string()), + ); + item1.insert( + "source".to_string(), + AttributeValue::S("table1".to_string()), + ); + + let mut item2 = BTreeMap::new(); + item2.insert( + "id".to_string(), + AttributeValue::S("table2-item".to_string()), + ); + item2.insert( + "source".to_string(), + AttributeValue::S("table2".to_string()), + ); + + engine + .put_item( + &table1.key_info, + item1, + false, + None, + &Default::default(), + None, + ) + .await + .unwrap(); + + engine + .put_item( + &table2.key_info, + item2, + false, + None, + &Default::default(), + None, + ) + .await + .unwrap(); + + // TransactGetItems across tables + let mut key1 = BTreeMap::new(); + key1.insert( + "id".to_string(), + AttributeValue::S("table1-item".to_string()), + ); + + let mut key2 = BTreeMap::new(); + key2.insert( + "id".to_string(), + AttributeValue::S("table2-item".to_string()), + ); + + let ops = vec![ + TransactGetOp { + key_info: &table1.key_info, + key: &key1, + }, + TransactGetOp { + key_info: &table2.key_info, + key: &key2, + }, + ]; + + let results = engine + .transact_get_items(&ops) + .await + .expect("TransactGetItems should succeed across tables in same account"); + + assert_eq!(results.len(), 2); + assert_eq!( + results[0].as_ref().unwrap().get("source"), + Some(&AttributeValue::S("table1".to_string())) + ); + assert_eq!( + results[1].as_ref().unwrap().get("source"), + Some(&AttributeValue::S("table2".to_string())) + ); +} + +#[tokio::test] +async fn test_transact_get_items_validation_error() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TransactGetValidationTable", true).await; + + // Create a key with wrong type (should have both id and sort) + let mut bad_key = BTreeMap::new(); + bad_key.insert("id".to_string(), AttributeValue::S("test".to_string())); + // Missing sort - validation should fail + + let ops = vec![TransactGetOp { + key_info: &table.key_info, + key: &bad_key, + }]; + + let result = engine.transact_get_items(&ops).await; + + // Should get TransactionCanceled with validation error + match result { + Err(StorageError::TransactionCanceled(reasons)) => { + assert_eq!(reasons.len(), 1); + assert_eq!(reasons[0].code, "ValidationError"); + } + _ => panic!("Expected TransactionCanceled with ValidationError"), + } +} + +#[tokio::test] +async fn test_transact_get_items_max_items() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TransactGetMaxItemsTable", false).await; + + // Put 100 items (DynamoDB limit) + for i in 1..=100 { + let mut item = BTreeMap::new(); + item.insert( + "id".to_string(), + AttributeValue::S(format!("item-{:03}", i)), + ); + item.insert("index".to_string(), AttributeValue::N(i.to_string())); + + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .unwrap(); + } + + // TransactGetItems with 100 items (max allowed) + let ops: Vec = (1..=100) + .map(|i| { + let mut key = BTreeMap::new(); + key.insert( + "id".to_string(), + AttributeValue::S(format!("item-{:03}", i)), + ); + TransactGetOp { + key_info: &table.key_info, + key: Box::leak(Box::new(key)), + } + }) + .collect(); + + let results = engine + .transact_get_items(&ops) + .await + .expect("TransactGetItems with 100 items should succeed"); + + assert_eq!(results.len(), 100); + for result in &results { + assert!(result.is_some(), "All items should exist"); + } +} + +#[tokio::test] +async fn test_transact_get_items_empty() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + + // TransactGetItems with empty ops list + let ops: Vec = vec![]; + + let results = engine + .transact_get_items(&ops) + .await + .expect("Empty TransactGetItems should succeed"); + + assert_eq!(results.len(), 0); +} diff --git a/crates/storage-cassandra/tests/direct/transact_write_items.rs b/crates/storage-cassandra/tests/direct/transact_write_items.rs new file mode 100644 index 00000000..1e1a8b12 --- /dev/null +++ b/crates/storage-cassandra/tests/direct/transact_write_items.rs @@ -0,0 +1,585 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Integration tests for TransactWriteItems (two-phase commit). + +use extenddb_core::expression::ExpressionMaps; +use extenddb_core::types::{AttributeValue, Item, ReturnValuesOnConditionCheckFailure}; +use extenddb_storage::{DataEngine, IdempotencyKey, TransactWriteOp}; + +use crate::helpers::{TestTable, setup_engine}; + +#[tokio::test] +async fn test_transact_write_put_simple() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TxnPutSimple", false).await; + + // Create an item to put + let mut item = Item::new(); + item.insert("id".to_string(), AttributeValue::S("test_key".to_string())); + item.insert("value".to_string(), AttributeValue::N("42".to_string())); + + let maps = ExpressionMaps::default(); + + // Create transaction with single Put operation + let ops = vec![TransactWriteOp::Put { + key_info: &table.key_info, + item: &item, + condition: None, + maps: &maps, + return_values_on_ccf: ReturnValuesOnConditionCheckFailure::None, + stream: None, + }]; + + let result = engine.transact_write_items(&ops, None).await; + + // Should succeed + assert!( + result.is_ok(), + "Transaction should succeed: {:?}", + result.err() + ); + + // Verify item was written + let mut key = Item::new(); + key.insert("id".to_string(), AttributeValue::S("test_key".to_string())); + + let retrieved = engine + .get_item(&table.key_info, &key) + .await + .expect("get_item should succeed"); + + assert!(retrieved.is_some(), "Item should exist after commit"); + let retrieved_item = retrieved.unwrap(); + assert_eq!( + retrieved_item.get("value"), + Some(&AttributeValue::N("42".to_string())), + "Item value should match" + ); +} + +#[tokio::test] +async fn test_transact_write_put_and_delete() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TxnPutDel", false).await; + + let maps = ExpressionMaps::default(); + + // First, put an item using regular put_item + let mut item1 = Item::new(); + item1.insert("id".to_string(), AttributeValue::S("key1".to_string())); + item1.insert( + "value".to_string(), + AttributeValue::S("original".to_string()), + ); + + engine + .put_item(&table.key_info, item1.clone(), false, None, &maps, None) + .await + .expect("Initial put_item should succeed"); + + // Now create a transaction that: + // 1. Puts a new item (key2) + // 2. Deletes the existing item (key1) + let mut item2 = Item::new(); + item2.insert("id".to_string(), AttributeValue::S("key2".to_string())); + item2.insert("value".to_string(), AttributeValue::S("new".to_string())); + + let mut key1 = Item::new(); + key1.insert("id".to_string(), AttributeValue::S("key1".to_string())); + + let ops = vec![ + TransactWriteOp::Put { + key_info: &table.key_info, + item: &item2, + condition: None, + maps: &maps, + return_values_on_ccf: ReturnValuesOnConditionCheckFailure::None, + stream: None, + }, + TransactWriteOp::Delete { + key_info: &table.key_info, + key: &key1, + condition: None, + maps: &maps, + return_values_on_ccf: ReturnValuesOnConditionCheckFailure::None, + stream: None, + }, + ]; + + let result = engine.transact_write_items(&ops, None).await; + assert!( + result.is_ok(), + "Transaction should succeed: {:?}", + result.err() + ); + + // Verify key1 was deleted + let retrieved1 = engine + .get_item(&table.key_info, &key1) + .await + .expect("get_item should succeed"); + assert!(retrieved1.is_none(), "key1 should be deleted"); + + // Verify key2 was written + let mut key2 = Item::new(); + key2.insert("id".to_string(), AttributeValue::S("key2".to_string())); + + let retrieved2 = engine + .get_item(&table.key_info, &key2) + .await + .expect("get_item should succeed"); + assert!(retrieved2.is_some(), "key2 should exist"); + assert_eq!( + retrieved2.unwrap().get("value"), + Some(&AttributeValue::S("new".to_string())), + "key2 value should match" + ); +} + +#[tokio::test] +async fn test_transact_write_rollback_on_condition_failure() { + if crate::helpers::skip_without_cassandra() { + return; + } + use std::collections::HashMap; + + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TxnRollback", false).await; + + // First, put an item with value=10 + let mut item1 = Item::new(); + item1.insert("id".to_string(), AttributeValue::S("key1".to_string())); + item1.insert("value".to_string(), AttributeValue::N("10".to_string())); + + let maps = ExpressionMaps::default(); + + engine + .put_item(&table.key_info, item1.clone(), false, None, &maps, None) + .await + .expect("Initial put_item should succeed"); + + // Create a transaction with a condition that will fail + // Condition: value = 999 (which is false, so transaction will rollback) + use extenddb_core::expression::{CompareOp, Expr, PathElement}; + + // Create expression maps with the comparison value + let mut values_map = HashMap::new(); + values_map.insert("val1".to_string(), AttributeValue::N("999".to_string())); + let maps_with_condition = ExpressionMaps::new(HashMap::new(), values_map); + + let condition = Expr::Compare { + left: Box::new(Expr::Path(vec![PathElement::Attribute( + "value".to_string(), + )])), + op: CompareOp::Eq, + right: Box::new(Expr::Placeholder("val1".to_string())), + }; + + // Put a new item (key2) + let mut item2 = Item::new(); + item2.insert("id".to_string(), AttributeValue::S("key2".to_string())); + item2.insert("value".to_string(), AttributeValue::S("new".to_string())); + + let ops = vec![ + TransactWriteOp::Put { + key_info: &table.key_info, + item: &item2, + condition: None, + maps: &maps_with_condition, + return_values_on_ccf: ReturnValuesOnConditionCheckFailure::None, + stream: None, + }, + TransactWriteOp::ConditionCheck { + key_info: &table.key_info, + key: &item1, + condition: &condition, + maps: &maps_with_condition, + return_values_on_ccf: ReturnValuesOnConditionCheckFailure::None, + }, + ]; + + // Execute the transaction - should fail with TransactionCanceled + let result = engine.transact_write_items(&ops, None).await; + assert!( + result.is_err(), + "Transaction should fail due to condition check" + ); + + // Verify key2 was NOT written (transaction was rolled back) + let mut key2 = Item::new(); + key2.insert("id".to_string(), AttributeValue::S("key2".to_string())); + + let retrieved2 = engine + .get_item(&table.key_info, &key2) + .await + .expect("get_item should succeed"); + assert!( + retrieved2.is_none(), + "key2 should not exist (transaction rolled back)" + ); + + // Verify key1 still exists unchanged + let mut key1 = Item::new(); + key1.insert("id".to_string(), AttributeValue::S("key1".to_string())); + + let retrieved1 = engine + .get_item(&table.key_info, &key1) + .await + .expect("get_item should succeed"); + assert!(retrieved1.is_some(), "key1 should still exist"); + assert_eq!( + retrieved1.unwrap().get("value"), + Some(&AttributeValue::N("10".to_string())), + "key1 should be unchanged" + ); +} + +#[tokio::test] +async fn test_transact_write_rollback_update_existing_item() { + if crate::helpers::skip_without_cassandra() { + return; + } + // Rollback must restore an existing item's prepared_txn_id to null without + // deleting the row (created_to_prepare=false path). + use extenddb_core::expression::{CompareOp, Expr, PathElement, UpdateAction}; + use std::collections::HashMap; + + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TxnRollbackUpdate", false).await; + + // Write an existing item + let mut existing = Item::new(); + existing.insert("id".to_string(), AttributeValue::S("upd_key".to_string())); + existing.insert("value".to_string(), AttributeValue::N("100".to_string())); + let maps_empty = ExpressionMaps::default(); + engine + .put_item( + &table.key_info, + existing.clone(), + false, + None, + &maps_empty, + None, + ) + .await + .expect("setup put_item"); + + let mut key = Item::new(); + key.insert("id".to_string(), AttributeValue::S("upd_key".to_string())); + + // Update sets value = :new_val (999) + let mut values_map = HashMap::new(); + values_map.insert("new_val".to_string(), AttributeValue::N("999".to_string())); + // ConditionCheck on a non-existent item forces rollback + values_map.insert("v".to_string(), AttributeValue::S("x".to_string())); + let maps_with_vals = ExpressionMaps::new(HashMap::new(), values_map); + + let set_action = UpdateAction::Set { + path: vec![PathElement::Attribute("value".to_string())], + value: Expr::Placeholder("new_val".to_string()), + }; + + let mut other_key = Item::new(); + other_key.insert( + "id".to_string(), + AttributeValue::S("no_such_key".to_string()), + ); + let failing_condition = Expr::Compare { + left: Box::new(Expr::Path(vec![PathElement::Attribute( + "value".to_string(), + )])), + op: CompareOp::Eq, + right: Box::new(Expr::Placeholder("v".to_string())), + }; + + let actions = [set_action]; + let ops = vec![ + TransactWriteOp::Update { + key_info: &table.key_info, + key: &key, + actions: &actions, + condition: None, + maps: &maps_with_vals, + return_values_on_ccf: ReturnValuesOnConditionCheckFailure::None, + stream: None, + }, + TransactWriteOp::ConditionCheck { + key_info: &table.key_info, + key: &other_key, + condition: &failing_condition, + maps: &maps_with_vals, + return_values_on_ccf: ReturnValuesOnConditionCheckFailure::None, + }, + ]; + + let result = engine.transact_write_items(&ops, None).await; + assert!(result.is_err(), "Transaction should be cancelled"); + + // Item must still exist with original value and be unlocked + let retrieved = engine + .get_item(&table.key_info, &key) + .await + .expect("get_item"); + assert!(retrieved.is_some(), "item must still exist after rollback"); + assert_eq!( + retrieved.unwrap().get("value"), + Some(&AttributeValue::N("100".to_string())), + "item value must be unchanged after rollback" + ); +} + +#[tokio::test] +async fn test_transact_write_rollback_delete_existing_item() { + if crate::helpers::skip_without_cassandra() { + return; + } + // Rollback of a Delete must leave the existing item intact and unlocked. + use extenddb_core::expression::{CompareOp, Expr, PathElement}; + use std::collections::HashMap; + + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TxnRollbackDelete", false).await; + let maps = ExpressionMaps::default(); + + // Write an existing item + let mut existing = Item::new(); + existing.insert("id".to_string(), AttributeValue::S("del_key".to_string())); + existing.insert( + "value".to_string(), + AttributeValue::S("keep_me".to_string()), + ); + engine + .put_item(&table.key_info, existing.clone(), false, None, &maps, None) + .await + .expect("setup put_item"); + + let mut key = Item::new(); + key.insert("id".to_string(), AttributeValue::S("del_key".to_string())); + + // ConditionCheck on a non-existent item - will fail, forcing rollback + let mut other_key = Item::new(); + other_key.insert( + "id".to_string(), + AttributeValue::S("no_such_key".to_string()), + ); + + let mut values_map = HashMap::new(); + values_map.insert("v".to_string(), AttributeValue::S("x".to_string())); + let failing_maps = ExpressionMaps::new(HashMap::new(), values_map); + + let failing_condition = Expr::Compare { + left: Box::new(Expr::Path(vec![PathElement::Attribute( + "value".to_string(), + )])), + op: CompareOp::Eq, + right: Box::new(Expr::Placeholder("v".to_string())), + }; + + let ops = vec![ + TransactWriteOp::Delete { + key_info: &table.key_info, + key: &key, + condition: None, + maps: &maps, + return_values_on_ccf: ReturnValuesOnConditionCheckFailure::None, + stream: None, + }, + TransactWriteOp::ConditionCheck { + key_info: &table.key_info, + key: &other_key, + condition: &failing_condition, + maps: &failing_maps, + return_values_on_ccf: ReturnValuesOnConditionCheckFailure::None, + }, + ]; + + let result = engine.transact_write_items(&ops, None).await; + assert!(result.is_err(), "Transaction should be cancelled"); + + // Item must still exist and be readable (not locked) + let retrieved = engine + .get_item(&table.key_info, &key) + .await + .expect("get_item"); + assert!(retrieved.is_some(), "item must still exist after rollback"); + assert_eq!( + retrieved.unwrap().get("value"), + Some(&AttributeValue::S("keep_me".to_string())), + "item value must be unchanged after rollback" + ); + + // Verify item is no longer locked (a subsequent write should succeed) + engine + .put_item(&table.key_info, existing.clone(), false, None, &maps, None) + .await + .expect("put_item after rollback should succeed (item not locked)"); +} + +#[tokio::test] +async fn test_idempotency_token_replay() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TxnIdempotencyReplay", false).await; + let token = format!("tok-replay-{}", uuid::Uuid::new_v4().simple()); + + let mut item = Item::new(); + item.insert("id".to_string(), AttributeValue::S("idem_key".to_string())); + let maps = ExpressionMaps::default(); + let ops = vec![TransactWriteOp::Put { + key_info: &table.key_info, + item: &item, + condition: None, + maps: &maps, + return_values_on_ccf: ReturnValuesOnConditionCheckFailure::None, + stream: None, + }]; + + engine + .transact_write_items( + &ops, + Some(IdempotencyKey { + account_id: &table.key_info.account_id, + token: &token, + fingerprint: "fp-123", + }), + ) + .await + .expect("First call should succeed"); + + let result = engine + .transact_write_items( + &ops, + Some(IdempotencyKey { + account_id: &table.key_info.account_id, + token: &token, + fingerprint: "fp-123", + }), + ) + .await; + assert!( + matches!( + result, + Err(extenddb_storage::error::StorageError::IdempotentReplay) + ), + "Expected IdempotentReplay, got: {:?}", + result + ); +} + +#[tokio::test] +async fn test_idempotency_token_mismatch() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table = TestTable::new(&engine, "TxnIdempotencyMismatch", false).await; + let token = format!("tok-mismatch-{}", uuid::Uuid::new_v4().simple()); + + let mut item = Item::new(); + item.insert("id".to_string(), AttributeValue::S("idem_key2".to_string())); + let maps = ExpressionMaps::default(); + let ops = vec![TransactWriteOp::Put { + key_info: &table.key_info, + item: &item, + condition: None, + maps: &maps, + return_values_on_ccf: ReturnValuesOnConditionCheckFailure::None, + stream: None, + }]; + + engine + .transact_write_items( + &ops, + Some(IdempotencyKey { + account_id: &table.key_info.account_id, + token: &token, + fingerprint: "fp-aaa", + }), + ) + .await + .expect("First call should succeed"); + + let result = engine + .transact_write_items( + &ops, + Some(IdempotencyKey { + account_id: &table.key_info.account_id, + token: &token, + fingerprint: "fp-bbb", + }), + ) + .await; + assert!( + matches!( + result, + Err(extenddb_storage::error::StorageError::IdempotentMismatch) + ), + "Expected IdempotentMismatch, got: {:?}", + result + ); +} + +#[tokio::test] +async fn test_idempotency_token_is_scoped_to_account() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let table_a = TestTable::new(&engine, "TxnIdempotencyAccountA", false).await; + let table_b = TestTable::new(&engine, "TxnIdempotencyAccountB", false).await; + let token = format!("shared-token-{}", uuid::Uuid::new_v4().simple()); + let maps = ExpressionMaps::default(); + + let mut item_a = Item::new(); + item_a.insert("id".to_owned(), AttributeValue::S("account-a".to_owned())); + let ops_a = vec![TransactWriteOp::Put { + key_info: &table_a.key_info, + item: &item_a, + condition: None, + maps: &maps, + return_values_on_ccf: ReturnValuesOnConditionCheckFailure::None, + stream: None, + }]; + engine + .transact_write_items( + &ops_a, + Some(IdempotencyKey { + account_id: &table_a.key_info.account_id, + token: &token, + fingerprint: "same-fingerprint", + }), + ) + .await + .expect("first account should reserve the token"); + + let mut item_b = Item::new(); + item_b.insert("id".to_owned(), AttributeValue::S("account-b".to_owned())); + let ops_b = vec![TransactWriteOp::Put { + key_info: &table_b.key_info, + item: &item_b, + condition: None, + maps: &maps, + return_values_on_ccf: ReturnValuesOnConditionCheckFailure::None, + stream: None, + }]; + engine + .transact_write_items( + &ops_b, + Some(IdempotencyKey { + account_id: &table_b.key_info.account_id, + token: &token, + fingerprint: "same-fingerprint", + }), + ) + .await + .expect("the same token in another account must not replay"); +} diff --git a/crates/storage-cassandra/tests/direct/transaction_ledger.rs b/crates/storage-cassandra/tests/direct/transaction_ledger.rs new file mode 100644 index 00000000..ab4d9732 --- /dev/null +++ b/crates/storage-cassandra/tests/direct/transaction_ledger.rs @@ -0,0 +1,296 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Direct integration tests for transaction ledger operations. + +use extenddb_storage::error::StorageError; +use extenddb_storage_cassandra::data::transaction_ledger::TransactionState; +use uuid::Uuid; + +use crate::helpers::{ensure_test_account, setup_engine, unique_test_account}; + +#[tokio::test] +async fn test_write_and_read_ledger_entry() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let account_id = unique_test_account(); + ensure_test_account(&engine, &account_id).await.unwrap(); + let keyspace = engine.account_keyspace(&account_id); + + let txn_id = Uuid::new_v4(); + let started_at = 1000000; + let items_blob = r#"[{"table":"t1","pk":"a","sk":"b"}]"#; + + // Write ledger entry + engine + .write_ledger_entry( + &keyspace, + txn_id, + TransactionState::Preparing, + started_at, + Some("client-token-1"), + Some("fingerprint-1"), + items_blob, + ) + .await + .expect("Write ledger entry failed"); + + // Read it back + let entry = engine + .read_ledger_entry(&keyspace, txn_id) + .await + .expect("Read ledger entry failed") + .expect("Entry should exist"); + + assert_eq!(entry.txn_id, txn_id); + assert_eq!(entry.state, "PREPARING"); + assert_eq!(entry.started_at, started_at); + assert_eq!(entry.client_token, Some("client-token-1".to_string())); + assert_eq!(entry.request_fingerprint, Some("fingerprint-1".to_string())); + assert_eq!(entry.items_blob, items_blob); +} + +#[tokio::test] +async fn test_write_duplicate_txn_id_fails() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let account_id = unique_test_account(); + ensure_test_account(&engine, &account_id).await.unwrap(); + let keyspace = engine.account_keyspace(&account_id); + + let txn_id = Uuid::new_v4(); + let items_blob = r#"[{"table":"t1"}]"#; + + // First write succeeds + engine + .write_ledger_entry( + &keyspace, + txn_id, + TransactionState::Preparing, + 1000000, + None, + None, + items_blob, + ) + .await + .expect("First write should succeed"); + + // Second write with same txn_id fails + let result = engine + .write_ledger_entry( + &keyspace, + txn_id, + TransactionState::Preparing, + 2000000, + None, + None, + items_blob, + ) + .await; + + assert!(result.is_err(), "Duplicate txn_id should fail"); + match result { + Err(StorageError::Internal(msg)) => { + assert!(msg.contains("already exists"), "Error message: {}", msg); + } + _ => panic!("Expected Internal error with 'already exists'"), + } +} + +#[tokio::test] +async fn test_update_ledger_state() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let account_id = unique_test_account(); + ensure_test_account(&engine, &account_id).await.unwrap(); + let keyspace = engine.account_keyspace(&account_id); + + let txn_id = Uuid::new_v4(); + let items_blob = r#"[{"table":"t1"}]"#; + + // Create entry + engine + .write_ledger_entry( + &keyspace, + txn_id, + TransactionState::Preparing, + 1000000, + None, + None, + items_blob, + ) + .await + .unwrap(); + + // Update state to committing + engine + .update_ledger_state(&keyspace, txn_id, TransactionState::Committing) + .await + .expect("Update state failed"); + + // Verify state changed + let entry = engine + .read_ledger_entry(&keyspace, txn_id) + .await + .unwrap() + .unwrap(); + assert_eq!(entry.state, "COMMITTING"); + + // Update to cancelling + engine + .update_ledger_state(&keyspace, txn_id, TransactionState::Cancelling) + .await + .unwrap(); + + let entry = engine + .read_ledger_entry(&keyspace, txn_id) + .await + .unwrap() + .unwrap(); + assert_eq!(entry.state, "CANCELLING"); +} + +#[tokio::test] +async fn test_delete_ledger_entry() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let account_id = unique_test_account(); + ensure_test_account(&engine, &account_id).await.unwrap(); + let keyspace = engine.account_keyspace(&account_id); + + let txn_id = Uuid::new_v4(); + let items_blob = r#"[{"table":"t1"}]"#; + + // Create entry + engine + .write_ledger_entry( + &keyspace, + txn_id, + TransactionState::Preparing, + 1000000, + None, + None, + items_blob, + ) + .await + .unwrap(); + + // Verify it exists + assert!( + engine + .read_ledger_entry(&keyspace, txn_id) + .await + .unwrap() + .is_some() + ); + + // Delete it + engine + .delete_ledger_entry(&keyspace, txn_id) + .await + .expect("Delete failed"); + + // Verify it's gone + assert!( + engine + .read_ledger_entry(&keyspace, txn_id) + .await + .unwrap() + .is_none() + ); +} + +#[tokio::test] +async fn test_scan_old_transactions() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let account_id = unique_test_account(); + ensure_test_account(&engine, &account_id).await.unwrap(); + let keyspace = engine.account_keyspace(&account_id); + + let items_blob = r#"[{"table":"t1"}]"#; + + // Create transactions with different timestamps + let old_txn_1 = Uuid::new_v4(); + let old_txn_2 = Uuid::new_v4(); + let recent_txn = Uuid::new_v4(); + + engine + .write_ledger_entry( + &keyspace, + old_txn_1, + TransactionState::Preparing, + 1000, + None, + None, + items_blob, + ) + .await + .unwrap(); + engine + .write_ledger_entry( + &keyspace, + old_txn_2, + TransactionState::Committing, + 2000, + None, + None, + items_blob, + ) + .await + .unwrap(); + engine + .write_ledger_entry( + &keyspace, + recent_txn, + TransactionState::Preparing, + 100000, + None, + None, + items_blob, + ) + .await + .unwrap(); + + // Scan for transactions older than 50000 + let old_txns = engine + .scan_old_transactions(&keyspace, 50000) + .await + .expect("Scan failed"); + + assert_eq!(old_txns.len(), 2, "Should find 2 old transactions"); + + let old_ids: Vec = old_txns.iter().map(|e| e.txn_id).collect(); + assert!(old_ids.contains(&old_txn_1)); + assert!(old_ids.contains(&old_txn_2)); + assert!(!old_ids.contains(&recent_txn)); +} + +#[tokio::test] +async fn test_read_nonexistent_ledger_entry() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let account_id = unique_test_account(); + ensure_test_account(&engine, &account_id).await.unwrap(); + let keyspace = engine.account_keyspace(&account_id); + + let txn_id = Uuid::new_v4(); + let entry = engine + .read_ledger_entry(&keyspace, txn_id) + .await + .expect("Query should succeed"); + + assert!(entry.is_none(), "Should return None for nonexistent entry"); +} diff --git a/crates/storage-cassandra/tests/direct/users.rs b/crates/storage-cassandra/tests/direct/users.rs new file mode 100644 index 00000000..1ab865b5 --- /dev/null +++ b/crates/storage-cassandra/tests/direct/users.rs @@ -0,0 +1,426 @@ +// Copyright 2026 ExtendDB Contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Integration tests for User management operations for `CassandraCatalogStore`. + +#[cfg(test)] +mod tests { + use crate::helpers::{setup_engine, test_config, unique_test_account, unique_test_id}; + use extenddb_storage::management_store::ManagementStore; + + #[tokio::test] + async fn test_create_user() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let account_id = unique_test_account(); + let account_name = format!("TestAccount_{}", unique_test_id()); + let user_name = format!("testuser_{}", unique_test_id()); + + catalog_store + .create_account(&account_id, &account_name) + .await + .expect("Failed to create account"); + + catalog_store + .create_user(&account_id, &user_name, None) + .await + .expect("Failed to create user"); + + println!("✓ User created successfully"); + + let result = catalog_store + .create_user(&account_id, &user_name, None) + .await; + match result { + Err(extenddb_storage::management_store::OpError::AlreadyExists(_)) => { + println!("✓ Duplicate user correctly rejected"); + } + other => panic!("Expected AlreadyExists, got: {:?}", other), + } + } + + #[tokio::test] + async fn test_foreign_key_checks() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let nonexistent_account = unique_test_account(); + let user_name = format!("testuser_{}", unique_test_id()); + + let result = catalog_store + .create_user(&nonexistent_account, &user_name, None) + .await; + + match result { + Err(extenddb_storage::management_store::OpError::NotFound(_)) => { + println!("✓ Foreign key check correctly rejected user creation"); + } + other => panic!("Expected NotFound for account, got: {:?}", other), + } + } + + #[tokio::test] + async fn test_delete_user() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let account_id = unique_test_account(); + let account_name = format!("TestAccount_{}", unique_test_id()); + let user_name = format!("testuser_{}", unique_test_id()); + + // Setup: create account and user + catalog_store + .create_account(&account_id, &account_name) + .await + .expect("Failed to create account"); + + catalog_store + .create_user(&account_id, &user_name, None) + .await + .expect("Failed to create user"); + + // Happy case: delete existing user + catalog_store + .delete_user(&account_id, &user_name) + .await + .expect("Failed to delete user"); + + println!("✓ User deleted successfully"); + + // Unhappy case: delete non-existent user + let result = catalog_store.delete_user(&account_id, &user_name).await; + + match result { + Err(extenddb_storage::management_store::OpError::NotFound(_)) => { + println!("✓ Delete non-existent user correctly rejected"); + } + other => panic!("Expected NotFound, got: {:?}", other), + } + } + + #[tokio::test] + async fn test_list_users() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let account_id = unique_test_account(); + let account_name = format!("TestAccount_{}", unique_test_id()); + + catalog_store + .create_account(&account_id, &account_name) + .await + .expect("Failed to create account"); + + // Happy case: list users when none exist + let users = catalog_store + .list_users(&account_id) + .await + .expect("Failed to list users"); + + assert_eq!(users.len(), 0, "Expected no users initially"); + println!("✓ List empty users works"); + + // Create multiple users + for i in 0..3 { + let user_name = format!("testuser_{}_{}", unique_test_id(), i); + catalog_store + .create_user(&account_id, &user_name, None) + .await + .expect("Failed to create user"); + } + + // Happy case: list multiple users + let users = catalog_store + .list_users(&account_id) + .await + .expect("Failed to list users"); + + assert_eq!(users.len(), 3, "Expected 3 users"); + println!("✓ Listed {} users successfully", users.len()); + + // Verify structure: (account_id, user_name, user_arn, has_password, created_at) + for (aid, uname, arn, has_pw, _created) in &users { + assert_eq!(aid, &account_id); + assert!(uname.starts_with("testuser_")); + assert!(arn.contains(&account_id)); + assert!(!(*has_pw), "No password set"); + } + + println!("✓ All users have correct structure"); + } + + #[tokio::test] + async fn test_get_user_detail() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + + // Need encryption key for access keys + use extenddb_storage::bootstrapper::helpers::generate_encryption_key; + let enc_key = generate_encryption_key(); + let catalog_store = extenddb_storage_cassandra::CassandraCatalogStore::with_encryption_key( + engine.session_arc(), + config.keyspace_prefix.clone(), + config.datacenter.clone(), + config.replication_factor, + enc_key, + ); + + let account_id = unique_test_account(); + let account_name = format!("TestAccount_{}", unique_test_id()); + let user_name = format!("testuser_{}", unique_test_id()); + + catalog_store + .create_account(&account_id, &account_name) + .await + .expect("Failed to create account"); + + catalog_store + .create_user(&account_id, &user_name, None) + .await + .expect("Failed to create user"); + + // Add access key + catalog_store + .create_access_key(&account_id, &user_name) + .await + .expect("Failed to create access key"); + + // Add tags + let tags = vec![("Env".to_string(), "Test".to_string())]; + catalog_store + .tag_user(&account_id, &user_name, &tags) + .await + .expect("Failed to tag user"); + + // Happy case: get user detail + let detail = catalog_store + .get_user_detail(&account_id, &user_name) + .await + .expect("Failed to get user detail") + .expect("User detail should exist"); + + assert_eq!(detail.keys.len(), 1); + assert_eq!(detail.policies.len(), 1); // SelfServicePolicy + assert_eq!(detail.policies[0], "SelfServicePolicy"); + assert_eq!(detail.tags.len(), 1); + assert_eq!(detail.groups.len(), 0); + + println!("✓ User detail retrieved successfully"); + println!(" Keys: {}", detail.keys.len()); + println!(" Policies: {}", detail.policies.len()); + println!(" Tags: {}", detail.tags.len()); + println!(" Groups: {}", detail.groups.len()); + + // Unhappy case: get detail for non-existent user + let detail = catalog_store + .get_user_detail(&account_id, "nonexistent_user") + .await + .expect("Failed to get user detail"); + + assert!(detail.is_none(), "Non-existent user should return None"); + println!("✓ Non-existent user returns None"); + } + + #[tokio::test] + async fn test_user_tags() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let account_id = unique_test_account(); + let account_name = format!("TestAccount_{}", unique_test_id()); + let user_name = format!("testuser_{}", unique_test_id()); + + // Setup + catalog_store + .create_account(&account_id, &account_name) + .await + .expect("Failed to create account"); + + catalog_store + .create_user(&account_id, &user_name, None) + .await + .expect("Failed to create user"); + + // Happy case: add tags + let tags = vec![ + ("Environment".to_string(), "Production".to_string()), + ("Team".to_string(), "Platform".to_string()), + ]; + + catalog_store + .tag_user(&account_id, &user_name, &tags) + .await + .expect("Failed to tag user"); + + println!("✓ Tagged user successfully"); + + // Happy case: list tags + let fetched_tags = catalog_store + .list_user_tags(&account_id, &user_name) + .await + .expect("Failed to list tags"); + + assert_eq!(fetched_tags.len(), 2); + assert!(fetched_tags.contains(&("Environment".to_string(), "Production".to_string()))); + assert!(fetched_tags.contains(&("Team".to_string(), "Platform".to_string()))); + println!("✓ Listed {} tags correctly", fetched_tags.len()); + + // Happy case: update existing tag (upsert) + let updated_tags = vec![("Environment".to_string(), "Staging".to_string())]; + + catalog_store + .tag_user(&account_id, &user_name, &updated_tags) + .await + .expect("Failed to update tag"); + + let fetched_tags = catalog_store + .list_user_tags(&account_id, &user_name) + .await + .expect("Failed to list tags after update"); + + assert_eq!(fetched_tags.len(), 2); + assert!(fetched_tags.contains(&("Environment".to_string(), "Staging".to_string()))); + println!("✓ Tag upsert works correctly"); + + // Happy case: untag + let tag_keys = vec!["Environment".to_string()]; + catalog_store + .untag_user(&account_id, &user_name, &tag_keys) + .await + .expect("Failed to untag user"); + + let fetched_tags = catalog_store + .list_user_tags(&account_id, &user_name) + .await + .expect("Failed to list tags after untag"); + + assert_eq!(fetched_tags.len(), 1); + assert!(fetched_tags.contains(&("Team".to_string(), "Platform".to_string()))); + println!("✓ Untag works correctly"); + + // Unhappy case: tag non-existent user + let result = catalog_store + .tag_user(&account_id, "nonexistent_user", &tags) + .await; + + match result { + Err(extenddb_storage::management_store::OpError::NotFound(_)) => { + println!("✓ Tag non-existent user correctly rejected"); + } + other => panic!("Expected NotFound, got: {:?}", other), + } + } + + #[tokio::test] + async fn test_user_passwords() { + if crate::helpers::skip_without_cassandra() { + return; + } + let engine = setup_engine().await; + let config = test_config(); + let catalog_store = crate::helpers::create_catalog_store(&engine, &config); + + let account_id = unique_test_account(); + let account_name = format!("TestAccount_{}", unique_test_id()); + let user_name = format!("testuser_{}", unique_test_id()); + + // Setup + catalog_store + .create_account(&account_id, &account_name) + .await + .expect("Failed to create account"); + + // Create user with password + let password = "TestPassword123!"; + let password_hash = + bcrypt::hash(password, bcrypt::DEFAULT_COST).expect("Failed to hash password"); + + catalog_store + .create_user(&account_id, &user_name, Some(&password_hash)) + .await + .expect("Failed to create user with password"); + + println!("✓ User with password created"); + + // Happy case: verify correct password + let verified = catalog_store + .verify_iam_user_password(&account_id, &user_name, password) + .await + .expect("Failed to verify password"); + + assert!(verified, "Password should verify"); + println!("✓ Correct password verified"); + + // Unhappy case: verify wrong password + let verified = catalog_store + .verify_iam_user_password(&account_id, &user_name, "WrongPassword") + .await + .expect("Failed to verify password"); + + assert!(!verified, "Wrong password should not verify"); + println!("✓ Wrong password correctly rejected"); + + // Happy case: change password + let new_password = "NewPassword456!"; + let new_hash = + bcrypt::hash(new_password, bcrypt::DEFAULT_COST).expect("Failed to hash new password"); + + catalog_store + .change_user_password(&account_id, &user_name, &new_hash) + .await + .expect("Failed to change password"); + + // Verify old password no longer works + let verified = catalog_store + .verify_iam_user_password(&account_id, &user_name, password) + .await + .expect("Failed to verify password"); + + assert!(!verified, "Old password should not work"); + + // Verify new password works + let verified = catalog_store + .verify_iam_user_password(&account_id, &user_name, new_password) + .await + .expect("Failed to verify new password"); + + assert!(verified, "New password should verify"); + println!("✓ Password changed successfully"); + + // Unhappy case: change password for non-existent user + let result = catalog_store + .change_user_password(&account_id, "nonexistent", &new_hash) + .await; + + match result { + Err(extenddb_storage::management_store::OpError::NotFound(_)) => { + println!("✓ Change password for non-existent user correctly rejected"); + } + other => panic!("Expected NotFound, got: {:?}", other), + } + } +} diff --git a/crates/storage-cassandra/tests/direct_integration.rs b/crates/storage-cassandra/tests/direct_integration.rs new file mode 100644 index 00000000..43eb28b8 --- /dev/null +++ b/crates/storage-cassandra/tests/direct_integration.rs @@ -0,0 +1,59 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Direct storage-trait integration tests, ported from the original +//! extenddb-cassandra-plugin repository (its tests/rust suite). They exercise +//! the Cassandra engine's trait implementations directly against a live node +//! at 127.0.0.1:9042 — no HTTP server in between — and skip themselves when +//! no Cassandra is reachable (see helpers::skip_without_cassandra). +//! +//! One binary, one module per area, sharing tests/common/mod.rs, which is the +//! in-tree descendant of the plug-in repo's helpers.rs. + +#[path = "common/mod.rs"] +mod helpers; + +#[path = "direct/access_keys.rs"] +mod access_keys; +#[path = "direct/accounts.rs"] +mod accounts; +#[path = "direct/admin_store.rs"] +mod admin_store; +#[path = "direct/authorization_store.rs"] +mod authorization_store; +#[path = "direct/backup_engine.rs"] +mod backup_engine; +#[path = "direct/cassandra_engine.rs"] +mod cassandra_engine; +#[path = "direct/delete_item.rs"] +mod delete_item; +#[path = "direct/groups.rs"] +mod groups; +#[path = "direct/index.rs"] +mod index; +#[path = "direct/metadata_engine.rs"] +mod metadata_engine; +#[path = "direct/policies.rs"] +mod policies; +#[path = "direct/put_get_item.rs"] +mod put_get_item; +#[path = "direct/query.rs"] +mod query; +#[path = "direct/roles.rs"] +mod roles; +#[path = "direct/scan.rs"] +mod scan; +#[path = "direct/settings_store.rs"] +mod settings_store; +#[path = "direct/streams.rs"] +mod streams; +#[path = "direct/table_engine.rs"] +mod table_engine; +#[path = "direct/transact_get_items.rs"] +mod transact_get_items; +#[path = "direct/transact_write_items.rs"] +mod transact_write_items; +#[path = "direct/transaction_ledger.rs"] +mod transaction_ledger; +#[path = "direct/users.rs"] +mod users; From a79080e866c8bc9c29c0b1c6b918bceb8cb4cf07 Mon Sep 17 00:00:00 2001 From: Scott Robinson Date: Tue, 15 Sep 2026 03:37:37 +0000 Subject: [PATCH 36/48] feat: ENABLING/DISABLING TTL states, non-blocking updates, durable backfill cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UpdateTimeToLive used to block for the whole enable backfill (a full table scan) and the whole disable drain, so the call took as long as the table was large and could outlive a client timeout; meanwhile DescribeTimeToLive reported ENABLED for tables whose queue did not yet cover every item. The Cassandra catalog already encoded the real lifecycle in ttl_index_ready and ttl_cleanup_generation — nothing read it. TimeToLiveStatus gains Enabling and Disabling (DynamoDB's wire names). Cassandra derives all four states from the catalog; PostgreSQL derives Enabling from its own ttl_index_ready (its disable is synchronous, so Disabling never appears); SQLite and MongoDB keep their two-state synchronous behavior, and the handler only detaches the backfill when the backend actually reports a transitional state — detaching on a two-state backend would leave a table claiming Enabled with no index. Updates are rejected while a transition settles, as DynamoDB does; the gate is a pure function with the full 8-case truth table tested. Enable returns once the catalog flip is durable and the backfill runs detached, resuming after a failure from a new durable cursor: a JSON resume point written after every scanned page as an LWT fenced on the live ttl_generation. The fence is what makes the cursor safe, not just useful — a plain write is an upsert, and a detached backfill racing DeleteTable would resurrect a partial catalog row for the dead table, blocking a same-name CreateTable (adversarial-review finding). A refused fence means the lifecycle moved and the scan stops. Disable returns once the flip is durable and the worker's pending-cleanup pass finishes the drain; the table reports DISABLING until it does. Also from review: a PostgreSQL CONCURRENTLY build that fails partway leaves an INVALID index that IF NOT EXISTS would silently keep on retry, so invalid leftovers are dropped before rebuilding, and the readiness publication is fenced on the attribute the build indexed so a stale detached task cannot certify a later lifecycle. The V004 migration uses ADD IF NOT EXISTS so a run that dies between applying and recording is rerunnable. The migration runner splits statements on every semicolon including in comments; V004 documents that trap. Tests: the four lifecycle states in order; a backfill killed mid-scan (cursor present, still ENABLING) resumed to completion by the worker pass with an audit proving full coverage; a planted cursor at the scan-last item proving the worker path resumes rather than rescans (audit finds exactly the ten skipped registrations); the transition gate truth table. Existing disable tests now invoke the worker drain they previously got inline. --- crates/core/src/types/table.rs | 9 +- crates/engine/src/ttl.rs | 108 +++++- .../catalog/V004__ttl_backfill_cursor.cql | 12 + crates/storage-cassandra/src/lib.rs | 1 + .../storage-cassandra/src/metadata_engine.rs | 160 +++++++- crates/storage-cassandra/src/migrations.rs | 4 + crates/storage-cassandra/src/ttl_worker.rs | 12 +- .../tests/ttl_integration.rs | 344 ++++++++++++++++++ .../storage-postgres/src/metadata_engine.rs | 43 ++- .../0010-cassandra-ttl-expiration-queue.md | 3 +- docs/differences-from-dynamodb.md | 4 +- 11 files changed, 648 insertions(+), 52 deletions(-) create mode 100644 crates/storage-cassandra/migrations/catalog/V004__ttl_backfill_cursor.cql diff --git a/crates/core/src/types/table.rs b/crates/core/src/types/table.rs index c0a47f40..531c4094 100755 --- a/crates/core/src/types/table.rs +++ b/crates/core/src/types/table.rs @@ -1075,10 +1075,17 @@ impl UpdateTableInput { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum TimeToLiveStatus { - /// TTL is enabled. + /// TTL is enabled and the expiration queue covers every item. Enabled, + /// TTL has been requested and the queue backfill has not yet finished. + /// Writes made in this state are TTL-tracked; pre-existing items are + /// still being registered. + Enabling, /// TTL is disabled. Disabled, + /// TTL has been disabled and the old expiration queue is still being + /// drained. No further expirations happen in this state. + Disabling, } /// TTL description returned by `DescribeTimeToLive` and `UpdateTimeToLive`. diff --git a/crates/engine/src/ttl.rs b/crates/engine/src/ttl.rs index 95e750bc..f930b660 100755 --- a/crates/engine/src/ttl.rs +++ b/crates/engine/src/ttl.rs @@ -54,6 +54,35 @@ pub async fn handle_describe_time_to_live( /// is already in the requested state (idempotency check). /// Returns `ResourceNotFoundException` if the table does not exist. /// Returns `InternalServerError` on storage failures. +/// Gate an `UpdateTimeToLive` request on the table's current TTL state. +/// +/// A table mid-transition rejects further changes: the backfill and the queue +/// drain are both keyed to the current generation, and admitting a toggle now +/// would race them. The client retries once the transition lands (matching +/// DynamoDB, which also refuses updates in ENABLING and DISABLING). Steady +/// states reject only the idempotent no-op, as before. +fn check_ttl_transition( + current: TimeToLiveStatus, + requested_enabled: bool, +) -> Result<(), DynamoDbError> { + match current { + TimeToLiveStatus::Enabling | TimeToLiveStatus::Disabling => { + Err(DynamoDbError::ValidationException( + "Time to live is being updated for this table. Retry the request once the \ + current change completes." + .to_owned(), + )) + } + TimeToLiveStatus::Enabled if requested_enabled => Err(DynamoDbError::ValidationException( + "TimeToLive is already enabled".to_owned(), + )), + TimeToLiveStatus::Disabled if !requested_enabled => Err( + DynamoDbError::ValidationException("TimeToLive is already disabled".to_owned()), + ), + _ => Ok(()), + } +} + pub async fn handle_update_time_to_live( body: Value, ctx: &OperationContext, @@ -72,17 +101,10 @@ pub async fn handle_update_time_to_live( .await .map_err(storage_to_dynamo)?; - let already_enabled = current.time_to_live_status == TimeToLiveStatus::Enabled; - if input.time_to_live_specification.enabled && already_enabled { - return Err(DynamoDbError::ValidationException( - "TimeToLive is already enabled".to_owned(), - )); - } - if !input.time_to_live_specification.enabled && !already_enabled { - return Err(DynamoDbError::ValidationException( - "TimeToLive is already disabled".to_owned(), - )); - } + check_ttl_transition( + current.time_to_live_status, + input.time_to_live_specification.enabled, + )?; // Resolve the old attribute before committing the disable. If the catalog // is inconsistent, fail without leaving a partially applied request. @@ -105,18 +127,44 @@ pub async fn handle_update_time_to_live( .map_err(storage_to_dynamo)?; if input.time_to_live_specification.enabled { - // Kick off index creation (CONCURRENTLY — non-blocking for other database - // operations on the table, but the handler awaits completion). - // If it fails, the TTL sweeper will retry on its next cycle. - let account_id = ctx.account_id.clone(); - let table_name = input.table_name.clone(); - let attr = input.time_to_live_specification.attribute_name.clone(); - if let Err(e) = ctx + // Backends that report ENABLING (Cassandra, PostgreSQL) get the + // backfill kicked off in the background: it is a full table scan, and + // awaiting it made the API call take as long as the table is large. + // The caller sees ENABLING until readiness is published; if the task + // dies unfinished, the TTL worker's pending-index pass retries (on + // Cassandra, from the durable cursor). Backends that report Enabled + // immediately (SQLite, MongoDB) keep the awaited call: detaching it + // would leave a table claiming Enabled while its index does not yet + // exist, with no observable transition state. + let transitional = ctx + .storage + .describe_ttl(&ctx.account_id, &input.table_name) + .await + .map(|d| d.time_to_live_status == TimeToLiveStatus::Enabling) + .unwrap_or(false); + if transitional { + let storage = ctx.storage.clone(); + let account_id = ctx.account_id.clone(); + let table_name = input.table_name.clone(); + let attr = input.time_to_live_specification.attribute_name.clone(); + tokio::spawn(async move { + if let Err(e) = storage + .create_ttl_index(&account_id, &table_name, &attr) + .await + { + tracing::warn!("TTL queue backfill deferred for {table_name}: {e}"); + } + }); + } else if let Err(e) = ctx .storage - .create_ttl_index(&account_id, &table_name, &attr) + .create_ttl_index( + &ctx.account_id, + &input.table_name, + &input.time_to_live_specification.attribute_name, + ) .await { - tracing::warn!("TTL index creation deferred for {table_name}: {e}"); + tracing::warn!("TTL index creation deferred for {}: {e}", input.table_name); } } else { // Disable path: metadata already updated (sweeper won't pick up this table). @@ -211,6 +259,26 @@ mod tests { assert!(validate_ttl_attribute_name(&max).is_ok()); } + #[test] + fn transition_gate() { + use TimeToLiveStatus::{Disabled, Disabling, Enabled, Enabling}; + // Steady states admit the toggle and reject the no-op. + assert!(check_ttl_transition(Disabled, true).is_ok()); + assert!(check_ttl_transition(Enabled, false).is_ok()); + assert!(check_ttl_transition(Enabled, true).is_err()); + assert!(check_ttl_transition(Disabled, false).is_err()); + // Mid-transition rejects both directions. + for state in [Enabling, Disabling] { + for requested in [true, false] { + let error = check_ttl_transition(state, requested).unwrap_err(); + assert!( + format!("{error:?}").contains("being updated"), + "expected transition rejection, got {error:?}" + ); + } + } + } + #[test] fn special_chars_rejected() { assert!(validate_ttl_attribute_name("it's").is_err()); diff --git a/crates/storage-cassandra/migrations/catalog/V004__ttl_backfill_cursor.cql b/crates/storage-cassandra/migrations/catalog/V004__ttl_backfill_cursor.cql new file mode 100644 index 00000000..83473e8d --- /dev/null +++ b/crates/storage-cassandra/migrations/catalog/V004__ttl_backfill_cursor.cql @@ -0,0 +1,12 @@ +-- Copyright 2026 ExtendDB contributors +-- SPDX-License-Identifier: Apache-2.0 +-- Durable resume point for the TTL enable backfill. +-- +-- JSON blob {"generation": , "last_key": } written after each +-- scanned page. A backfill that dies mid-scan (host crash, deploy) resumes +-- from here instead of rescanning the table from the top. A cursor whose +-- generation does not match the table's current ttl_generation is ignored. +-- +-- NOTE for future migrations: the migration runner splits files on the +-- semicolon character, including inside comments. Do not use one in a comment. +ALTER TABLE tables ADD IF NOT EXISTS ttl_backfill_cursor text; diff --git a/crates/storage-cassandra/src/lib.rs b/crates/storage-cassandra/src/lib.rs index 46af6e98..bced78b5 100644 --- a/crates/storage-cassandra/src/lib.rs +++ b/crates/storage-cassandra/src/lib.rs @@ -40,6 +40,7 @@ pub use bootstrapper::CassandraBootstrapper; pub use catalog_store::CassandraCatalogStore; pub use config::CassandraStorageConfig; pub use engine::{CassandraEngine, CassandraSession}; +pub use metadata_engine::TtlBackfillCursor; use cdrs_tokio::authenticators::StaticPasswordAuthenticatorProvider; use cdrs_tokio::cluster::NodeTcpConfigBuilder; diff --git a/crates/storage-cassandra/src/metadata_engine.rs b/crates/storage-cassandra/src/metadata_engine.rs index aabf2d66..8112897c 100755 --- a/crates/storage-cassandra/src/metadata_engine.rs +++ b/crates/storage-cassandra/src/metadata_engine.rs @@ -15,6 +15,17 @@ use crate::CassandraEngine; const TTL_CONTROL_MAX_RETRIES: u32 = 4; const TTL_CONTROL_RETRY_DELAY_MS: u64 = 25; +/// Durable resume point for the TTL enable backfill, stored per table in the +/// catalog as JSON. `generation` pins it to one enable cycle: a cursor left +/// behind by a retired generation must never seed a resume for the next one. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct TtlBackfillCursor { + /// Hyphenated UUID string (the local `uuid` dependency has no serde + /// feature, so the generation travels in its display form). + pub generation: String, + pub last_key: extenddb_core::types::Item, +} + impl CassandraEngine { /// How long a cached TTL configuration may serve the write path. The /// staleness is safe in both directions because the audit exists; see the @@ -572,7 +583,7 @@ impl CassandraEngine { /// `insert_ttl_entry` runs only for items whose registration is actually /// missing. Pages are paced so a large table's audit is a slow background /// murmur rather than a read burst. - pub(crate) async fn audit_ttl_queue_for_table( + pub async fn audit_ttl_queue_for_table( &self, account_id: &str, table_name: &str, @@ -655,12 +666,81 @@ impl CassandraEngine { Ok(Some(repaired)) } + /// Read the durable backfill resume point, if any. Public for direct + /// backend integration tests; production readers are the backfill itself. + pub async fn ttl_backfill_cursor( + &self, + account_id: &str, + table_name: &str, + ) -> Result, StorageError> { + let query = format!( + "SELECT ttl_backfill_cursor FROM {}.tables \ + WHERE account_id = ? AND table_name = ?", + self.catalog_keyspace() + ); + let Some(row) = crate::cassandra_util::query_optional( + &self.session, + &query, + cdrs_tokio::query_values!(account_id, table_name), + "ttl_backfill_cursor", + ) + .await? + else { + return Ok(None); + }; + let raw: Option = row.get_by_name("ttl_backfill_cursor").ok().flatten(); + // An unparseable cursor is treated as absent: the backfill falls back + // to a full rescan, which is always correct. + Ok(raw.and_then(|raw| serde_json::from_str(&raw).ok())) + } + + /// Persist a backfill resume point. Public for direct backend integration + /// tests; production writers are the backfill itself. + /// + /// Returns whether the write applied. An LWT fenced on the generation, not + /// a plain write: a plain UPDATE is an upsert, and a backfill racing + /// DeleteTable would resurrect a partial catalog row for the dead table, + /// blocking a same-name CreateTable. A refused write means the lifecycle + /// moved under the scan (disable, re-enable, or table deletion) and the + /// backfill must stop. One Paxos round per 1,000-item page is noise next + /// to the page's inserts. + pub async fn write_ttl_backfill_cursor( + &self, + account_id: &str, + table_name: &str, + generation: uuid::Uuid, + cursor: &TtlBackfillCursor, + ) -> Result { + let raw = serde_json::to_string(cursor) + .map_err(|error| StorageError::Internal(format!("TTL cursor encode: {error}")))?; + let query = format!( + "UPDATE {}.tables SET ttl_backfill_cursor = ? \ + WHERE account_id = ? AND table_name = ? IF ttl_generation = ?", + self.catalog_keyspace() + ); + let result = crate::cassandra_util::query_lwt( + &self.session, + &query, + cdrs_tokio::query_values!( + raw.as_str(), + account_id, + table_name, + cdrs_tokio::types::value::Bytes::new(generation.as_bytes().to_vec()) + ), + ) + .await + .map_err(|error| StorageError::Internal(format!("TTL cursor write: {error}")))?; + metadata_lwt_applied(&result) + } + + /// Scan the table and register an expiration entry for every item that /// carries a valid TTL timestamp, then publish the generation as ready. /// - /// Runs under the caller's control lease. The scan has no durable cursor, so - /// a failure restarts it from the beginning on the next cycle; entry inserts - /// are conditional, so repeating the scan is idempotent. + /// Runs under the caller's control lease. Resumes from the durable + /// per-page cursor when one exists for this generation, so a failure + /// costs one replayed page rather than a rescan; entry inserts are + /// conditional, so replaying is idempotent. async fn backfill_ttl_queue( &self, account_id: &str, @@ -671,7 +751,17 @@ impl CassandraEngine { let key_info = self.fetch_table_key_info(account_id, table_name).await?; let account_keyspace = self.account_keyspace(account_id); - let mut start_key = None; + // Resume from the durable cursor when it belongs to this generation. + // A backfill that died mid-scan (host crash, deploy) picks up at its + // last completed page instead of rescanning the table from the top; + // a cursor from a retired generation is ignored. Registrations are + // idempotent, so a page replayed around the crash point is harmless. + let mut start_key: Option = self + .ttl_backfill_cursor(account_id, table_name) + .await? + .and_then(|cursor| { + (cursor.generation == config.generation.to_string()).then_some(cursor.last_key) + }); loop { if self.ttl_config_for_table(account_id, table_name).await? != Some(config.clone()) { return Ok(()); @@ -694,13 +784,31 @@ impl CassandraEngine { } } match next_key { - Some(key) => start_key = Some(key), + Some(key) => { + let applied = self + .write_ttl_backfill_cursor( + account_id, + table_name, + config.generation, + &TtlBackfillCursor { + generation: config.generation.to_string(), + last_key: key.clone(), + }, + ) + .await?; + if !applied { + // The generation moved or the table is gone; this + // scan's registrations belong to a retired lifecycle. + return Ok(()); + } + start_key = Some(key); + } None => break, } } let query = format!( - "UPDATE {}.tables SET ttl_index_ready = true \ + "UPDATE {}.tables SET ttl_index_ready = true, ttl_backfill_cursor = null \ WHERE account_id = ? AND table_name = ? \ IF ttl_attribute = ? AND ttl_generation = ? AND table_status = 'ACTIVE'", self.catalog_keyspace() @@ -791,7 +899,8 @@ impl MetadataEngine for CassandraEngine { let table_name = table_name.to_owned(); Box::pin(async move { let query = format!( - "SELECT ttl_attribute FROM {}.tables WHERE account_id = ? AND table_name = ?", + "SELECT ttl_attribute, ttl_index_ready, ttl_cleanup_generation \ + FROM {}.tables WHERE account_id = ? AND table_name = ?", self.catalog_keyspace() ); let row = crate::cassandra_util::query_optional( @@ -803,12 +912,22 @@ impl MetadataEngine for CassandraEngine { .await? .ok_or_else(|| StorageError::TableNotFound(table_name.clone()))?; let attribute: Option = row.get_by_name("ttl_attribute").ok().flatten(); + let index_ready: Option = row.get_by_name("ttl_index_ready").ok().flatten(); + let cleanup_generation: Option = + row.get_by_name("ttl_cleanup_generation").ok().flatten(); + // The catalog already encodes the full lifecycle; this is just the + // first reader to surface it. `ttl_index_ready` is published by the + // backfill when the queue covers every pre-existing item, and + // `ttl_cleanup_generation` is held until the retired generation's + // queue is fully drained. + let time_to_live_status = match (&attribute, index_ready, cleanup_generation) { + (Some(_), Some(true), _) => TimeToLiveStatus::Enabled, + (Some(_), _, _) => TimeToLiveStatus::Enabling, + (None, _, Some(_)) => TimeToLiveStatus::Disabling, + (None, _, None) => TimeToLiveStatus::Disabled, + }; Ok(TimeToLiveDescription { - time_to_live_status: if attribute.is_some() { - TimeToLiveStatus::Enabled - } else { - TimeToLiveStatus::Disabled - }, + time_to_live_status, attribute_name: attribute, }) }) @@ -877,7 +996,8 @@ impl MetadataEngine for CassandraEngine { let query = if enabled { format!( "UPDATE {}.tables SET ttl_attribute = ?, ttl_generation = ?, \ - ttl_index_ready = false WHERE account_id = ? AND table_name = ? \ + ttl_index_ready = false, ttl_backfill_cursor = null \ + WHERE account_id = ? AND table_name = ? \ IF table_status = 'ACTIVE' AND ttl_sweep_owner = null \ AND ttl_cleanup_generation = null \ AND ttl_attribute = null AND ttl_generation = null", @@ -886,7 +1006,8 @@ impl MetadataEngine for CassandraEngine { } else { format!( "UPDATE {}.tables SET ttl_attribute = null, ttl_generation = null, \ - ttl_cleanup_generation = ?, ttl_index_ready = false \ + ttl_cleanup_generation = ?, ttl_index_ready = false, \ + ttl_backfill_cursor = null \ WHERE account_id = ? AND table_name = ? \ IF table_status = 'ACTIVE' AND ttl_sweep_owner = null \ AND ttl_cleanup_generation = null \ @@ -964,10 +1085,11 @@ impl MetadataEngine for CassandraEngine { // The lifecycle change is durable; the issuing host must not keep // serving the old configuration from its cache. self.invalidate_ttl_config_cache(&account_id, &table_name); - if !enabled { - self.complete_ttl_cleanup(&account_id, &table_name, &table_id, cleanup_generation) - .await?; - } + // Disable does NOT drain the retired generation's queue here: the + // drain visits every leftover entry and used to make the API call + // take as long as the queue was deep. `ttl_cleanup_generation` + // stays set (the table reports DISABLING) until the worker's + // pending-cleanup pass finishes the drain and clears it. Ok(()) }) } diff --git a/crates/storage-cassandra/src/migrations.rs b/crates/storage-cassandra/src/migrations.rs index 9b8957b4..fb0ab64d 100644 --- a/crates/storage-cassandra/src/migrations.rs +++ b/crates/storage-cassandra/src/migrations.rs @@ -58,6 +58,10 @@ pub(crate) const CATALOG_MIGRATIONS: &[(&str, &str)] = &[ "V003__account_scoped_idempotency.cql", include_str!("../migrations/catalog/V003__account_scoped_idempotency.cql"), ), + ( + "V004__ttl_backfill_cursor.cql", + include_str!("../migrations/catalog/V004__ttl_backfill_cursor.cql"), + ), ]; /// Embedded data migration files, applied in order. diff --git a/crates/storage-cassandra/src/ttl_worker.rs b/crates/storage-cassandra/src/ttl_worker.rs index 965809b5..f6a170dd 100644 --- a/crates/storage-cassandra/src/ttl_worker.rs +++ b/crates/storage-cassandra/src/ttl_worker.rs @@ -767,7 +767,11 @@ pub(crate) async fn drain_retired_generation( Ok(()) } -async fn retry_pending_cleanup(storage: &CassandraEngine) { +/// Drain the retired generation's queue for every table whose disable is +/// still finalizing (`ttl_cleanup_generation` set; the table reports +/// DISABLING). Public for direct backend integration tests and manual +/// operational triggering. +pub async fn retry_pending_cleanup(storage: &CassandraEngine) { let pending = match storage.pending_ttl_cleanups().await { Ok(pending) => pending, Err(error) => { @@ -785,11 +789,13 @@ async fn retry_pending_cleanup(storage: &CassandraEngine) { } } -/// Retry the queue backfill for any TTL-enabled table that is not yet ready. +/// Retry the queue backfill for any TTL-enabled table that is not yet ready +/// (the table reports ENABLING). Resumes from the durable cursor. Public for +/// direct backend integration tests and manual operational triggering. /// /// `create_ttl_index` takes the table's control lease internally, so a table is /// scanned by one host at a time even though every host runs this pass. -async fn retry_pending_indexes(storage: &CassandraEngine) { +pub async fn retry_pending_indexes(storage: &CassandraEngine) { let Ok(enabled) = MetadataEngine::all_tables_with_ttl(storage).await else { return; }; diff --git a/crates/storage-cassandra/tests/ttl_integration.rs b/crates/storage-cassandra/tests/ttl_integration.rs index 03245357..1c4a7c30 100644 --- a/crates/storage-cassandra/tests/ttl_integration.rs +++ b/crates/storage-cassandra/tests/ttl_integration.rs @@ -273,6 +273,10 @@ async fn test_ttl_metadata_enable_disable_and_listing() { ) .await .expect("disable TTL metadata"); + // The drain is deferred to the worker now; run its pass so the + // assertions below observe the completed drain, as they did when the + // disable call was synchronous. + extenddb_storage_cassandra::ttl_worker::retry_pending_cleanup(&engine).await; engine .drop_ttl_index( &table.key_info.account_id, @@ -1204,6 +1208,10 @@ async fn test_disable_drains_claimed_work_and_releases_its_claim() { ) .await .expect("disable succeeds even with claimed work in flight"); + // The drain is deferred to the worker now; run its pass so the + // assertions below observe the completed drain, as they did when the + // disable call was synchronous. + extenddb_storage_cassandra::ttl_worker::retry_pending_cleanup(&engine).await; assert_eq!( base_row_owner(&engine, &table.key_info, &item).await, @@ -1264,6 +1272,10 @@ async fn test_disable_completes_effects_applying_work() { ) .await .expect("disable completes effects-applying work"); + // The drain is deferred to the worker now; run its pass so the + // assertions below observe the completed drain, as they did when the + // disable call was synchronous. + extenddb_storage_cassandra::ttl_worker::retry_pending_cleanup(&engine).await; let mut key = extenddb_core::types::Item::new(); key.insert( @@ -1854,6 +1866,10 @@ async fn test_disable_completes_effects_applied_work() { ) .await .expect("disable succeeds with effects-applied work in flight"); + // The drain is deferred to the worker now; run its pass so the + // assertions below observe the completed drain, as they did when the + // disable call was synchronous. + extenddb_storage_cassandra::ttl_worker::retry_pending_cleanup(&engine).await; let mut key = extenddb_core::types::Item::new(); key.insert( @@ -3268,3 +3284,331 @@ async fn test_audit_restores_lost_queue_registration() { .await; assert_eq!(queue_rows(&engine).await, 1); } + +/// The four lifecycle states in order, as `describe_ttl` reports them: the +/// catalog encoded this all along, F5 is the first reader. Also proves the +/// disable drain is genuinely deferred (DISABLING is observable) and that the +/// worker's cleanup pass is what lands DISABLED. +#[tokio::test] +async fn test_ttl_lifecycle_states() { + if helpers::skip_without_cassandra() { + return; + } + use extenddb_core::types::{AttributeValue, TimeToLiveStatus}; + use extenddb_storage::DataEngine; + + let engine = setup_engine().await; + let table = crate::helpers::TestTable::new(&engine, "TtlLifecycle", false).await; + activate_tables(&engine).await; + let account = &table.key_info.account_id; + let name = &table.key_info.table_name; + + // Something for the queue to hold so the disable drain has real work. + let mut item = std::collections::BTreeMap::new(); + item.insert("id".to_owned(), AttributeValue::S("holder".to_owned())); + item.insert( + "expires_at".to_owned(), + AttributeValue::N((chrono::Utc::now().timestamp() + 3_600).to_string()), + ); + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("put"); + + // ENABLING: requested, backfill not yet run. + engine + .update_ttl(account, name, "expires_at", true) + .await + .expect("enable"); + let state = engine.describe_ttl(account, name).await.expect("describe"); + assert_eq!(state.time_to_live_status, TimeToLiveStatus::Enabling); + assert_eq!(state.attribute_name.as_deref(), Some("expires_at")); + + // ENABLED: backfill published readiness. + engine + .create_ttl_index(account, name, "expires_at") + .await + .expect("backfill"); + let state = engine.describe_ttl(account, name).await.expect("describe"); + assert_eq!(state.time_to_live_status, TimeToLiveStatus::Enabled); + assert!( + engine + .ttl_backfill_cursor(account, name) + .await + .expect("cursor read") + .is_none(), + "completed backfill must clear its cursor" + ); + + // DISABLING: the flip is durable but the old generation's queue is not + // yet drained — the API call no longer waits for that. + engine + .update_ttl(account, name, "expires_at", false) + .await + .expect("disable"); + let state = engine.describe_ttl(account, name).await.expect("describe"); + assert_eq!(state.time_to_live_status, TimeToLiveStatus::Disabling); + + // DISABLED: the worker's pending-cleanup pass finishes the drain. + extenddb_storage_cassandra::ttl_worker::retry_pending_cleanup(&engine).await; + let state = engine.describe_ttl(account, name).await.expect("describe"); + assert_eq!(state.time_to_live_status, TimeToLiveStatus::Disabled); + assert!(state.attribute_name.is_none()); +} + +/// Kill the backfill mid-scan and prove the durable cursor makes the retry +/// resume instead of rescanning, and that the resumed run covers every item. +#[tokio::test] +async fn test_backfill_cursor_resume() { + if helpers::skip_without_cassandra() { + return; + } + use extenddb_core::types::{AttributeValue, TimeToLiveStatus}; + use extenddb_storage::DataEngine; + + let engine = setup_engine().await; + let table = crate::helpers::TestTable::new(&engine, "TtlCursorResume", false).await; + activate_tables(&engine).await; + let account = table.key_info.account_id.clone(); + let name = table.key_info.table_name.clone(); + + // Three scan pages' worth of TTL-carrying items (page size is 1,000). + let future = chrono::Utc::now().timestamp() + 86_400; + for index in 0..2_500u32 { + let mut item = std::collections::BTreeMap::new(); + item.insert( + "id".to_owned(), + AttributeValue::S(format!("cur-{index:05}")), + ); + item.insert( + "expires_at".to_owned(), + AttributeValue::N(future.to_string()), + ); + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("seed"); + } + + engine + .update_ttl(&account, &name, "expires_at", true) + .await + .expect("enable"); + + // Run the backfill on its own task and kill it once it has durably + // finished at least one page (cursor present), like a host dying + // mid-enable. + let engine_for_task = setup_engine().await; + let (task_account, task_name) = (account.clone(), name.clone()); + let backfill = tokio::spawn(async move { + let _ = engine_for_task + .create_ttl_index(&task_account, &task_name, "expires_at") + .await; + }); + let started = std::time::Instant::now(); + let mut cursor = None; + let mut polls = 0u32; + for _ in 0..1_200 { + polls += 1; + cursor = engine + .ttl_backfill_cursor(&account, &name) + .await + .expect("cursor read"); + if cursor.is_some() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + backfill.abort(); + let abort_result = backfill.await; + assert!( + abort_result.err().is_some_and(|e| e.is_cancelled()), + "backfill task should have been killed mid-scan, not finished \ + (cursor seen after {polls} polls / {:?}; slow machine or page size drift?)", + started.elapsed() + ); + + // The killed run died holding the table's control lease. In production it + // expires on its own (`USING TTL 900`) and the worker's next pending-index + // pass resumes — from the cursor, which is why a 15-minute-old death does + // not cost a fresh full-table scan. The test simulates the expiry instead + // of waiting out the clock. + let expire_lease = "UPDATE extenddb_ttl_test_catalog.tables SET ttl_sweep_owner = null \ + WHERE account_id = ? AND table_name = ?"; + engine + .session_arc() + .query_with_values( + expire_lease, + cdrs_tokio::query_values!(account.as_str(), name.as_str()), + ) + .await + .expect("simulate lease expiry"); + let cursor = cursor.expect("backfill should persist a cursor after its first page"); + + // The kill left the table mid-enable: cursor present, not ready. + let state = engine + .describe_ttl(&account, &name) + .await + .expect("describe"); + assert_eq!( + state.time_to_live_status, + TimeToLiveStatus::Enabling, + "aborted backfill must not have published readiness" + ); + + // The worker's pending-index pass is the production retry path. It must + // resume from the persisted cursor (observable as: it completes without + // rewriting the cursor's first page — verified below by full coverage + // plus the cursor being pinned to this generation). + let generation = ttl_generation(&engine, &account, &name).await; + assert_eq!( + cursor.generation, + generation.to_string(), + "cursor must be pinned to the live generation" + ); + extenddb_storage_cassandra::ttl_worker::retry_pending_indexes(&engine).await; + let state = engine + .describe_ttl(&account, &name) + .await + .expect("describe"); + assert_eq!(state.time_to_live_status, TimeToLiveStatus::Enabled); + assert!( + engine + .ttl_backfill_cursor(&account, &name) + .await + .expect("cursor read") + .is_none() + ); + + // Full coverage proof: an audit pass over the finished table repairs + // nothing, i.e. every one of the 2,500 items is registered. + let metrics = extenddb_core::metrics::MetricsCollector::new(); + let repaired = engine + .audit_ttl_queue_for_table(&account, &name, "expires_at", &metrics) + .await + .expect("audit") + .unwrap_or(0); + assert_eq!( + repaired, 0, + "resumed backfill left {repaired} items unregistered" + ); +} + +/// The resume is real, not a rescan: a cursor pointing at the last item makes +/// the backfill skip everything before it (the audit then counts exactly the +/// skipped items as missing). Companion to `test_backfill_cursor_resume`, +/// whose full-coverage assertion a silent rescan would also satisfy. +#[tokio::test] +async fn test_backfill_cursor_is_honored() { + if helpers::skip_without_cassandra() { + return; + } + use extenddb_core::types::{AttributeValue, TimeToLiveStatus}; + use extenddb_storage::DataEngine; + + let engine = setup_engine().await; + let table = crate::helpers::TestTable::new(&engine, "TtlCursorHonored", false).await; + activate_tables(&engine).await; + let account = table.key_info.account_id.clone(); + let name = table.key_info.table_name.clone(); + + let future = chrono::Utc::now().timestamp() + 86_400; + for index in 0..10u32 { + let mut item = std::collections::BTreeMap::new(); + item.insert( + "id".to_owned(), + AttributeValue::S(format!("hon-{index:02}")), + ); + item.insert( + "expires_at".to_owned(), + AttributeValue::N(future.to_string()), + ); + engine + .put_item( + &table.key_info, + item, + false, + None, + &Default::default(), + None, + ) + .await + .expect("seed"); + } + + engine + .update_ttl(&account, &name, "expires_at", true) + .await + .expect("enable"); + let generation = ttl_generation(&engine, &account, &name).await; + + // Plant a cursor claiming the scan already covered everything up to the + // item the scan returns LAST — scan order is token order, not insertion + // order, so ask the engine rather than assuming. Key only: the cursor is + // an exclusive start key, so the planted item itself is also skipped. + let (scanned, tail) = engine + .scan(&table.key_info, None, None, None, None, None) + .await + .expect("scan for token order"); + assert!( + tail.is_none() && scanned.len() == 10, + "expected one full page" + ); + let mut last_key = std::collections::BTreeMap::new(); + last_key.insert( + "id".to_owned(), + scanned.last().expect("ten items")["id"].clone(), + ); + let planted = engine + .write_ttl_backfill_cursor( + &account, + &name, + generation, + &extenddb_storage_cassandra::TtlBackfillCursor { + generation: generation.to_string(), + last_key, + }, + ) + .await + .expect("plant cursor"); + assert!(planted, "cursor fence must admit the live generation"); + + // The worker's pending-index pass is the production retry entry point; + // proving THE WORKER honors the cursor is the point of this test. + extenddb_storage_cassandra::ttl_worker::retry_pending_indexes(&engine).await; + let state = engine + .describe_ttl(&account, &name) + .await + .expect("describe"); + assert_eq!(state.time_to_live_status, TimeToLiveStatus::Enabled); + + // If the cursor was honored, the backfill scanned nothing (the cursor + // points past the scan-last item) and the audit finds all ten items — + // nine predecessors plus the exclusive cursor item itself, which puts on + // an ENABLING table never registered because the puts predate the enable. + // A rescan would find zero. + let metrics = extenddb_core::metrics::MetricsCollector::new(); + let repaired = engine + .audit_ttl_queue_for_table(&account, &name, "expires_at", &metrics) + .await + .expect("audit") + .unwrap_or(0); + assert_eq!( + repaired, 10, + "backfill did not resume from the planted cursor (repaired {repaired}, expected all 10 skipped)" + ); +} diff --git a/crates/storage-postgres/src/metadata_engine.rs b/crates/storage-postgres/src/metadata_engine.rs index 322a9015..beccfeca 100755 --- a/crates/storage-postgres/src/metadata_engine.rs +++ b/crates/storage-postgres/src/metadata_engine.rs @@ -20,8 +20,9 @@ impl MetadataEngine for PostgresEngine { let account_id = account_id.to_string(); let table_name = table_name.to_string(); Box::pin(async move { - let row: Option<(Option,)> = sqlx::query_as( - "SELECT ttl_attribute FROM tables WHERE account_id = $1 AND table_name = $2", + let row: Option<(Option, Option)> = sqlx::query_as( + "SELECT ttl_attribute, ttl_index_ready FROM tables \ + WHERE account_id = $1 AND table_name = $2", ) .bind(&account_id) .bind(&table_name) @@ -29,11 +30,20 @@ impl MetadataEngine for PostgresEngine { .await .map_err(|e| StorageError::Internal(e.to_string()))?; - let (ttl_attr,) = row.ok_or_else(|| StorageError::TableNotFound(table_name.clone()))?; + let (ttl_attr, index_ready) = + row.ok_or_else(|| StorageError::TableNotFound(table_name.clone()))?; Ok(match ttl_attr { Some(attr) => TimeToLiveDescription { - time_to_live_status: TimeToLiveStatus::Enabled, + // Enabled only once the queue backfill has published + // readiness; until then pre-existing items are still + // being registered. Postgres disable tears the queue + // down synchronously, so it has no Disabling state. + time_to_live_status: if index_ready == Some(true) { + TimeToLiveStatus::Enabled + } else { + TimeToLiveStatus::Enabling + }, attribute_name: Some(attr), }, None => TimeToLiveDescription { @@ -290,6 +300,25 @@ impl MetadataEngine for PostgresEngine { let bare_table = data_table.trim_matches('"'); let index_name = format!("idx_ttl_{bare_table}"); + // A CONCURRENTLY build that fails partway leaves an INVALID + // index behind, and IF NOT EXISTS would silently keep it on the + // retry — publishing readiness over an index that indexes + // nothing. Drop any invalid leftover before building. + let invalid: Option<(bool,)> = sqlx::query_as( + "SELECT i.indisvalid FROM pg_index i \ + JOIN pg_class c ON c.oid = i.indexrelid WHERE c.relname = $1", + ) + .bind(&index_name) + .fetch_optional(&self.data_pool) + .await + .map_err(|e| StorageError::Internal(e.to_string()))?; + if invalid == Some((false,)) { + sqlx::query(&format!("DROP INDEX IF EXISTS \"{index_name}\"")) + .execute(&self.data_pool) + .await + .map_err(|e| StorageError::Internal(format!("Drop invalid TTL index: {e}")))?; + } + let sql = format!( "CREATE INDEX CONCURRENTLY IF NOT EXISTS \"{index_name}\" \ ON {data_table} (((item_data->'{ttl_attribute}'->>'N')::BIGINT)) \ @@ -300,12 +329,16 @@ impl MetadataEngine for PostgresEngine { .await .map_err(|e| StorageError::Internal(format!("TTL index creation failed: {e}")))?; + // Fenced on the attribute this build actually indexed: a stale + // task surviving a disable / re-enable-with-different-attribute + // must not certify the new lifecycle's readiness. sqlx::query( "UPDATE tables SET ttl_index_ready = TRUE \ - WHERE account_id = $1 AND table_name = $2", + WHERE account_id = $1 AND table_name = $2 AND ttl_attribute = $3", ) .bind(&account_id) .bind(&table_name) + .bind(&ttl_attribute) .execute(&self.pool) .await .map_err(|e| StorageError::Internal(e.to_string()))?; diff --git a/docs/adr/0010-cassandra-ttl-expiration-queue.md b/docs/adr/0010-cassandra-ttl-expiration-queue.md index c027005e..9e35d5c5 100644 --- a/docs/adr/0010-cassandra-ttl-expiration-queue.md +++ b/docs/adr/0010-cassandra-ttl-expiration-queue.md @@ -129,7 +129,7 @@ The bar for this feature is the production readiness of the PostgreSQL TTL imple | --- | --- | | ~100 expirations per table per minute, and no increase with fleet size | Both backends use `SCAN_INTERVAL = 60s` and `BATCH_SIZE = 100`. PostgreSQL runs without a lease but every host issues the same `ORDER BY ttl LIMIT 100` query, so hosts contend for the same rows and the loser's delete fails its TTL condition. | | `UpdateTimeToLive` blocks instead of returning immediately | The shared handler awaits `create_ttl_index` on every backend. | -| No `ENABLING`/`DISABLING` status | `TimeToLiveStatus` has only two variants; both backends derive status from catalog presence. | +| `ENABLING`/`DISABLING` are reported while a change settles | The catalog already encoded the lifecycle (`ttl_index_ready`, `ttl_cleanup_generation`); `describe_ttl` surfaces it, the API call no longer waits out the backfill or the drain, and updates are rejected mid-transition. | | No five-year cutoff on old timestamps | PostgreSQL matches on `BETWEEN 1 AND now`; Cassandra accepts any positive `i64`. | | TTL deletion bypasses write-capacity accounting and throttling | Both workers call the storage layer directly, below the request capacity path. | | No caching of TTL configuration | Neither backend caches it. Cassandra pays a per-write catalog read because it needs the configuration on the write path at all; PostgreSQL does not need it, because expiry is derived from the item by a database index. | @@ -161,7 +161,6 @@ The summary: with this change, Cassandra TTL matches PostgreSQL on every shared Deliberately out of scope for this change, in rough priority order: * **Expiration throughput does not scale horizontally.** The sweep lease is per table even though the queue is already sharded 64 ways by key and those shards are disjoint partitions. Leasing per `(table, shard)` would allow up to 64 concurrent workers per table with no change to the claim protocol, because every queue transition is already conditional on the exact work UUID. This is the highest-value follow-up, and it would take Cassandra past the PostgreSQL rate rather than merely matching it. -* **The backfill has no durable cursor.** A failure restarts the scan from the beginning, and the `UpdateTimeToLive` call blocks for its duration instead of reporting `ENABLING`. * **TTL alongside asynchronously propagated GSIs**, per the table above. * **Version fence hardening.** The fence now uses the `version` column; remaining follow-up is retiring the `item_data` fallback once no unversioned rows can exist (requires a fleet-wide rewrite pass or an explicit migration), after which the canonical-JSON coupling disappears entirely. * **Recovery-path test coverage.** The transitions this change introduced — draining a retired generation, retiring a drained bucket registration, an expired worker claim — are reasoned about but not yet covered by tests. diff --git a/docs/differences-from-dynamodb.md b/docs/differences-from-dynamodb.md index f1c86b86..837a41da 100755 --- a/docs/differences-from-dynamodb.md +++ b/docs/differences-from-dynamodb.md @@ -54,8 +54,8 @@ adaptation when switching between ExtendDB and the real service. | TTL stream records | REMOVE events with `userIdentity: {type: "Service", principalId: "dynamodb.amazonaws.com"}` | Supported — TTL deletions generate REMOVE stream records with the same `userIdentity` | | TTL timestamps far in the past | Ignored if more than five years before the current time | No cutoff. Any positive epoch-second value is queued and expired, however old. | | TTL attribute value validation | Non-conforming values are ignored | Cassandra matches: only a positive integral `N` is expired, and missing, non-numeric, fractional, zero, and negative values are ignored. | -| `UpdateTimeToLive` response timing | Returns immediately; the change takes effect asynchronously (up to about an hour) | All backends await readiness before returning, so the call can be slow on a large table and can exceed a client timeout. PostgreSQL awaits a concurrent index build; Cassandra awaits a full-table backfill of the expiration queue, which has no durable cursor and restarts on the next worker cycle if it fails. | -| `DescribeTimeToLive` transitional states | `ENABLING` and `DISABLING` are reported while a change settles | Only `ENABLED` and `DISABLED`. A table reports `ENABLED` as soon as the attribute is set, including while its queue is still being backfilled and nothing will expire yet. | +| `UpdateTimeToLive` response timing | Returns immediately; the change takes effect asynchronously (up to about an hour) | Matches: the call returns once the catalog flip is durable. The Cassandra queue backfill runs in the background with a durable per-page cursor (a failed host's retry resumes rather than rescanning), and the disable drain is finished by the worker. | +| `DescribeTimeToLive` transitional states | `ENABLING` and `DISABLING` are reported while a change settles | Matches on Cassandra (all four states; `ENABLED` means the queue covers every item) and on PostgreSQL for enable (`ENABLING` until the concurrent index build publishes; its disable is synchronous so `DISABLING` never appears). During `DISABLING`, `AttributeName` is absent, unlike DynamoDB. Updates are rejected while a transition is settling, as in DynamoDB. | | Concurrent same-key writes on a TTL-enabled table | Unaffected by TTL | Serialized through a base-row claim. Contention is retried internally against a re-read image, so writes stay last-writer-wins; only sustained contention surfaces `TransactionConflictException`. | | TTL-enabled write cost | Unaffected by TTL | A claim LWT, with the base write becoming a logged batch (measured: roughly 2× put latency on a single-node bench). The TTL configuration is served from a 5-second cache, the remaining catalog read is overlapped with the index read, and the release is folded into the batch with an exact conditional release off the request path. Non-TTL tables keep the existing fast paths. | | Cassandra TTL with asynchronous GSIs | Supported | Not currently supported. The Cassandra backend rejects TTL enable when any GSI has a nonzero effective propagation delay, and rejects creating such a GSI on a TTL-enabled table; base tables, LSIs, and synchronous GSIs are supported. See [ADR-0010](adr/0010-cassandra-ttl-expiration-queue.md). | From 3223a929d04dc509083de278641e33476662c6eb Mon Sep 17 00:00:00 2001 From: Scott Robinson Date: Tue, 15 Sep 2026 04:07:11 +0000 Subject: [PATCH 37/48] test: wait out the TTL enable transition in the E2E disable tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enable returns during ENABLING now and updates are rejected until the transition settles, so the enable-then-immediately-disable sequence in the Python and Rust E2E suites hits the transition gate — the same rejection real DynamoDB gives that sequence, which is why both tests already carried a cooldown comment about it. Poll DescribeTimeToLive for ENABLED (30s bound) before disabling, like a real client must. Both tests' final assertions already accepted DISABLED or DISABLING. --- .../storage-cassandra/src/metadata_engine.rs | 1 - tests/python/test_ttl.py | 11 ++++++++ tests/rust/src/ttl.rs | 26 +++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/crates/storage-cassandra/src/metadata_engine.rs b/crates/storage-cassandra/src/metadata_engine.rs index 8112897c..1eb07ee4 100755 --- a/crates/storage-cassandra/src/metadata_engine.rs +++ b/crates/storage-cassandra/src/metadata_engine.rs @@ -733,7 +733,6 @@ impl CassandraEngine { metadata_lwt_applied(&result) } - /// Scan the table and register an expiration entry for every item that /// carries a valid TTL timestamp, then publish the generation as ready. /// diff --git a/tests/python/test_ttl.py b/tests/python/test_ttl.py index b02a8600..9b277f62 100755 --- a/tests/python/test_ttl.py +++ b/tests/python/test_ttl.py @@ -10,6 +10,7 @@ from __future__ import annotations import os +import time import pytest from botocore.exceptions import ClientError @@ -57,6 +58,16 @@ def test_disable_ttl(self, table_factory, dynamodb_client): TableName=name, TimeToLiveSpecification={"Enabled": True, "AttributeName": "ttl"}, ) + # Enable returns during ENABLING now (the backfill is detached, as in + # DynamoDB) and updates are rejected until the transition settles, so + # wait for ENABLED like a real client must. + for _ in range(120): + resp = dynamodb_client.describe_time_to_live(TableName=name) + if resp["TimeToLiveDescription"]["TimeToLiveStatus"] == "ENABLED": + break + time.sleep(0.25) + else: + pytest.fail("TTL enable did not settle within 30s") dynamodb_client.update_time_to_live( TableName=name, TimeToLiveSpecification={"Enabled": False, "AttributeName": "ttl"}, diff --git a/tests/rust/src/ttl.rs b/tests/rust/src/ttl.rs index 6adeb96f..2aa8d06e 100755 --- a/tests/rust/src/ttl.rs +++ b/tests/rust/src/ttl.rs @@ -192,6 +192,32 @@ async fn disable_ttl() { tokio::time::sleep(std::time::Duration::from_secs(30)).await; } + // Enable returns during ENABLING now (the backfill is detached, as in + // DynamoDB) and updates are rejected until the transition settles, so + // wait for ENABLED like a real client must. + let mut settled = false; + for _ in 0..120 { + let resp = c + .describe_time_to_live() + .table_name(&name) + .send() + .await + .unwrap(); + let status = format!( + "{:?}", + resp.time_to_live_description() + .unwrap() + .time_to_live_status() + .unwrap() + ); + if status == "Enabled" { + settled = true; + break; + } + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + } + assert!(settled, "TTL enable did not settle within 30s"); + c.update_time_to_live() .table_name(&name) .time_to_live_specification( From df551859b88d524780be9c494717d5fca7c84b8e Mon Sep 17 00:00:00 2001 From: Scott Robinson Date: Fri, 18 Sep 2026 18:17:28 +0000 Subject: [PATCH 38/48] test: fold the metadata suite into the direct integration binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review suggestion from jcshepherd. The standalone metadata_integration binary held one test — the in-tree consolidation of the plug-in repo's four tag tests — while the ported suite carried those four originals, so merging the binaries also deduplicates: the consolidated phased lifecycle test replaces the four in direct/metadata_engine.rs (same assertions, one engine connection instead of four catalog-migration setups). It is parallel-safe like the rest of the suite. The TTL suite stays its own serial binary: its tests drive global sweep, repair, and audit passes that would see every concurrent test's tables, so the two binaries have deliberately opposite concurrency contracts. One fewer test binary to link, one fewer CI step. --- .github/workflows/integration-cassandra.yml | 2 - .../tests/direct/metadata_engine.rs | 173 ++++++------------ .../tests/metadata_integration.rs | 99 ---------- 3 files changed, 58 insertions(+), 216 deletions(-) delete mode 100644 crates/storage-cassandra/tests/metadata_integration.rs diff --git a/.github/workflows/integration-cassandra.yml b/.github/workflows/integration-cassandra.yml index 4c3eebbd..2fe0acc3 100644 --- a/.github/workflows/integration-cassandra.yml +++ b/.github/workflows/integration-cassandra.yml @@ -48,8 +48,6 @@ jobs: cache-on-failure: true - name: Unit tests run: cargo test -p extenddb-storage-cassandra --lib - - name: Metadata integration tests - run: cargo test -p extenddb-storage-cassandra --test metadata_integration -- --test-threads=1 - name: TTL integration tests run: cargo test -p extenddb-storage-cassandra --test ttl_integration -- --test-threads=1 - name: Direct storage-trait integration tests diff --git a/crates/storage-cassandra/tests/direct/metadata_engine.rs b/crates/storage-cassandra/tests/direct/metadata_engine.rs index 53ea8672..3d7717f8 100644 --- a/crates/storage-cassandra/tests/direct/metadata_engine.rs +++ b/crates/storage-cassandra/tests/direct/metadata_engine.rs @@ -1,54 +1,32 @@ // Copyright 2026 ExtendDB contributors // SPDX-License-Identifier: Apache-2.0 -//! Integration tests for MetadataEngine tag operations. +//! Direct integration tests for `MetadataEngine` operations that are not +//! TTL-specific. +//! +//! The plug-in repo's four tag tests were consolidated in-tree into the single +//! phased `test_resource_tag_lifecycle` before this suite was ported, so this +//! module carries the consolidated form rather than both. use extenddb_core::types::Tag; use extenddb_storage::MetadataEngine; use crate::helpers::setup_engine; -#[tokio::test] -async fn test_tag_and_list_tags() { - if crate::helpers::skip_without_cassandra() { - return; +fn tag(key: &str, value: &str) -> Tag { + Tag { + key: key.to_owned(), + value: value.to_owned(), } - let engine = setup_engine().await; - let arn = format!( - "arn:aws:dynamodb:us-east-1:123456789012:table/test-{}", - uuid::Uuid::new_v4().simple() - ); - - let tags = vec![ - Tag { - key: "env".to_string(), - value: "test".to_string(), - }, - Tag { - key: "owner".to_string(), - value: "alice".to_string(), - }, - ]; - - engine - .tag_resource(&arn, &tags) - .await - .expect("tag_resource should succeed"); - - let result = engine - .list_tags(&arn) - .await - .expect("list_tags should succeed"); - assert_eq!(result.len(), 2); - // Cassandra returns in clustering key order - assert_eq!(result[0].key, "env"); - assert_eq!(result[0].value, "test"); - assert_eq!(result[1].key, "owner"); - assert_eq!(result[1].value, "alice"); } +/// One phased pass over the tag lifecycle. Each phase asserts a distinct +/// behaviour — empty listing, exact multi-tag persistence and ordering, +/// same-key overwrite, and selective removal — but they share one engine +/// connection, because `setup_engine` reruns catalog migrations and dominated +/// the cost of testing these as four separate cases. #[tokio::test] -async fn test_tag_resource_upserts() { +async fn test_resource_tag_lifecycle() { if crate::helpers::skip_without_cassandra() { return; } @@ -58,95 +36,60 @@ async fn test_tag_resource_upserts() { uuid::Uuid::new_v4().simple() ); + // An untouched resource has no tags. + assert!( + engine + .list_tags(&arn) + .await + .expect("list_tags on an untagged resource") + .is_empty() + ); + + // Tags persist with their exact keys and values, in clustering-key order. engine - .tag_resource( - &arn, - &[Tag { - key: "env".to_string(), - value: "staging".to_string(), - }], - ) + .tag_resource(&arn, &[tag("env", "staging"), tag("owner", "alice")]) .await - .expect("first tag_resource should succeed"); + .expect("tag_resource"); + let tags = engine.list_tags(&arn).await.expect("list_tags"); + assert_eq!( + tags.iter() + .map(|t| (t.key.as_str(), t.value.as_str())) + .collect::>(), + vec![("env", "staging"), ("owner", "alice")] + ); - // Overwrite with new value + // Re-tagging an existing key replaces its value and leaves the other alone. engine - .tag_resource( - &arn, - &[Tag { - key: "env".to_string(), - value: "prod".to_string(), - }], - ) + .tag_resource(&arn, &[tag("env", "prod")]) .await - .expect("second tag_resource should succeed"); - - let result = engine + .expect("tag_resource overwrite"); + let tags = engine .list_tags(&arn) .await - .expect("list_tags should succeed"); - assert_eq!(result.len(), 1); - assert_eq!(result[0].value, "prod"); -} - -#[tokio::test] -async fn test_untag_resource() { - if crate::helpers::skip_without_cassandra() { - return; - } - let engine = setup_engine().await; - let arn = format!( - "arn:aws:dynamodb:us-east-1:123456789012:table/test-{}", - uuid::Uuid::new_v4().simple() + .expect("list_tags after upsert"); + assert_eq!( + tags.iter() + .map(|t| (t.key.as_str(), t.value.as_str())) + .collect::>(), + vec![("env", "prod"), ("owner", "alice")], + "an upsert must replace only the key it names" ); - let tags = vec![ - Tag { - key: "a".to_string(), - value: "1".to_string(), - }, - Tag { - key: "b".to_string(), - value: "2".to_string(), - }, - Tag { - key: "c".to_string(), - value: "3".to_string(), - }, - ]; + // Untagging removes exactly the named keys. engine - .tag_resource(&arn, &tags) + .tag_resource(&arn, &[tag("team", "storage")]) .await - .expect("tag_resource should succeed"); - + .expect("tag_resource third key"); engine - .untag_resource(&arn, &["a".to_string(), "c".to_string()]) - .await - .expect("untag_resource should succeed"); - - let result = engine - .list_tags(&arn) + .untag_resource(&arn, &["env".to_owned(), "team".to_owned()]) .await - .expect("list_tags should succeed"); - assert_eq!(result.len(), 1); - assert_eq!(result[0].key, "b"); - assert_eq!(result[0].value, "2"); -} - -#[tokio::test] -async fn test_list_tags_empty() { - if crate::helpers::skip_without_cassandra() { - return; - } - let engine = setup_engine().await; - let arn = format!( - "arn:aws:dynamodb:us-east-1:123456789012:table/test-{}", - uuid::Uuid::new_v4().simple() + .expect("untag_resource"); + let tags = engine.list_tags(&arn).await.expect("list_tags after untag"); + assert_eq!( + tags.iter() + .map(|t| (t.key.as_str(), t.value.as_str())) + .collect::>(), + vec![("owner", "alice")], + "untag must remove only the keys it names" ); - - let result = engine - .list_tags(&arn) - .await - .expect("list_tags should succeed"); - assert!(result.is_empty()); } diff --git a/crates/storage-cassandra/tests/metadata_integration.rs b/crates/storage-cassandra/tests/metadata_integration.rs deleted file mode 100644 index 9a9b5877..00000000 --- a/crates/storage-cassandra/tests/metadata_integration.rs +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright 2026 ExtendDB contributors -// SPDX-License-Identifier: Apache-2.0 - -//! Integration tests for `MetadataEngine` operations that are not TTL-specific. -//! -//! These exercise the Cassandra adapter directly. The SDK-level suites under -//! `tests/rust` and `tests/python` cover the same semantics through the API, but -//! CI runs those against PostgreSQL, SQLite, and MongoDB only — there is no -//! Cassandra integration workflow — so this is the only coverage that reaches -//! the Cassandra implementation of these calls. - -#[path = "common/mod.rs"] -mod helpers; - -use extenddb_core::types::Tag; -use extenddb_storage::MetadataEngine; - -use crate::helpers::setup_engine; - -fn tag(key: &str, value: &str) -> Tag { - Tag { - key: key.to_owned(), - value: value.to_owned(), - } -} - -/// One phased pass over the tag lifecycle. Each phase asserts a distinct -/// behaviour — empty listing, exact multi-tag persistence and ordering, -/// same-key overwrite, and selective removal — but they share one engine -/// connection, because `setup_engine` reruns catalog migrations and dominated -/// the cost of testing these as four separate cases. -#[tokio::test] -async fn test_resource_tag_lifecycle() { - if crate::helpers::skip_without_cassandra() { - return; - } - let engine = setup_engine().await; - let arn = format!( - "arn:aws:dynamodb:us-east-1:123456789012:table/test-{}", - uuid::Uuid::new_v4().simple() - ); - - // An untouched resource has no tags. - assert!( - engine - .list_tags(&arn) - .await - .expect("list_tags on an untagged resource") - .is_empty() - ); - - // Tags persist with their exact keys and values, in clustering-key order. - engine - .tag_resource(&arn, &[tag("env", "staging"), tag("owner", "alice")]) - .await - .expect("tag_resource"); - let tags = engine.list_tags(&arn).await.expect("list_tags"); - assert_eq!( - tags.iter() - .map(|t| (t.key.as_str(), t.value.as_str())) - .collect::>(), - vec![("env", "staging"), ("owner", "alice")] - ); - - // Re-tagging an existing key replaces its value and leaves the other alone. - engine - .tag_resource(&arn, &[tag("env", "prod")]) - .await - .expect("tag_resource overwrite"); - let tags = engine - .list_tags(&arn) - .await - .expect("list_tags after upsert"); - assert_eq!( - tags.iter() - .map(|t| (t.key.as_str(), t.value.as_str())) - .collect::>(), - vec![("env", "prod"), ("owner", "alice")], - "an upsert must replace only the key it names" - ); - - // Untagging removes exactly the named keys. - engine - .tag_resource(&arn, &[tag("team", "storage")]) - .await - .expect("tag_resource third key"); - engine - .untag_resource(&arn, &["env".to_owned(), "team".to_owned()]) - .await - .expect("untag_resource"); - let tags = engine.list_tags(&arn).await.expect("list_tags after untag"); - assert_eq!( - tags.iter() - .map(|t| (t.key.as_str(), t.value.as_str())) - .collect::>(), - vec![("owner", "alice")], - "untag must remove only the keys it names" - ); -} From 12e25c59cf3e5353ffeb26f3c28a9f8dcc764cc8 Mon Sep 17 00:00:00 2001 From: Joel Shepherd Date: Fri, 18 Sep 2026 21:26:27 +0000 Subject: [PATCH 39/48] fix(storage-cassandra): responding to PR feedback on extenddb init behavior and GSI initialization. (PR338, B12) - extenddb init now honors a keyspace prefix provided to init, instead of defaulting to 'extenddb'. If extenddb init is run on a partially initialized Cassandra instance with an extenddb application user already configured, requires application user password to re-run. (PR338, B6) - New GSIs go into creating state, and don't enter active state until backfill is complete. --- .../catalog/V001__initial_schema.cql | 1 + crates/storage-cassandra/src/bootstrapper.rs | 45 ++- crates/storage-cassandra/src/data/ddl.rs | 274 ++++++++++-------- crates/storage-cassandra/src/engine.rs | 4 + crates/storage-cassandra/src/lib.rs | 14 + crates/storage-cassandra/src/update_table.rs | 132 ++++++--- crates/storage-cassandra/src/workers.rs | 248 +++++++++++++++- tests/test_gsi_async.py | 161 +++++++++- 8 files changed, 701 insertions(+), 178 deletions(-) diff --git a/crates/storage-cassandra/migrations/catalog/V001__initial_schema.cql b/crates/storage-cassandra/migrations/catalog/V001__initial_schema.cql index eab613ec..98289e2a 100644 --- a/crates/storage-cassandra/migrations/catalog/V001__initial_schema.cql +++ b/crates/storage-cassandra/migrations/catalog/V001__initial_schema.cql @@ -65,6 +65,7 @@ CREATE TABLE IF NOT EXISTS indexes ( index_status text, provisioned_throughput text, propagation_delay_ms int, + backfill_heartbeat timestamp, PRIMARY KEY ((table_id), index_name) ); diff --git a/crates/storage-cassandra/src/bootstrapper.rs b/crates/storage-cassandra/src/bootstrapper.rs index 548cf2c2..eab3e5b2 100644 --- a/crates/storage-cassandra/src/bootstrapper.rs +++ b/crates/storage-cassandra/src/bootstrapper.rs @@ -41,6 +41,7 @@ struct SettingRow { pub struct CassandraBootstrapper { engine: CassandraEngine, config: BootstrapConfig, + keyspace_prefix: String, } impl CassandraBootstrapper { @@ -56,6 +57,7 @@ impl CassandraBootstrapper { Ok(Self { engine, config: bootstrap_config, + keyspace_prefix: cassandra_config.keyspace_prefix.clone(), }) } @@ -258,7 +260,14 @@ impl Bootstrapper for CassandraBootstrapper { .is_some_and(|rows| !rows.is_empty()); if exists { - println!(" User '{user}' already exists."); + if password.is_empty() { + return Err(OpError::AlreadyExists(format!( + "Application role '{user}' already exists. \ + Supply its password with --extenddb-pass (or EXTENDDB_APP_PASSWORD) \ + to regenerate the config, or run 'extenddb destroy' first." + ))); + } + println!(" User '{user}' already exists; using supplied password."); return Ok(()); } @@ -689,16 +698,42 @@ impl Bootstrapper for CassandraBootstrapper { } fn generate_backend_config_section(&self) -> String { + Self::backend_config_section(&self.catalog_connection_url(), &self.keyspace_prefix) + } +} + +impl CassandraBootstrapper { + /// Pure helper so the output can be tested without a live Cassandra connection. + fn backend_config_section(contact_point: &str, keyspace_prefix: &str) -> String { format!( r#"[storage.cassandra] -contact_points = ["{}"] +contact_points = ["{contact_point}"] # username = "cassandra" # Application user # password = "cassandra-password" # Application password -keyspace_prefix = "extenddb" +keyspace_prefix = "{keyspace_prefix}" replication_factor = 1 # Single node (use 3+ for production) datacenter = "datacenter1" -max_connections = 10"#, - self.catalog_connection_url() +max_connections = 10"# ) } } + +#[cfg(test)] +mod tests { + use super::CassandraBootstrapper; + + #[test] + fn backend_config_section_uses_actual_keyspace_prefix() { + let section = + CassandraBootstrapper::backend_config_section("cassandra-node-1:9042", "mycompany"); + assert!( + section.contains("keyspace_prefix = \"mycompany\""), + "config must use the supplied prefix, got:\n{section}" + ); + assert!( + !section.contains("keyspace_prefix = \"extenddb\""), + "config must not contain hardcoded 'extenddb' prefix, got:\n{section}" + ); + assert!(section.contains("contact_points = [\"cassandra-node-1:9042\"]")); + } +} diff --git a/crates/storage-cassandra/src/data/ddl.rs b/crates/storage-cassandra/src/data/ddl.rs index b3c1216f..9016422d 100644 --- a/crates/storage-cassandra/src/data/ddl.rs +++ b/crates/storage-cassandra/src/data/ddl.rs @@ -416,153 +416,175 @@ impl CassandraEngine { Ok(()) } +} - /// Backfill an existing base table into a newly created GSI data table. - /// - /// Scans the base table in token order in batches, projecting and inserting - /// each qualifying item into the index table. Called synchronously from - /// `update_table_impl` while a propagation hold is active, so workers will - /// not apply queued writes to this index until the hold is released. - #[allow(clippy::too_many_arguments)] - pub(crate) async fn backfill_gsi( - &self, - account_keyspace: &str, - table_id: &str, - index_id: &str, - index_key_schema: &[extenddb_core::types::KeySchemaElement], - attr_defs: &[extenddb_core::types::AttributeDefinition], - base_key_schema: &[extenddb_core::types::KeySchemaElement], - base_attr_defs: &[extenddb_core::types::AttributeDefinition], - projection: &extenddb_core::types::Projection, - ) -> Result<(), StorageError> { - use crate::data::index::{ - insert_index_row_multi, item_has_index_keys, project_item_for_index, - }; - use cdrs_tokio::consistency::Consistency; - use cdrs_tokio::query::BatchQueryBuilder; - - const PAGE_SIZE: i64 = 500; - - let base_table = data_table_name(table_id); - let idx_table = index_table_name(index_id); - let idx_sks = all_sort_key_info(index_key_schema, attr_defs); - let base_sks = all_sort_key_info(base_key_schema, base_attr_defs); - - // Token-based full-table scan matching the pattern used by scan_impl. - // First page: no lower bound. Subsequent pages: token(pk) > last_token. - let first_page_query = format!( - "SELECT pk, item_data FROM {account_keyspace}.{base_table} \ +/// Backfill a newly created GSI data table from the base table. +/// +/// Free function so the spawned backfill task can call it with only an +/// `Arc` rather than requiring the whole engine. +/// +/// Scans the base table in token order in batches, projecting and inserting +/// each qualifying item into the index table. Updates `backfill_heartbeat` +/// after each batch so a recovery worker can distinguish a live (slow) build +/// from a crashed one. Called while a propagation hold is active, so workers +/// will not apply queued writes to this index until the hold is released. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn backfill_gsi( + session: &std::sync::Arc, + account_keyspace: &str, + catalog_keyspace: &str, + table_id: &str, + index_id: &str, + index_name: &str, + index_key_schema: &[extenddb_core::types::KeySchemaElement], + attr_defs: &[extenddb_core::types::AttributeDefinition], + base_key_schema: &[extenddb_core::types::KeySchemaElement], + base_attr_defs: &[extenddb_core::types::AttributeDefinition], + projection: &extenddb_core::types::Projection, +) -> Result<(), StorageError> { + use crate::data::index::{insert_index_row_multi, item_has_index_keys, project_item_for_index}; + use cdrs_tokio::consistency::Consistency; + use cdrs_tokio::query::BatchQueryBuilder; + + const PAGE_SIZE: i64 = 500; + + let heartbeat_cql = format!( + "UPDATE {catalog_keyspace}.indexes SET backfill_heartbeat = toTimestamp(now()) \ + WHERE table_id = ? AND index_name = ?" + ); + + let base_table = data_table_name(table_id); + let idx_table = index_table_name(index_id); + let idx_sks = all_sort_key_info(index_key_schema, attr_defs); + let base_sks = all_sort_key_info(base_key_schema, base_attr_defs); + + // Token-based full-table scan matching the pattern used by scan_impl. + // First page: no lower bound. Subsequent pages: token(pk) > last_token. + let first_page_query = format!( + "SELECT pk, item_data FROM {account_keyspace}.{base_table} \ LIMIT {PAGE_SIZE} ALLOW FILTERING" - ); - let next_page_query = format!( - "SELECT pk, item_data FROM {account_keyspace}.{base_table} \ + ); + let next_page_query = format!( + "SELECT pk, item_data FROM {account_keyspace}.{base_table} \ WHERE token(pk) > ? LIMIT {PAGE_SIZE} ALLOW FILTERING" - ); + ); - let mut last_token: Option = None; + let mut last_token: Option = None; - loop { - let rows = if let Some(tok) = last_token { - crate::cassandra_util::query_rows::( - &self.session, - &next_page_query, - cdrs_tokio::query_values!(tok), - "backfill_gsi", - ) - .await? - } else { - crate::cassandra_util::query_rows::( - &self.session, - &first_page_query, - cdrs_tokio::query_values!(), - "backfill_gsi", - ) - .await? - }; - - if rows.is_empty() { - break; - } + loop { + let rows = if let Some(tok) = last_token { + crate::cassandra_util::query_rows::( + session, + &next_page_query, + cdrs_tokio::query_values!(tok), + "backfill_gsi", + ) + .await? + } else { + crate::cassandra_util::query_rows::( + session, + &first_page_query, + cdrs_tokio::query_values!(), + "backfill_gsi", + ) + .await? + }; - let mut batch = BatchQueryBuilder::new().with_consistency(Consistency::LocalQuorum); - let mut batch_has_rows = false; + if rows.is_empty() { + break; + } - for row in &rows { - let item_json: String = - crate::cassandra_util::get_column(row, "item_data", "backfill_gsi")?; - let item = super::json_to_item(item_json)?; + let mut batch = BatchQueryBuilder::new().with_consistency(Consistency::LocalQuorum); + let mut batch_has_rows = false; - if !item_has_index_keys(&item, index_key_schema) { - continue; - } + for row in &rows { + let item_json: String = + crate::cassandra_util::get_column(row, "item_data", "backfill_gsi")?; + let item = super::json_to_item(item_json)?; - let projected = - project_item_for_index(&item, index_key_schema, base_key_schema, projection); - - insert_index_row_multi( - &mut batch, - account_keyspace, - &idx_table, - &item, - &projected, - index_key_schema, - base_key_schema, - &idx_sks, - &base_sks, - )?; - batch_has_rows = true; + if !item_has_index_keys(&item, index_key_schema) { + continue; } - if batch_has_rows { - self.session - .batch( - batch - .build() - .map_err(|e| StorageError::Internal(e.to_string()))?, - ) - .await - .map_err(|e| StorageError::Internal(format!("backfill_gsi batch: {e}")))?; - } + let projected = + project_item_for_index(&item, index_key_schema, base_key_schema, projection); - // Advance the token cursor using the last pk in this page. - // If we got fewer rows than PAGE_SIZE we've reached the end. - #[allow(clippy::cast_possible_truncation)] - if (rows.len() as i64) < PAGE_SIZE { - break; - } + insert_index_row_multi( + &mut batch, + account_keyspace, + &idx_table, + &item, + &projected, + index_key_schema, + base_key_schema, + &idx_sks, + &base_sks, + )?; + batch_has_rows = true; + } - // Get the token of the last pk to use as the next page cursor. - if let Some(last_row) = rows.last() { - let pk: String = crate::cassandra_util::get_column(last_row, "pk", "backfill_gsi")?; - let token_query = format!( - "SELECT token(pk) AS tok FROM {account_keyspace}.{base_table} WHERE pk = ?" - ); - let tok_rows = crate::cassandra_util::query_rows::( - &self.session, - &token_query, - cdrs_tokio::query_values!(pk.as_str()), - "backfill_gsi_token", + if batch_has_rows { + session + .batch( + batch + .build() + .map_err(|e| StorageError::Internal(e.to_string()))?, ) - .await?; - if let Some(tok_row) = tok_rows.first() { - let tok: i64 = - crate::cassandra_util::get_column(tok_row, "tok", "backfill_gsi_token")?; - // Guard against token collisions: if the token hasn't - // advanced, there are no more distinct partitions to scan. - if Some(tok) == last_token { - break; - } - last_token = Some(tok); - } else { + .await + .map_err(|e| StorageError::Internal(format!("backfill_gsi batch: {e}")))?; + } + + // Update heartbeat after each page so a recovery worker can + // distinguish a live (slow) backfill from a crashed one. + if let Err(e) = crate::cassandra_util::execute::( + session, + &heartbeat_cql, + cdrs_tokio::query_values!(table_id, index_name), + "backfill_gsi_heartbeat", + ) + .await + { + tracing::warn!("backfill_gsi: failed to update heartbeat for {index_name}: {e}"); + } + + // Advance the token cursor using the last pk in this page. + // If we got fewer rows than PAGE_SIZE we've reached the end. + #[allow(clippy::cast_possible_truncation)] + if (rows.len() as i64) < PAGE_SIZE { + break; + } + + // Get the token of the last pk to use as the next page cursor. + if let Some(last_row) = rows.last() { + let pk: String = crate::cassandra_util::get_column(last_row, "pk", "backfill_gsi")?; + let token_query = format!( + "SELECT token(pk) AS tok FROM {account_keyspace}.{base_table} WHERE pk = ?" + ); + let tok_rows = crate::cassandra_util::query_rows::( + session, + &token_query, + cdrs_tokio::query_values!(pk.as_str()), + "backfill_gsi_token", + ) + .await?; + if let Some(tok_row) = tok_rows.first() { + let tok: i64 = + crate::cassandra_util::get_column(tok_row, "tok", "backfill_gsi_token")?; + // Guard against token collisions: if the token hasn't + // advanced, there are no more distinct partitions to scan. + if Some(tok) == last_token { break; } + last_token = Some(tok); } else { break; } + } else { + break; } - - Ok(()) } + + Ok(()) } #[cfg(test)] diff --git a/crates/storage-cassandra/src/engine.rs b/crates/storage-cassandra/src/engine.rs index 8550616e..23f9bc36 100644 --- a/crates/storage-cassandra/src/engine.rs +++ b/crates/storage-cassandra/src/engine.rs @@ -69,6 +69,9 @@ pub struct CassandraEngine { /// GSI queue handle for waking workers after async enqueues pub(crate) gsi_queue: Arc, + /// Wakes the GSI backfill recovery worker when a new CREATING index is committed. + pub(crate) gsi_backfill_notify: Arc, + /// Hybrid Logical Clock for stream sequence number generation pub(crate) hlc: crate::stream_util::SharedHlc, @@ -120,6 +123,7 @@ impl CassandraEngine { ttl_config_cache: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())), ttl_config_cache_epoch: Arc::new(std::sync::atomic::AtomicU64::new(0)), gsi_queue: crate::gsi_queue::GsiQueue::new(), + gsi_backfill_notify: Arc::new(tokio::sync::Notify::new()), hlc: crate::stream_util::new_shared_hlc( config.instance_id.as_deref().unwrap_or("default"), ), diff --git a/crates/storage-cassandra/src/lib.rs b/crates/storage-cassandra/src/lib.rs index bced78b5..d2af4aa6 100644 --- a/crates/storage-cassandra/src/lib.rs +++ b/crates/storage-cassandra/src/lib.rs @@ -117,6 +117,19 @@ impl ServerRuntimeHooks for CassandraRuntimeHooks { let guard = workers::spawn_gsi_workers(self.engine.clone()); let _ = self.gsi_worker_guard.set(guard); + // GSI backfill recovery worker. + let engine_for_gsi_recovery = self.engine.clone(); + let gsi_backfill_notify = self.engine.gsi_backfill_notify.clone(); + let gsi_backfill_recovery = tokio::spawn(async move { + workers::poll_gsi_backfill_recovery( + engine_for_gsi_recovery, + gsi_backfill_notify, + std::time::Duration::from_secs(120), // stale threshold + std::time::Duration::from_secs(60), // scan interval backstop + ) + .await; + }); + let ttl_engine = self.engine.clone(); let ttl_metrics = ctx.metrics.clone(); let ttl_shutdown = ctx.shutdown.clone(); @@ -146,6 +159,7 @@ impl ServerRuntimeHooks for CassandraRuntimeHooks { control_plane, transaction_recovery, gsi_delay_poller, + gsi_backfill_recovery, ttl_cleanup, ttl_repair, ttl_audit, diff --git a/crates/storage-cassandra/src/update_table.rs b/crates/storage-cassandra/src/update_table.rs index 35c0677e..b0f63568 100644 --- a/crates/storage-cassandra/src/update_table.rs +++ b/crates/storage-cassandra/src/update_table.rs @@ -282,7 +282,7 @@ impl CassandraEngine { "INSERT INTO {catalog_ks}.indexes \ (table_id, index_name, index_id, index_type, key_schema, \ projection, index_status, provisioned_throughput) \ - VALUES (?, ?, ?, 'GSI', ?, ?, 'ACTIVE', ?)" + VALUES (?, ?, ?, 'GSI', ?, ?, 'CREATING', ?)" ), QueryValues::SimpleValues(vec![ Value::from(table_id.as_str()), @@ -299,6 +299,7 @@ impl CassandraEngine { // Create the data table after the batch commits (DDL can't be batched). // Store what we need for post-batch DDL. let _ = (effective_attr_defs, &base_key_schema, &base_attr_defs); + // Propagation hold is taken before the catalog batch executes (see below). } if let Some(delete) = &update.delete { @@ -461,9 +462,47 @@ impl CassandraEngine { base_key_schema: &[KeySchemaElement], base_attr_defs: &[AttributeDefinition], ) -> Result { + let account_ks = self.account_keyspace(account_id); + + // Take propagation holds for all new GSIs BEFORE the catalog batch + // commits. This ensures no worker can apply a queued write to the index + // between the CREATING row becoming visible and the backfill completing. + // If the batch subsequently fails, we release the holds in the error path. + let mut taken_holds: Vec = Vec::new(); + if let Some(updates) = &input.global_secondary_index_updates { + let mut create_idx = 0usize; + for update in updates { + if update.create.is_some() { + let (index_id, _) = &gsi_creates[create_idx]; + create_idx += 1; + if let Err(e) = crate::propagation_hold::take_propagation_hold( + &self.session_arc(), + &account_ks, + table_id, + index_id, + ) + .await + { + for held_id in &taken_holds { + let _ = crate::propagation_hold::release_propagation_hold( + &self.session_arc(), + &account_ks, + table_id, + held_id, + ) + .await; + } + return Err(e); + } + taken_holds.push(index_id.clone()); + } + } + } + // Execute the catalog batch atomically. - if batch_has_statements { - self.session + if batch_has_statements + && let Err(e) = self + .session .batch( batch .build() @@ -473,73 +512,84 @@ impl CassandraEngine { .map_err(|e| { tracing::error!("update_table batch: {e}"); StorageError::Internal("Database error".to_owned()) - })?; + }) + { + for held_id in &taken_holds { + let _ = crate::propagation_hold::release_propagation_hold( + &self.session_arc(), + &account_ks, + table_id, + held_id, + ) + .await; + } + return Err(e); } // Post-batch: shard init (has its own internal batch across two keyspaces). if needs_shard_init { - let account_ks = self.account_keyspace(account_id); self.init_stream_shards(account_id, &input.table_name, &account_ks, table_id) .await?; } let _ = needs_label_restore; // handled inside the batch above - // Post-batch: GSI data table DDL (CREATE/DROP TABLE cannot be batched). + // Post-batch: GSI data table DDL and async backfill. if let Some(updates) = &input.global_secondary_index_updates { let effective_attr_defs = input .attribute_definitions .as_deref() - .unwrap_or(base_attr_defs); - let account_ks = self.account_keyspace(account_id); + .unwrap_or(base_attr_defs) + .to_vec(); let mut create_idx = 0usize; let mut delete_idx = 0usize; for update in updates { if let Some(create) = &update.create { - let (index_id, _) = &gsi_creates[create_idx]; + let (index_id, index_name) = &gsi_creates[create_idx]; create_idx += 1; - self.create_index_data_table( - &account_ks, - index_id, - &create.key_schema, - effective_attr_defs, - base_key_schema, - base_attr_defs, - ) - .await?; - crate::propagation_hold::take_propagation_hold( - &self.session_arc(), - &account_ks, - table_id, - index_id, - ) - .await?; - let backfill_result = self - .backfill_gsi( + + // Create the data table synchronously — it must exist before + // the spawned backfill task or any worker tries to write to it. + if let Err(e) = self + .create_index_data_table( &account_ks, - table_id, index_id, &create.key_schema, - effective_attr_defs, + &effective_attr_defs, base_key_schema, base_attr_defs, - &create.projection, ) - .await; - if let Err(e) = crate::propagation_hold::release_propagation_hold( - &self.session_arc(), - &account_ks, - table_id, - index_id, - ) - .await + .await { tracing::error!( - "failed to release propagation hold for index {index_id} \ - on table {table_id}: {e}" + "Failed to create data table for GSI '{}' on '{}': {e}", + index_name, + input.table_name, ); + let _ = crate::cassandra_util::execute::( + &self.session, + &format!( + "DELETE FROM {}.indexes WHERE table_id = ? AND index_name = ?", + self.catalog_keyspace() + ), + cdrs_tokio::query_values!(table_id, index_name.as_str()), + "cleanup_failed_gsi", + ) + .await; + let _ = crate::propagation_hold::release_propagation_hold( + &self.session_arc(), + &account_ks, + table_id, + index_id, + ) + .await; + return Err(e); } - backfill_result?; + + // The backfill recovery worker picks up any CREATING index + // with a null or stale heartbeat. Wake it immediately so + // the new index doesn't wait for the periodic scan interval. + self.gsi_backfill_notify.notify_one(); } if update.delete.is_some() { let index_id = &gsi_deletes[delete_idx]; diff --git a/crates/storage-cassandra/src/workers.rs b/crates/storage-cassandra/src/workers.rs index e3a41502..520175ed 100644 --- a/crates/storage-cassandra/src/workers.rs +++ b/crates/storage-cassandra/src/workers.rs @@ -18,7 +18,7 @@ pub(crate) async fn poll_gsi_delay( store: Arc, gsi_delay: Arc, ) { - const POLL_INTERVAL: Duration = Duration::from_secs(30); + const POLL_INTERVAL: Duration = Duration::from_secs(5); loop { tokio::time::sleep(POLL_INTERVAL).await; @@ -445,6 +445,252 @@ async fn gsi_apply_index( Ok(()) } +/// Background worker that detects and recovers stale GSI backfills. +/// +/// Woken immediately when `apply_table_update` commits a new `CREATING` index, +/// and also sweeps periodically as a backstop. Treats a NULL `backfill_heartbeat` +/// (never started) and a heartbeat older than `stale_threshold` (crashed) the +/// same way: drop+recreate the data table and rerun the backfill. +pub(crate) async fn poll_gsi_backfill_recovery( + engine: Arc, + notify: Arc, + stale_threshold: Duration, + scan_interval: Duration, +) { + loop { + // Wait for an explicit wake or the periodic backstop timeout. + let _ = tokio::time::timeout(scan_interval, notify.notified()).await; + + let keyspaces = match list_account_keyspaces(&engine).await { + Ok(ks) => ks, + Err(e) => { + tracing::warn!("gsi_recovery: list keyspaces failed: {e}"); + continue; + } + }; + + for account_ks in &keyspaces { + if let Err(e) = recover_stale_backfills(&engine, account_ks, stale_threshold).await { + tracing::warn!("gsi_recovery: {account_ks}: {e}"); + } + } + } +} + +async fn recover_stale_backfills( + engine: &Arc, + account_ks: &str, + stale_threshold: Duration, +) -> Result<(), extenddb_storage::error::StorageError> { + use cdrs_tokio::types::IntoRustByName as _; + + let catalog_ks = engine.catalog_keyspace(); + + // Find all CREATING indexes whose heartbeat is stale (or NULL). + let cutoff_ms = (chrono::Utc::now() - stale_threshold).timestamp_millis(); + + // We need table_id, index_name, index_id, key_schema, projection, and the + // base table's key_schema + attribute_definitions. Fetch all CREATING + // indexes from the catalog, then filter by heartbeat age. + let rows = crate::cassandra_util::query_rows::( + &engine.session, + &format!( + "SELECT table_id, index_name, index_id, key_schema, projection, \ + backfill_heartbeat \ + FROM {catalog_ks}.indexes \ + WHERE index_status = 'CREATING' ALLOW FILTERING" + ), + cdrs_tokio::query_values!(), + "gsi_recovery_scan", + ) + .await?; + + for row in rows { + let heartbeat_ms: Option = row.get_by_name("backfill_heartbeat").ok().flatten(); + let is_stale = heartbeat_ms.is_none_or(|hb| hb < cutoff_ms); + if !is_stale { + continue; + } + + let table_id: String = crate::cassandra_util::get_column(&row, "table_id", "gsi_recovery")?; + let index_name: String = + crate::cassandra_util::get_column(&row, "index_name", "gsi_recovery")?; + let index_id: String = crate::cassandra_util::get_column(&row, "index_id", "gsi_recovery")?; + let ks_json: String = + crate::cassandra_util::get_column(&row, "key_schema", "gsi_recovery")?; + let proj_json: String = + crate::cassandra_util::get_column(&row, "projection", "gsi_recovery")?; + + let index_key_schema: Vec = + match serde_json::from_str(&ks_json) { + Ok(v) => v, + Err(e) => { + tracing::warn!("gsi_recovery: bad key_schema for {index_name}: {e}"); + continue; + } + }; + let projection: extenddb_core::types::Projection = match serde_json::from_str(&proj_json) { + Ok(v) => v, + Err(e) => { + tracing::warn!("gsi_recovery: bad projection for {index_name}: {e}"); + continue; + } + }; + + // Fetch base table key_schema + attribute_definitions. + let base_row = + match crate::cassandra_util::query_optional::( + &engine.session, + &format!( + "SELECT key_schema, attribute_definitions \ + FROM {catalog_ks}.tables WHERE table_id = ? ALLOW FILTERING" + ), + cdrs_tokio::query_values!(table_id.as_str()), + "gsi_recovery_base", + ) + .await + { + Ok(Some(r)) => r, + Ok(None) => { + tracing::warn!( + "gsi_recovery: base table for index {index_name} not found, skipping" + ); + continue; + } + Err(e) => { + tracing::warn!("gsi_recovery: fetch base table for {index_name}: {e}"); + continue; + } + }; + + let base_ks_json: String = + crate::cassandra_util::get_column(&base_row, "key_schema", "gsi_recovery_base")?; + let base_ad_json: String = crate::cassandra_util::get_column( + &base_row, + "attribute_definitions", + "gsi_recovery_base", + )?; + let base_key_schema: Vec = + match serde_json::from_str(&base_ks_json) { + Ok(v) => v, + Err(e) => { + tracing::warn!("gsi_recovery: bad base key_schema for {index_name}: {e}"); + continue; + } + }; + let base_attr_defs: Vec = + match serde_json::from_str(&base_ad_json) { + Ok(v) => v, + Err(e) => { + tracing::warn!("gsi_recovery: bad base attr_defs for {index_name}: {e}"); + continue; + } + }; + + tracing::info!( + "gsi_recovery: recovering stale backfill for index '{index_name}' \ + (table_id={table_id}, index_id={index_id})" + ); + + // Drop and recreate the data table so we start from a clean slate. + if let Err(e) = engine.drop_index_data_table(account_ks, &index_id).await { + tracing::warn!("gsi_recovery: drop data table for {index_name}: {e}"); + continue; + } + if let Err(e) = engine + .create_index_data_table( + account_ks, + &index_id, + &index_key_schema, + &base_attr_defs, + &base_key_schema, + &base_attr_defs, + ) + .await + { + tracing::warn!("gsi_recovery: recreate data table for {index_name}: {e}"); + continue; + } + + // Re-take the propagation hold (it may already be absent if the crash + // happened after the hold was released but before ACTIVE was written). + if let Err(e) = crate::propagation_hold::take_propagation_hold( + &engine.session_arc(), + account_ks, + &table_id, + &index_id, + ) + .await + { + tracing::warn!("gsi_recovery: take hold for {index_name}: {e}"); + continue; + } + + // Spawn the backfill so the recovery loop is not blocked. + let session = engine.session_arc(); + let gsi_queue = Arc::clone(&engine.gsi_queue); + let account_ks_owned = account_ks.to_owned(); + let catalog_ks_owned = catalog_ks.clone(); + let table_id_owned = table_id.clone(); + let index_id_owned = index_id.clone(); + let index_name_owned = index_name.clone(); + tokio::spawn(async move { + let result = crate::data::ddl::backfill_gsi( + &session, + &account_ks_owned, + &catalog_ks_owned, + &table_id_owned, + &index_id_owned, + &index_name_owned, + &index_key_schema, + &base_attr_defs, + &base_key_schema, + &base_attr_defs, + &projection, + ) + .await; + + if let Err(e) = result { + tracing::error!( + "gsi_recovery: backfill for '{index_name_owned}' failed again: {e}" + ); + } else { + let flip = crate::cassandra_util::execute::( + &session, + &format!( + "UPDATE {catalog_ks_owned}.indexes \ + SET index_status = 'ACTIVE' \ + WHERE table_id = ? AND index_name = ?" + ), + cdrs_tokio::query_values!(table_id_owned.as_str(), index_name_owned.as_str()), + "gsi_recovery_activate", + ) + .await; + if let Err(e) = flip { + tracing::error!("gsi_recovery: failed to activate '{index_name_owned}': {e}"); + } + } + + if let Err(e) = crate::propagation_hold::release_propagation_hold( + &session, + &account_ks_owned, + &table_id_owned, + &index_id_owned, + ) + .await + { + tracing::error!( + "gsi_recovery: failed to release hold for '{index_name_owned}': {e}" + ); + } + + gsi_queue.notify_workers(); + }); + } + + Ok(()) +} + /// Returns true if the error indicates the index table no longer exists. pub(crate) fn is_table_not_found(err: &extenddb_storage::error::StorageError) -> bool { match err { diff --git a/tests/test_gsi_async.py b/tests/test_gsi_async.py index c71f7b2e..069fa001 100755 --- a/tests/test_gsi_async.py +++ b/tests/test_gsi_async.py @@ -23,7 +23,7 @@ import pytest -from conftest import wait_for_active, wait_for_deleted +from conftest import wait_for_active, wait_for_deleted, wait_for_gsi_items # EXTENDDB_TEST_ENDPOINT is required — devtools/run-tests validates this. # Tests will use the default endpoint if the env var is missing. @@ -86,6 +86,7 @@ def gsi_table(self, dynamodb_client, unique_table_name): BillingMode="PAY_PER_REQUEST", ) wait_for_active(dynamodb_client, table_name) + wait_for_gsi_active(dynamodb_client, table_name, "test-gsi") yield table_name try: dynamodb_client.delete_table(TableName=table_name) @@ -123,7 +124,7 @@ def test_gsi_update_is_eventually_consistent( # Measure time until item appears in GSI. start = time.monotonic() - deadline = start + 5.0 + deadline = start + 15.0 found = False while time.monotonic() < deadline: resp = dynamodb_client.query( @@ -141,7 +142,7 @@ def test_gsi_update_is_eventually_consistent( break time.sleep(0.005) # 5ms poll interval - assert found, f"Item {pk} did not appear in GSI within 5 seconds" + assert found, f"Item {pk} did not appear in GSI within 15 seconds" # Print observed delays for human review. print(f"\n GSI propagation delays (ms): {[f'{d:.1f}' for d in delays]}") @@ -169,7 +170,7 @@ def test_gsi_delete_is_eventually_consistent( ) # Wait for it to appear in GSI. - deadline = time.monotonic() + 5.0 + deadline = time.monotonic() + 15.0 while time.monotonic() < deadline: resp = dynamodb_client.query( TableName=table_name, @@ -189,7 +190,7 @@ def test_gsi_delete_is_eventually_consistent( ) # Wait for removal from GSI. - deadline = time.monotonic() + 5.0 + deadline = time.monotonic() + 15.0 while time.monotonic() < deadline: resp = dynamodb_client.query( TableName=table_name, @@ -337,3 +338,153 @@ def test_gsi_configured_delay_range( ) finally: extenddb_settings_set("index_propagation_delay_ms", original_delay) + + +def wait_for_gsi_active(client, table_name: str, index_name: str, timeout: float = 30.0) -> None: + """Poll DescribeTable until the named GSI reaches ACTIVE status.""" + interval = 0.02 if os.environ.get("EXTENDDB_TEST_ENDPOINT", "").strip() else 0.2 + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + resp = client.describe_table(TableName=table_name) + gsis = resp["Table"].get("GlobalSecondaryIndexes", []) + for gsi in gsis: + if gsi["IndexName"] == index_name and gsi["IndexStatus"] == "ACTIVE": + return + time.sleep(interval) + raise TimeoutError( + f"GSI '{index_name}' on '{table_name}' did not become ACTIVE within {timeout}s" + ) + + +class TestGsiAddToExistingTable: + """Tests for adding a GSI to an existing table via UpdateTable. + + Covers the full lifecycle: CREATING initial state with async transition to + ACTIVE, backfill of pre-existing items, and capture of writes that arrive + after UpdateTable returns. + """ + + @pytest.fixture() + def base_table(self, dynamodb_client, unique_table_name): + """Empty base table with a single hash key.""" + name = unique_table_name + dynamodb_client.create_table( + TableName=name, + AttributeDefinitions=[ + {"AttributeName": "pk", "AttributeType": "S"}, + ], + KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}], + BillingMode="PAY_PER_REQUEST", + ) + wait_for_active(dynamodb_client, name) + yield name + try: + dynamodb_client.delete_table(TableName=name) + except Exception: + pass + else: + wait_for_deleted(dynamodb_client, name) + + def test_update_table_gsi_lifecycle(self, dynamodb_client, base_table): + """UpdateTable response shows CREATING; GSI transitions to ACTIVE on its own.""" + resp = dynamodb_client.update_table( + TableName=base_table, + AttributeDefinitions=[ + {"AttributeName": "gsi_pk", "AttributeType": "S"}, + ], + GlobalSecondaryIndexUpdates=[{ + "Create": { + "IndexName": "new-gsi", + "KeySchema": [{"AttributeName": "gsi_pk", "KeyType": "HASH"}], + "Projection": {"ProjectionType": "ALL"}, + } + }], + ) + gsis = resp["TableDescription"].get("GlobalSecondaryIndexes", []) + statuses = {g["IndexName"]: g["IndexStatus"] for g in gsis} + assert statuses.get("new-gsi") == "CREATING", ( + f"Expected CREATING, got {statuses.get('new-gsi')!r}" + ) + # Raises TimeoutError if the transition never arrives. + wait_for_gsi_active(dynamodb_client, base_table, "new-gsi") + + def test_update_table_gsi_backfills_existing_items(self, dynamodb_client, base_table): + """Items written before UpdateTable are visible through the GSI after it goes ACTIVE.""" + gsi_pk_value = f"backfill-{uuid.uuid4().hex[:8]}" + + # Write items before the GSI exists. + for i in range(5): + dynamodb_client.put_item( + TableName=base_table, + Item={ + "pk": {"S": f"item-{i}"}, + "gsi_pk": {"S": gsi_pk_value}, + }, + ) + + dynamodb_client.update_table( + TableName=base_table, + AttributeDefinitions=[ + {"AttributeName": "gsi_pk", "AttributeType": "S"}, + ], + GlobalSecondaryIndexUpdates=[{ + "Create": { + "IndexName": "backfill-gsi", + "KeySchema": [{"AttributeName": "gsi_pk", "KeyType": "HASH"}], + "Projection": {"ProjectionType": "ALL"}, + } + }], + ) + wait_for_gsi_active(dynamodb_client, base_table, "backfill-gsi") + + resp = dynamodb_client.query( + TableName=base_table, + IndexName="backfill-gsi", + KeyConditionExpression="gsi_pk = :v", + ExpressionAttributeValues={":v": {"S": gsi_pk_value}}, + ) + assert resp["Count"] == 5, ( + f"Expected 5 backfilled items, got {resp['Count']}" + ) + + def test_update_table_gsi_captures_writes_after_creation(self, dynamodb_client, base_table): + """Items written after UpdateTable returns are visible through the GSI once ACTIVE.""" + gsi_pk_value = f"post-write-{uuid.uuid4().hex[:8]}" + + dynamodb_client.update_table( + TableName=base_table, + AttributeDefinitions=[ + {"AttributeName": "gsi_pk", "AttributeType": "S"}, + ], + GlobalSecondaryIndexUpdates=[{ + "Create": { + "IndexName": "post-write-gsi", + "KeySchema": [{"AttributeName": "gsi_pk", "KeyType": "HASH"}], + "Projection": {"ProjectionType": "ALL"}, + } + }], + ) + + # Write items after UpdateTable returns (GSI is CREATING). + for i in range(3): + dynamodb_client.put_item( + TableName=base_table, + Item={ + "pk": {"S": f"post-{i}"}, + "gsi_pk": {"S": gsi_pk_value}, + }, + ) + + wait_for_gsi_active(dynamodb_client, base_table, "post-write-gsi") + + # Poll until all 3 items are visible (GSI is eventually consistent). + def query_all(): + return dynamodb_client.query( + TableName=base_table, + IndexName="post-write-gsi", + KeyConditionExpression="gsi_pk = :v", + ExpressionAttributeValues={":v": {"S": gsi_pk_value}}, + )["Items"] + + items = wait_for_gsi_items(query_all, expected=3) + assert len(items) == 3, f"Expected 3 items, got {len(items)}" From 9b3f26d2c405b82757ae11c4ba41d1ebcd6db790 Mon Sep 17 00:00:00 2001 From: Joel Shepherd Date: Wed, 23 Sep 2026 00:13:35 +0000 Subject: [PATCH 40/48] chore(tests): tests shouldn't assume GSI creation is async, test console account lockout, Cargo.lock update --- Cargo.lock | 1193 ++++++++++++++++++----------------- tests/test_gsi_async.py | 6 +- tests/test_rate_limiting.py | 104 +++ 3 files changed, 734 insertions(+), 569 deletions(-) create mode 100644 tests/test_rate_limiting.py diff --git a/Cargo.lock b/Cargo.lock index ba920d1a..ceb6cae5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -58,9 +58,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -132,15 +132,15 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.103" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arc-swap" -version = "1.9.1" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" dependencies = [ "rustversion", ] @@ -175,8 +175,8 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", - "synstructure", + "syn 2.0.119", + "synstructure 0.13.2", ] [[package]] @@ -187,14 +187,14 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "async-compression" -version = "0.4.42" +version = "0.4.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +checksum = "fb61aea1a7def73ee7c350a184f0e70b32c182344e2e75bf70c9b621b83417fd" dependencies = [ "compression-codecs", "compression-core", @@ -215,13 +215,13 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] @@ -256,9 +256,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.0" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -267,14 +267,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.41.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -338,7 +339,7 @@ checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -375,6 +376,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" @@ -434,9 +441,9 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" dependencies = [ "serde_core", ] @@ -496,7 +503,7 @@ dependencies = [ "indexmap", "js-sys", "once_cell", - "rand 0.9.4", + "rand 0.9.5", "serde", "serde_bytes", "serde_json", @@ -521,13 +528,13 @@ dependencies = [ [[package]] name = "bytemuck_derive" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] @@ -538,9 +545,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cassandra-protocol" @@ -568,9 +575,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.62" +version = "1.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" dependencies = [ "find-msvc-tools", "jobserver", @@ -594,7 +601,7 @@ dependencies = [ "futures", "fxhash", "itertools 0.14.0", - "rand 0.10.2", + "rand 0.10.3", "serde_json", "thiserror", "tokio", @@ -611,23 +618,23 @@ dependencies = [ "itertools 0.11.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "rand_core 0.10.1", ] @@ -656,9 +663,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.1" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" dependencies = [ "clap_builder", "clap_derive", @@ -666,9 +673,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" dependencies = [ "anstream", "anstyle", @@ -678,21 +685,21 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] name = "clap_lex" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" [[package]] name = "cmake" @@ -717,9 +724,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "combine" -version = "4.6.7" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ "bytes", "memchr", @@ -727,9 +734,9 @@ dependencies = [ [[package]] name = "compression-codecs" -version = "0.4.38" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +checksum = "bef16c47ba2797aa6a909cc37d39911f3a6743811fe7408ac0b0cc0276b656e9" dependencies = [ "compression-core", "flate2", @@ -738,9 +745,9 @@ dependencies = [ [[package]] name = "compression-core" -version = "0.4.32" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" +checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" [[package]] name = "config" @@ -827,6 +834,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -838,9 +851,9 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] @@ -862,9 +875,9 @@ checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" dependencies = [ "cfg-if", ] @@ -877,36 +890,36 @@ checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" [[package]] name = "crossbeam-channel" -version = "0.5.15" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-epoch" -version = "0.9.20" +version = "0.9.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-queue" -version = "0.3.12" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" [[package]] name = "crunchy" @@ -963,9 +976,9 @@ dependencies = [ [[package]] name = "darling" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" dependencies = [ "darling_core", "darling_macro", @@ -973,26 +986,26 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" dependencies = [ "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] name = "darling_macro" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ "darling_core", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] @@ -1011,9 +1024,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] name = "der" @@ -1046,7 +1059,6 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "powerfmt", "serde_core", ] @@ -1069,18 +1081,18 @@ checksum = "d65d7ce8132b7c0e54497a4d9a55a1c2a0912a0d786cf894472ba818fba45762" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "derive-where" -version = "1.6.1" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" +checksum = "2e2b94854e8576378ccda7c8de8a66ed8b4e8acbd2c50ec3418ea6c8aaf4b567" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] @@ -1102,7 +1114,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.117", + "syn 2.0.119", "unicode-xid", ] @@ -1132,13 +1144,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] @@ -1164,20 +1176,26 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "either" -version = "1.16.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" dependencies = [ "serde", ] [[package]] name = "encoding_rs" -version = "0.8.35" +version = "0.8.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a" dependencies = [ "cfg-if", + "core_detect", + "multiversion", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", ] [[package]] @@ -1207,6 +1225,16 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + [[package]] name = "event-listener" version = "5.4.2" @@ -1260,7 +1288,7 @@ dependencies = [ "rustls", "serde", "serde_json", - "sqlx", + "sqlx 0.8.6", "time", "tokio", "toml", @@ -1366,7 +1394,7 @@ dependencies = [ "hyper", "libc", "metrics", - "rand 0.9.4", + "rand 0.9.5", "rustls", "serde", "serde_json", @@ -1392,7 +1420,7 @@ dependencies = [ "extenddb-auth", "extenddb-core", "futures", - "rand 0.9.4", + "rand 0.9.5", "serde", "serde_json", "thiserror", @@ -1406,7 +1434,7 @@ dependencies = [ [[package]] name = "extenddb-storage-cassandra" -version = "0.1.11" +version = "0.1.12" dependencies = [ "aes-gcm", "async-trait", @@ -1420,7 +1448,7 @@ dependencies = [ "extenddb-core", "extenddb-storage", "futures", - "rand 0.9.4", + "rand 0.9.5", "serde", "serde_json", "time", @@ -1449,7 +1477,7 @@ dependencies = [ "futures", "mongodb", "proptest", - "rand 0.9.4", + "rand 0.9.5", "serde", "serde_json", "thiserror", @@ -1476,10 +1504,10 @@ dependencies = [ "extenddb-storage", "futures", "pgvector", - "rand 0.9.4", + "rand 0.9.5", "serde", "serde_json", - "sqlx", + "sqlx 0.8.6", "time", "tokio", "toml", @@ -1503,10 +1531,10 @@ dependencies = [ "extenddb-core", "extenddb-storage", "futures", - "rand 0.9.4", + "rand 0.9.5", "serde", "serde_json", - "sqlx", + "sqlx 0.8.6", "time", "tokio", "toml", @@ -1523,18 +1551,19 @@ checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -1566,6 +1595,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1577,9 +1612,9 @@ dependencies = [ [[package]] name = "fs-err" -version = "3.3.0" +version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0" +checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a" dependencies = [ "autocfg", "tokio", @@ -1599,9 +1634,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -1614,9 +1649,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -1624,15 +1659,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -1652,38 +1687,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -1744,16 +1779,14 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", "rand_core 0.10.1", - "wasip2", - "wasip3", ] [[package]] @@ -1768,9 +1801,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.14" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" dependencies = [ "atomic-waker", "bytes", @@ -1803,7 +1836,18 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", ] [[package]] @@ -1830,6 +1874,15 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "heck" version = "0.5.0" @@ -1844,9 +1897,9 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hickory-net" -version = "0.26.1" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" +checksum = "c480823ed7c2c5d0f09c41020cb6b7c28029ce60ec42dc942158dcf22f8e0a4d" dependencies = [ "async-trait", "cfg-if", @@ -1858,7 +1911,7 @@ dependencies = [ "idna", "ipnet", "jni", - "rand 0.10.2", + "rand 0.10.3", "thiserror", "tinyvec", "tokio", @@ -1868,9 +1921,9 @@ dependencies = [ [[package]] name = "hickory-proto" -version = "0.26.1" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +checksum = "12b92608f679a6fa515dd1d15c1ff89443026e391200a2c840c7afcba482893d" dependencies = [ "data-encoding", "idna", @@ -1878,7 +1931,7 @@ dependencies = [ "jni", "once_cell", "prefix-trie", - "rand 0.10.2", + "rand 0.10.3", "ring", "thiserror", "tinyvec", @@ -1888,9 +1941,9 @@ dependencies = [ [[package]] name = "hickory-resolver" -version = "0.26.1" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" +checksum = "3f3da5255c95d5a716857d54b5b8f4e8d67c3484d3beaaaae2ce25063b3ba981" dependencies = [ "cfg-if", "futures-util", @@ -1903,7 +1956,7 @@ dependencies = [ "ndk-context", "once_cell", "parking_lot", - "rand 0.10.2", + "rand 0.10.3", "resolv-conf", "smallvec", "system-configuration", @@ -1921,6 +1974,15 @@ dependencies = [ "hmac 0.12.1", ] +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", +] + [[package]] name = "hmac" version = "0.12.1" @@ -1950,9 +2012,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -1960,9 +2022,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -1970,9 +2032,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -1995,18 +2057,18 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hybrid-array" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ "typenum", ] [[package]] name = "hyper" -version = "1.9.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" dependencies = [ "atomic-waker", "bytes", @@ -2064,9 +2126,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -2078,9 +2140,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -2091,9 +2153,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -2105,16 +2167,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -2125,15 +2188,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -2144,12 +2207,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -2179,14 +2236,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown 0.17.1", - "serde", - "serde_core", ] [[package]] @@ -2219,9 +2274,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.12.1" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" dependencies = [ "serde", ] @@ -2283,7 +2338,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2302,28 +2357,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.99" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -2347,17 +2401,11 @@ dependencies = [ "spin", ] -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libm" @@ -2367,14 +2415,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.16" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" dependencies = [ "bitflags", "libc", "plain", - "redox_syscall 0.7.5", + "redox_syscall 0.9.4", ] [[package]] @@ -2396,9 +2444,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "lock_api" @@ -2411,9 +2459,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lz4_flex" @@ -2433,7 +2481,7 @@ dependencies = [ "macro_magic_core", "macro_magic_macros", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2447,7 +2495,7 @@ dependencies = [ "macro_magic_core_macros", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2458,7 +2506,7 @@ checksum = "b02abfe41815b5bd98dbd4260173db2c116dda171dc0fe7838cb206333b83308" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2469,7 +2517,7 @@ checksum = "73ea28ee64b88876bf45277ed9a5817c1817df061a74f2b988971a12570e5869" dependencies = [ "macro_magic_core", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2509,9 +2557,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "metrics" @@ -2537,9 +2585,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.8.9" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" dependencies = [ "adler2", "simd-adler32", @@ -2547,9 +2595,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", "wasi", @@ -2558,9 +2606,9 @@ dependencies = [ [[package]] name = "moka" -version = "0.12.15" +version = "0.12.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9" dependencies = [ "async-lock", "crossbeam-channel", @@ -2596,9 +2644,9 @@ checksum = "851fac73f7fe22f6a3ab87f720ce509cae7c9fd08e7dd27866cc232dee07ccf4" [[package]] name = "mongodb" -version = "3.8.0" +version = "3.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b814038f367d212f55de0a630cb35102a9b8ca23785a86955d62c0087c93846d" +checksum = "af84443ae9878f59a6818686f339064479f42cdf293c4c637d8fc9c0a8c5eed6" dependencies = [ "base64 0.22.1", "bitflags", @@ -2619,7 +2667,7 @@ dependencies = [ "mongodb-internal-macros", "pbkdf2", "percent-encoding", - "rand 0.9.4", + "rand 0.9.5", "rustc_version_runtime", "rustls", "serde", @@ -2637,21 +2685,48 @@ dependencies = [ "tokio-util", "typed-builder", "uuid", - "webpki-roots 1.0.7", + "webpki-roots 1.0.9", ] [[package]] name = "mongodb-internal-macros" -version = "3.8.0" +version = "3.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f736d2fbc56e0011a341fbb9172bd822fda75c5f93b82fae1c7aab1e2613c810" +checksum = "504024bbb83ab1bf1512007f7288b54d3a6343043f3f619c26a153346773598d" dependencies = [ "macro_magic", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", +] + +[[package]] +name = "multiversion" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c" +dependencies = [ + "multiversion-macros", +] + +[[package]] +name = "multiversion-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83" +dependencies = [ + "proc-macro2", + "quote", + "rustversion", + "syn 3.0.6", ] +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "ndk-context" version = "0.1.1" @@ -2679,9 +2754,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -2698,7 +2773,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.6", + "rand 0.8.8", "smallvec", "zeroize", ] @@ -2711,20 +2786,19 @@ checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] [[package]] name = "num-iter" -version = "0.1.45" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" dependencies = [ - "autocfg", "num-integer", "num-traits", ] @@ -2826,11 +2900,11 @@ dependencies = [ [[package]] name = "pem" -version = "3.0.6" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "serde_core", ] @@ -2851,9 +2925,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.6" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +checksum = "45d3aca230fad2e6f6317ca0a72724338c4960cb97168a85cdee66df4a9a21a8" dependencies = [ "memchr", "ucd-trie", @@ -2861,9 +2935,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.6" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +checksum = "284b60557f2c4a2e72ad3f2d34d42685a2fa4a6a61d0d2a10c0ae2a5e916c2cf" dependencies = [ "pest", "pest_generator", @@ -2871,25 +2945,24 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.6" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +checksum = "1d9d1f08a115309ee99268cf85e5228e0e56aa9caf8841ec12866b6be07c3109" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "pest_meta" -version = "2.8.6" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +checksum = "ed93ba1a9ffcca32130a5188701c81c0c49cf00d4b7c5007d5148951d743adcb" dependencies = [ "pest", - "sha2 0.10.9", ] [[package]] @@ -2898,7 +2971,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3673cba5b9a124916096a423b806a9f29620972c6c97b08db5f2053e9428b481" dependencies = [ - "sqlx", + "sqlx 0.9.0", ] [[package]] @@ -2930,9 +3003,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "plain" @@ -2954,15 +3027,15 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -2993,21 +3066,11 @@ dependencies = [ "num-traits", ] -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.117", -] - [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -3022,7 +3085,7 @@ dependencies = [ "bit-vec 0.8.0", "bitflags", "num-traits", - "rand 0.9.4", + "rand 0.9.5", "rand_chacha 0.9.0", "rand_xorshift", "regex-syntax", @@ -3039,9 +3102,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -3066,9 +3129,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -3077,9 +3140,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -3087,12 +3150,12 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.2" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20", - "getrandom 0.4.2", + "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -3151,18 +3214,18 @@ dependencies = [ [[package]] name = "rapidhash" -version = "4.4.1" +version = "4.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e48930979c155e2f33aa36ab3119b5ee81332beb6482199a8ecd6029b80b59" +checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e" dependencies = [ "rustversion", ] [[package]] name = "rcgen" -version = "0.14.8" +version = "0.14.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" +checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8" dependencies = [ "aws-lc-rs", "pem", @@ -3183,18 +3246,18 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.7.5" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" dependencies = [ "bitflags", ] [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -3203,9 +3266,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "resolv-conf" @@ -3299,9 +3362,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ "bitflags", "errno", @@ -3312,9 +3375,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "log", @@ -3328,18 +3391,18 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "zeroize", ] [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -3349,9 +3412,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "rusty-fork" @@ -3394,9 +3457,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -3414,29 +3477,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "indexmap", "itoa", @@ -3480,9 +3543,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.21.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4" dependencies = [ "serde_core", "serde_with_macros", @@ -3490,21 +3553,21 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.21.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc" dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -3518,7 +3581,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "digest 0.11.3", ] @@ -3540,7 +3603,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "digest 0.11.3", ] @@ -3555,9 +3618,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -3581,9 +3644,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simd_cesu8" @@ -3609,9 +3672,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" dependencies = [ "serde", ] @@ -3624,9 +3687,9 @@ checksum = "199905e6153d6405f9728fe44daace35f8f837bbf830bb6e85fbd5828709a886" [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -3634,9 +3697,9 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" dependencies = [ "lock_api", ] @@ -3657,13 +3720,24 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" dependencies = [ - "sqlx-core", - "sqlx-macros", + "sqlx-core 0.8.6", + "sqlx-macros 0.8.6", "sqlx-mysql", - "sqlx-postgres", + "sqlx-postgres 0.8.6", "sqlx-sqlite", ] +[[package]] +name = "sqlx" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" +dependencies = [ + "sqlx-core 0.9.0", + "sqlx-macros 0.9.0", + "sqlx-postgres 0.9.0", +] + [[package]] name = "sqlx-core" version = "0.8.6" @@ -3703,6 +3777,38 @@ dependencies = [ "webpki-roots 0.26.11", ] +[[package]] +name = "sqlx-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" +dependencies = [ + "base64 0.22.1", + "bytes", + "cfg-if", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.16.1", + "hashlink 0.11.1", + "indexmap", + "log", + "memchr", + "percent-encoding", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror", + "tracing", + "url", +] + [[package]] name = "sqlx-macros" version = "0.8.6" @@ -3711,9 +3817,22 @@ checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" dependencies = [ "proc-macro2", "quote", - "sqlx-core", - "sqlx-macros-core", - "syn 2.0.117", + "sqlx-core 0.8.6", + "sqlx-macros-core 0.8.6", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core 0.9.0", + "sqlx-macros-core 0.9.0", + "syn 2.0.119", ] [[package]] @@ -3732,15 +3851,37 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "sqlx-core", + "sqlx-core 0.8.6", "sqlx-mysql", - "sqlx-postgres", + "sqlx-postgres 0.8.6", "sqlx-sqlite", - "syn 2.0.117", + "syn 2.0.119", "tokio", "url", ] +[[package]] +name = "sqlx-macros-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" +dependencies = [ + "cfg-if", + "dotenvy", + "either", + "heck", + "hex", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core 0.9.0", + "sqlx-postgres 0.9.0", + "syn 2.0.119", + "url", +] + [[package]] name = "sqlx-mysql" version = "0.8.6" @@ -3763,7 +3904,7 @@ dependencies = [ "futures-util", "generic-array", "hex", - "hkdf", + "hkdf 0.12.4", "hmac 0.12.1", "itoa", "log", @@ -3771,19 +3912,19 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand 0.8.6", + "rand 0.8.8", "rsa", "serde", - "sha1 0.10.6", + "sha1 0.10.7", "sha2 0.10.9", "smallvec", - "sqlx-core", + "sqlx-core 0.8.6", "stringprep", "thiserror", "time", "tracing", "uuid", - "whoami", + "whoami 1.6.1", ] [[package]] @@ -3799,12 +3940,12 @@ dependencies = [ "byteorder", "crc", "dotenvy", - "etcetera", + "etcetera 0.8.0", "futures-channel", "futures-core", "futures-util", "hex", - "hkdf", + "hkdf 0.12.4", "hmac 0.12.1", "home", "itoa", @@ -3813,18 +3954,53 @@ dependencies = [ "memchr", "num-bigint", "once_cell", - "rand 0.8.6", + "rand 0.8.8", "serde", "serde_json", "sha2 0.10.9", "smallvec", - "sqlx-core", + "sqlx-core 0.8.6", "stringprep", "thiserror", "time", "tracing", "uuid", - "whoami", + "whoami 1.6.1", +] + +[[package]] +name = "sqlx-postgres" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "crc", + "dotenvy", + "etcetera 0.11.0", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf 0.13.0", + "hmac 0.13.0", + "itoa", + "log", + "md-5 0.11.0", + "memchr", + "rand 0.10.3", + "serde", + "serde_json", + "sha2 0.11.0", + "smallvec", + "sqlx-core 0.9.0", + "stringprep", + "thiserror", + "tracing", + "whoami 2.1.3", ] [[package]] @@ -3845,7 +4021,7 @@ dependencies = [ "percent-encoding", "serde", "serde_urlencoded", - "sqlx-core", + "sqlx-core 0.8.6", "thiserror", "time", "tracing", @@ -3895,9 +4071,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -3906,9 +4082,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.5" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" dependencies = [ "proc-macro2", "quote", @@ -3929,7 +4105,18 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", ] [[package]] @@ -3989,7 +4176,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -3997,41 +4184,40 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] [[package]] name = "time" -version = "0.3.47" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde_core", @@ -4041,15 +4227,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -4066,9 +4252,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -4076,24 +4262,15 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -4108,20 +4285,20 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] name = "tokio-rustls" -version = "0.26.4" +version = "0.26.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" dependencies = [ "rustls", "tokio", @@ -4129,9 +4306,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ "futures-core", "pin-project-lite", @@ -4140,15 +4317,16 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-io", "futures-sink", "futures-util", + "libc", "pin-project-lite", "tokio", ] @@ -4261,7 +4439,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -4339,14 +4517,14 @@ checksum = "0e48cea23f68d1f78eb7bc092881b6bb88d3d6b5b7e6234f6f9c911da1ffb221" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "ucd-trie" @@ -4368,9 +4546,9 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" [[package]] name = "unicode-normalization" @@ -4389,9 +4567,9 @@ checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-xid" @@ -4453,11 +4631,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -4508,20 +4686,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] @@ -4532,9 +4701,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.122" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" dependencies = [ "cfg-if", "once_cell", @@ -4545,9 +4714,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4555,74 +4724,40 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - [[package]] name = "webpki-roots" version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.7", + "webpki-roots 1.0.9", ] [[package]] name = "webpki-roots" -version = "1.0.7" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" dependencies = [ "rustls-pki-types", ] @@ -4637,6 +4772,12 @@ dependencies = [ "wasite", ] +[[package]] +name = "whoami" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" + [[package]] name = "widestring" version = "1.2.1" @@ -4673,7 +4814,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -4684,7 +4825,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -4879,105 +5020,17 @@ dependencies = [ "memchr", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "wyz" @@ -5029,9 +5082,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -5040,34 +5093,34 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", - "synstructure", + "syn 3.0.6", + "synstructure 0.14.0", ] [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -5081,41 +5134,41 @@ dependencies = [ [[package]] name = "zerofrom-derive" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", - "synstructure", + "syn 3.0.6", + "synstructure 0.14.0", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.4.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -5124,9 +5177,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -5135,17 +5188,23 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/tests/test_gsi_async.py b/tests/test_gsi_async.py index 069fa001..2d091388 100755 --- a/tests/test_gsi_async.py +++ b/tests/test_gsi_async.py @@ -402,8 +402,10 @@ def test_update_table_gsi_lifecycle(self, dynamodb_client, base_table): ) gsis = resp["TableDescription"].get("GlobalSecondaryIndexes", []) statuses = {g["IndexName"]: g["IndexStatus"] for g in gsis} - assert statuses.get("new-gsi") == "CREATING", ( - f"Expected CREATING, got {statuses.get('new-gsi')!r}" + # SQL backends create GSIs synchronously so they immediately go into ACTIVE. Cassandra + # creates GSIs async, so GSIs are probably in CREATING immediately after table update. + assert statuses.get("new-gsi") in ("CREATING", "ACTIVE"), ( + f"Expected CREATING or ACTIVE, got {statuses.get('new-gsi')!r}" ) # Raises TimeoutError if the transition never arrives. wait_for_gsi_active(dynamodb_client, base_table, "new-gsi") diff --git a/tests/test_rate_limiting.py b/tests/test_rate_limiting.py new file mode 100644 index 00000000..a807281d --- /dev/null +++ b/tests/test_rate_limiting.py @@ -0,0 +1,104 @@ +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 + +"""Rate limiting and account lockout integration tests. + +Verifies that repeated failed login attempts on the management API trigger the +per-principal lockout enforced by the storage backend's RateLimitStore +implementation. + +ExtendDB-only: rate limiting is not exercised against real DynamoDB. + +Prerequisites: + - extenddb running with ``auth.provider = "builtin"`` on EXTENDDB_TEST_ENDPOINT + - Admin credentials in EXTENDDB_ADMIN_USER / EXTENDDB_ADMIN_PASSWORD env vars + +REQ-AUTH-003 +""" + +from __future__ import annotations + +import os +import uuid + +import pytest +import requests + +from management_helpers import ManagementClient + + +def _require_extenddb_env() -> tuple[str, str, str]: + endpoint = os.environ.get("EXTENDDB_TEST_ENDPOINT", "").strip() + admin_user = os.environ.get("EXTENDDB_ADMIN_USER", "").strip() + admin_pass = os.environ.get("EXTENDDB_ADMIN_PASSWORD", "").strip() + if not endpoint or not admin_user or not admin_pass: + pytest.skip( + "Rate limiting tests require EXTENDDB_TEST_ENDPOINT, " + "EXTENDDB_ADMIN_USER, and EXTENDDB_ADMIN_PASSWORD." + ) + return endpoint, admin_user, admin_pass + + +# MAX_FAILURES_PER_PRINCIPAL from crates/server/src/rate_limit.rs +_LOCKOUT_THRESHOLD = 5 + + +class TestRateLimiting: + @pytest.fixture(autouse=True) + def setup_and_teardown(self): + endpoint, admin_user, admin_pass = _require_extenddb_env() + self.endpoint = endpoint + self.mgmt = ManagementClient(endpoint, admin_user, admin_pass) + self.verify = not endpoint.startswith("https://") + + self.account_id = f"{uuid.uuid4().int % 10**12:012d}" + self.user_name = f"ratelimit-{uuid.uuid4().hex[:8]}" + + resp = self.mgmt.create_account(self.account_id, f"ratelimit-acct-{self.account_id}") + assert resp.status_code == 201, resp.text + resp = self.mgmt.create_user(self.account_id, self.user_name, "ValidPass123!") + assert resp.status_code == 201, resp.text + + yield + + self.mgmt.delete_account(self.account_id) + + def _management_request_with_password(self, password: str) -> requests.Response: + """Hit a self-service management endpoint as the test user with the given password.""" + return requests.get( + f"{self.mgmt.base_url}/accounts/{self.account_id}/users/{self.user_name}/access-keys", + auth=(f"{self.account_id}/{self.user_name}", password), + timeout=10, + verify=self.verify, + ) + + def test_principal_locked_out_after_threshold_failures(self): + """After MAX_FAILURES_PER_PRINCIPAL bad passwords the account is locked out.""" + for i in range(_LOCKOUT_THRESHOLD): + resp = self._management_request_with_password("wrongpassword") + assert resp.status_code == 401, ( + f"Expected 401 on attempt {i + 1}, got {resp.status_code}: {resp.text}" + ) + + # The next attempt should be rejected with 429, not 401. + resp = self._management_request_with_password("wrongpassword") + assert resp.status_code == 429, ( + f"Expected 429 (lockout) after {_LOCKOUT_THRESHOLD} failures, " + f"got {resp.status_code}: {resp.text}" + ) + assert "too many" in resp.text.lower(), ( + f"Expected lockout message in response body, got: {resp.text}" + ) + + def test_correct_password_still_works_before_lockout(self): + """Fewer than threshold failures do not lock out the account.""" + for _ in range(_LOCKOUT_THRESHOLD - 1): + resp = self._management_request_with_password("wrongpassword") + assert resp.status_code == 401 + + # Valid password should still succeed. + resp = self._management_request_with_password("ValidPass123!") + assert resp.status_code == 200, ( + f"Expected 200 with correct password before lockout, " + f"got {resp.status_code}: {resp.text}" + ) From 4f24818c348fe26296a851b8bfebc20dcf158542 Mon Sep 17 00:00:00 2001 From: Joel Shepherd Date: Wed, 23 Sep 2026 00:30:39 +0000 Subject: [PATCH 41/48] fix:Pinning pgvector to 0.4.1, 0.4.2 requires sqlx 0.9.0 and we're currently on 0.8 --- Cargo.lock | 208 ++++++----------------------------------------------- Cargo.toml | 2 +- 2 files changed, 23 insertions(+), 187 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ceb6cae5..4284d50f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1225,16 +1225,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "etcetera" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" -dependencies = [ - "cfg-if", - "windows-sys 0.61.2", -] - [[package]] name = "event-listener" version = "5.4.2" @@ -1288,7 +1278,7 @@ dependencies = [ "rustls", "serde", "serde_json", - "sqlx 0.8.6", + "sqlx", "time", "tokio", "toml", @@ -1507,7 +1497,7 @@ dependencies = [ "rand 0.9.5", "serde", "serde_json", - "sqlx 0.8.6", + "sqlx", "time", "tokio", "toml", @@ -1534,7 +1524,7 @@ dependencies = [ "rand 0.9.5", "serde", "serde_json", - "sqlx 0.8.6", + "sqlx", "time", "tokio", "toml", @@ -1595,12 +1585,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1836,18 +1820,7 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash 0.1.5", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.2.0", + "foldhash", ] [[package]] @@ -1874,15 +1847,6 @@ dependencies = [ "hashbrown 0.15.5", ] -[[package]] -name = "hashlink" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" -dependencies = [ - "hashbrown 0.16.1", -] - [[package]] name = "heck" version = "0.5.0" @@ -1974,15 +1938,6 @@ dependencies = [ "hmac 0.12.1", ] -[[package]] -name = "hkdf" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" -dependencies = [ - "hmac 0.13.0", -] - [[package]] name = "hmac" version = "0.12.1" @@ -2967,11 +2922,11 @@ dependencies = [ [[package]] name = "pgvector" -version = "0.4.2" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3673cba5b9a124916096a423b806a9f29620972c6c97b08db5f2053e9428b481" +checksum = "fc58e2d255979a31caa7cabfa7aac654af0354220719ab7a68520ae7a91e8c0b" dependencies = [ - "sqlx 0.9.0", + "sqlx", ] [[package]] @@ -3720,24 +3675,13 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" dependencies = [ - "sqlx-core 0.8.6", - "sqlx-macros 0.8.6", + "sqlx-core", + "sqlx-macros", "sqlx-mysql", - "sqlx-postgres 0.8.6", + "sqlx-postgres", "sqlx-sqlite", ] -[[package]] -name = "sqlx" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" -dependencies = [ - "sqlx-core 0.9.0", - "sqlx-macros 0.9.0", - "sqlx-postgres 0.9.0", -] - [[package]] name = "sqlx-core" version = "0.8.6" @@ -3777,38 +3721,6 @@ dependencies = [ "webpki-roots 0.26.11", ] -[[package]] -name = "sqlx-core" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" -dependencies = [ - "base64 0.22.1", - "bytes", - "cfg-if", - "crc", - "crossbeam-queue", - "either", - "event-listener", - "futures-core", - "futures-intrusive", - "futures-io", - "futures-util", - "hashbrown 0.16.1", - "hashlink 0.11.1", - "indexmap", - "log", - "memchr", - "percent-encoding", - "serde", - "serde_json", - "sha2 0.10.9", - "smallvec", - "thiserror", - "tracing", - "url", -] - [[package]] name = "sqlx-macros" version = "0.8.6" @@ -3817,21 +3729,8 @@ checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" dependencies = [ "proc-macro2", "quote", - "sqlx-core 0.8.6", - "sqlx-macros-core 0.8.6", - "syn 2.0.119", -] - -[[package]] -name = "sqlx-macros" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" -dependencies = [ - "proc-macro2", - "quote", - "sqlx-core 0.9.0", - "sqlx-macros-core 0.9.0", + "sqlx-core", + "sqlx-macros-core", "syn 2.0.119", ] @@ -3851,37 +3750,15 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "sqlx-core 0.8.6", + "sqlx-core", "sqlx-mysql", - "sqlx-postgres 0.8.6", + "sqlx-postgres", "sqlx-sqlite", "syn 2.0.119", "tokio", "url", ] -[[package]] -name = "sqlx-macros-core" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" -dependencies = [ - "cfg-if", - "dotenvy", - "either", - "heck", - "hex", - "proc-macro2", - "quote", - "serde", - "serde_json", - "sha2 0.10.9", - "sqlx-core 0.9.0", - "sqlx-postgres 0.9.0", - "syn 2.0.119", - "url", -] - [[package]] name = "sqlx-mysql" version = "0.8.6" @@ -3904,7 +3781,7 @@ dependencies = [ "futures-util", "generic-array", "hex", - "hkdf 0.12.4", + "hkdf", "hmac 0.12.1", "itoa", "log", @@ -3918,13 +3795,13 @@ dependencies = [ "sha1 0.10.7", "sha2 0.10.9", "smallvec", - "sqlx-core 0.8.6", + "sqlx-core", "stringprep", "thiserror", "time", "tracing", "uuid", - "whoami 1.6.1", + "whoami", ] [[package]] @@ -3940,12 +3817,12 @@ dependencies = [ "byteorder", "crc", "dotenvy", - "etcetera 0.8.0", + "etcetera", "futures-channel", "futures-core", "futures-util", "hex", - "hkdf 0.12.4", + "hkdf", "hmac 0.12.1", "home", "itoa", @@ -3959,48 +3836,13 @@ dependencies = [ "serde_json", "sha2 0.10.9", "smallvec", - "sqlx-core 0.8.6", + "sqlx-core", "stringprep", "thiserror", "time", "tracing", "uuid", - "whoami 1.6.1", -] - -[[package]] -name = "sqlx-postgres" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" -dependencies = [ - "atoi", - "base64 0.22.1", - "bitflags", - "byteorder", - "crc", - "dotenvy", - "etcetera 0.11.0", - "futures-channel", - "futures-core", - "futures-util", - "hex", - "hkdf 0.13.0", - "hmac 0.13.0", - "itoa", - "log", - "md-5 0.11.0", - "memchr", - "rand 0.10.3", - "serde", - "serde_json", - "sha2 0.11.0", - "smallvec", - "sqlx-core 0.9.0", - "stringprep", - "thiserror", - "tracing", - "whoami 2.1.3", + "whoami", ] [[package]] @@ -4021,7 +3863,7 @@ dependencies = [ "percent-encoding", "serde", "serde_urlencoded", - "sqlx-core 0.8.6", + "sqlx-core", "thiserror", "time", "tracing", @@ -4772,12 +4614,6 @@ dependencies = [ "wasite", ] -[[package]] -name = "whoami" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" - [[package]] name = "widestring" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index 8460da3d..3ee29358 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,7 +70,7 @@ sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", # Vector column type for the PostgreSQL backend. The `sqlx` feature gives a typed # `Vector` that encodes and decodes pgvector's binary format, so embeddings keep # f32 bit-exactness instead of going through a hand-written text parser. -pgvector = { version = "0.4", default-features = false, features = ["sqlx"] } +pgvector = { version = "=0.4.1", default-features = false, features = ["sqlx"] } mongodb = "3" bson = "2.13" dashmap = "6" From c94b69ec5fcd3cb4fa49bce617637e66510e49c7 Mon Sep 17 00:00:00 2001 From: Joel Shepherd Date: Wed, 23 Sep 2026 18:37:14 +0000 Subject: [PATCH 42/48] chore:updating SOFTWARE-LICENSE-NOTICES.html --- SOFTWARE-LICENSE-NOTICES.html | 506 +++++++++++++++++++++++----------- 1 file changed, 344 insertions(+), 162 deletions(-) diff --git a/SOFTWARE-LICENSE-NOTICES.html b/SOFTWARE-LICENSE-NOTICES.html index 998787f2..819aa0c3 100644 --- a/SOFTWARE-LICENSE-NOTICES.html +++ b/SOFTWARE-LICENSE-NOTICES.html @@ -49,7 +49,7 @@

ExtendDB Software License Notices

Overview of licenses:

    -
  • Apache License 2.0 (212)
  • +
  • Apache License 2.0 (219)
  • MIT License (53)
  • Unicode License v3 (19)
  • ISC License (5)
  • @@ -485,12 +485,15 @@

    Used by:

    Apache License 2.0

    Used by:

                                      Apache License
    @@ -909,7 +912,7 @@ 

    Used by:

    Apache License 2.0

    Used by:

                                     Apache License
                                Version 2.0, January 2004
    @@ -1118,7 +1121,7 @@ 

    Used by:

    Apache License 2.0

    Used by:

                                     Apache License
                                Version 2.0, January 2004
    @@ -1308,7 +1311,7 @@ 

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2020 Tomasz "Soveu" Marx + Copyright 2023 The Fuchsia Authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -1328,7 +1331,10 @@

    Used by:

    Apache License 2.0

    Used by:

                                     Apache License
                                Version 2.0, January 2004
    @@ -1510,7 +1516,7 @@ 

    Used by:

    APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" + boilerplate notice, with the fields enclosed by brackets "{}" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a @@ -1518,7 +1524,7 @@

    Used by:

    same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2023 The Fuchsia Authors + Copyright Individual contributors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -1542,13 +1548,9 @@

    Used by:

  • anstyle-parse 1.0.0
  • anstyle-query 1.1.5
  • anstyle 1.0.14
  • -
  • clap 4.6.1
  • -
  • clap_builder 4.6.0
  • -
  • clap_derive 4.6.1
  • -
  • clap_lex 1.1.0
  • colorchoice 1.0.5
  • config 0.14.1
  • -
  • crc32fast 1.5.0
  • +
  • crc32fast 1.5.2
  • hex 0.4.3
  • is_terminal_polyfill 1.70.2
  • serde_spanned 0.6.9
  • @@ -1975,15 +1977,15 @@

    Used by:

    Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -2402,7 +2404,7 @@ 

    Used by:

    Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -2819,7 +2821,7 @@ 

    Used by:

    Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -3028,7 +3030,7 @@ 

    Used by:

    Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -3446,7 +3448,7 @@ 

    Used by:

    Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -3866,7 +3868,7 @@ 

    Used by:

    Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -4070,39 +4072,248 @@ 

    Used by:

    See the License for the specific language governing permissions and limitations under the License.
    + +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0, January 2004
    +                     http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
    +
    +Copyright [yyyy] [name of copyright owner]
    +
    +Licensed under the Apache License, Version 2.0 (the "License");
    +you may not use this file except in compliance with the License.
    +You may obtain a copy of the License at
    +
    +	http://www.apache.org/licenses/LICENSE-2.0
    +
    +Unless required by applicable law or agreed to in writing, software
    +distributed under the License is distributed on an "AS IS" BASIS,
    +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +See the License for the specific language governing permissions and
    +limitations under the License.
  • Apache License 2.0

    Used by:

    @@ -5616,7 +5828,7 @@

    Used by:

                                  Apache License
    @@ -6036,7 +6248,7 @@ 

    Used by:

    Apache License 2.0

    Used by:

                                  Apache License
                             Version 2.0, January 2004
    @@ -6677,43 +6889,45 @@ 

    Used by:

  • extenddb-storage 0.1.12
  • extenddb-storage-postgres 0.1.12
  • allocator-api2 0.2.21
  • -
  • anyhow 1.0.103
  • +
  • anyhow 1.0.104
  • arraydeque 0.5.1
  • -
  • async-trait 0.1.89
  • -
  • aws-lc-sys 0.41.0
  • +
  • async-trait 0.1.92
  • +
  • aws-lc-sys 0.45.0
  • daemonize 0.5.0
  • dlv-list 0.5.2
  • dunce 1.0.5
  • itoa 1.0.18
  • -
  • libc 0.2.186
  • -
  • miniz_oxide 0.8.9
  • +
  • libc 0.2.189
  • +
  • miniz_oxide 0.9.1
  • num-conv 0.2.2
  • pin-project-lite 0.2.17
  • -
  • portable-atomic 1.13.1
  • -
  • proc-macro2 1.0.106
  • -
  • quote 1.0.45
  • -
  • rand 0.8.6
  • -
  • rand 0.9.4
  • +
  • portable-atomic 1.15.0
  • +
  • proc-macro2 1.0.107
  • +
  • quote 1.0.47
  • +
  • rand 0.8.8
  • +
  • rand 0.9.5
  • rand_chacha 0.9.0
  • -
  • rcgen 0.14.8
  • -
  • rustversion 1.0.22
  • +
  • rcgen 0.14.10
  • +
  • rustversion 1.0.23
  • ryu 1.0.23
  • -
  • serde 1.0.228
  • -
  • serde_core 1.0.228
  • -
  • serde_derive 1.0.228
  • -
  • serde_json 1.0.150
  • +
  • serde 1.0.229
  • +
  • serde_core 1.0.229
  • +
  • serde_derive 1.0.229
  • +
  • serde_json 1.0.151
  • serde_path_to_error 0.1.20
  • serde_urlencoded 0.7.1
  • -
  • shlex 1.3.0
  • -
  • syn 2.0.117
  • +
  • shlex 2.0.1
  • +
  • simdutf8 0.1.5
  • +
  • syn 2.0.119
  • +
  • syn 3.0.6
  • sync_wrapper 1.0.2
  • tagptr 0.2.0
  • -
  • thiserror-impl 2.0.18
  • -
  • thiserror 2.0.18
  • -
  • time-core 0.1.8
  • -
  • time-macros 0.2.27
  • -
  • time 0.3.47
  • -
  • unicode-ident 1.0.24
  • +
  • thiserror-impl 2.0.20
  • +
  • thiserror 2.0.20
  • +
  • time-core 0.1.9
  • +
  • time-macros 0.2.32
  • +
  • time 0.3.55
  • +
  • unicode-ident 1.0.26
  • utf8parse 0.2.2
  • whoami 1.6.1
  • yaml-rust2 0.8.1
  • @@ -6871,7 +7085,7 @@

    Used by:

    BSD 3-Clause "New" or "Revised" License

    Used by:

    Copyright (c) <year> <owner>. 
     
    @@ -6890,7 +7104,7 @@ 

    Used by:

    BSD 3-Clause "New" or "Revised" License

    Used by:

    Copyright © WHATWG (Apple, Google, Mozilla, Microsoft).
     
    @@ -7054,7 +7268,7 @@ 

    Community Data License Agreement Permissive 2.0

    Used by:
    # Community Data License Agreement - Permissive - Version 2.0
     
    @@ -7165,7 +7379,7 @@ 

    Used by:

    ISC License

    Used by:

    Except as otherwise noted, this project is licensed under the following
     (ISC-style) terms:
    @@ -7192,8 +7406,8 @@ 

    Used by:

    ISC License

    Used by:

    ISC License:
     
    @@ -7237,7 +7451,7 @@ 

    Used by:

    MIT License

    Used by:

    Copyright (c) 2014 Carl Lerche and other MIO contributors
     
    @@ -7292,7 +7506,7 @@ 

    Used by:

    MIT License

    Used by:

    Copyright (c) 2014-2026 Sean McArthur
     
    @@ -7319,7 +7533,7 @@ 

    Used by:

    MIT License

    Used by:

    Copyright (c) 2017 h2 authors
     
    @@ -7352,7 +7566,7 @@ 

    Used by:

    MIT License

    Used by:

    Copyright (c) 2018 Carl Lerche
     
    @@ -7611,42 +7825,10 @@ 

    Used by:

    MIT License

    Used by:

    -
    Copyright (c) 2019-2024 Sean McArthur & Hyper Contributors
    -
    -Permission is hereby granted, free of charge, to any
    -person obtaining a copy of this software and associated
    -documentation files (the "Software"), to deal in the
    -Software without restriction, including without
    -limitation the rights to use, copy, modify, merge,
    -publish, distribute, sublicense, and/or sell copies of
    -the Software, and to permit persons to whom the Software
    -is furnished to do so, subject to the following
    -conditions:
    -
    -The above copyright notice and this permission notice
    -shall be included in all copies or substantial portions
    -of the Software.
    -
    -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
    -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
    -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
    -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
    -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
    -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
    -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
    -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
    -DEALINGS IN THE SOFTWARE.
    -
    -
  • -
  • -

    MIT License

    -

    Used by:

    - -
    Copyright (c) 2019-2025 Sean McArthur & Hyper Contributors
    +                
    Copyright (c) 2019-2026 Sean McArthur & Hyper Contributors
     
     Permission is hereby granted, free of charge, to any
     person obtaining a copy of this software and associated
    @@ -7731,7 +7913,7 @@ 

    Used by:

    MIT License

    Used by:

    Copyright 2016 Nika Layzell
     
    @@ -7875,7 +8057,7 @@ 

    Used by:

    MIT License

    Used by:

    MIT License
     
    @@ -8023,9 +8205,9 @@ 

    Used by:

    MIT License

    Used by:

    MIT License
     
    @@ -8054,7 +8236,7 @@ 

    Used by:

    MIT License

    Used by:

    MIT License
     
    @@ -8084,7 +8266,7 @@ 

    MIT License

    Used by:

    Permission is hereby granted, free of charge, to any
     person obtaining a copy of this software and associated
    @@ -8159,7 +8341,7 @@ 

    MIT License

    Used by:

    The MIT License (MIT)
     
    @@ -8219,7 +8401,7 @@ 

    Used by:

    MIT License

    Used by:

    The MIT License (MIT)
     
    @@ -8249,7 +8431,7 @@ 

    Used by:

    MIT License

    Used by:

    The MIT License (MIT)
     
    @@ -8422,7 +8604,7 @@ 

    Used by:

    Unicode License v3

    Used by:

    UNICODE LICENSE V3
     
    @@ -8469,24 +8651,24 @@ 

    Used by:

    Unicode License v3

    Used by:

    UNICODE LICENSE V3
     
    
    From 9ea0dce7506bb1808cac5d8f101c7479ea6e9978 Mon Sep 17 00:00:00 2001
    From: Joel Shepherd 
    Date: Wed, 23 Sep 2026 18:39:14 +0000
    Subject: [PATCH 43/48] test(storage-cassandra): limit parallelism for
     direct-integration tests to avoid unpredictable exhaustion of the connection
     pool
    
    ---
     crates/storage-cassandra/.cargo/config.toml | 5 +++++
     1 file changed, 5 insertions(+)
     create mode 100644 crates/storage-cassandra/.cargo/config.toml
    
    diff --git a/crates/storage-cassandra/.cargo/config.toml b/crates/storage-cassandra/.cargo/config.toml
    new file mode 100644
    index 00000000..cdb38caf
    --- /dev/null
    +++ b/crates/storage-cassandra/.cargo/config.toml
    @@ -0,0 +1,5 @@
    +[test]
    +# Limit parallelism for integration tests that require a live Cassandra node.
    +# Each test opens its own engine/session; too many concurrent connections
    +# exhaust Cassandra's per-client limit and cause spurious timeouts.
    +test-threads = 4
    
    From 60b540db5045f0aca6a519803fdf865128cbe408 Mon Sep 17 00:00:00 2001
    From: Joel Shepherd 
    Date: Wed, 23 Sep 2026 19:50:43 +0000
    Subject: [PATCH 44/48] chore:refresh MongoDB and Dev licenses, add Cassandra
     license file and enable script to regenerate it
    
    ---
     SOFTWARE-LICENSE-NOTICES-CASSANDRA.html    | 9656 ++++++++++++++++++++
     SOFTWARE-LICENSE-NOTICES-DEV.html          |  507 +-
     SOFTWARE-LICENSE-NOTICES-MONGODB.html      |  544 +-
     devtools/generate-software-license-notices |    5 +-
     4 files changed, 10366 insertions(+), 346 deletions(-)
     create mode 100644 SOFTWARE-LICENSE-NOTICES-CASSANDRA.html
    
    diff --git a/SOFTWARE-LICENSE-NOTICES-CASSANDRA.html b/SOFTWARE-LICENSE-NOTICES-CASSANDRA.html
    new file mode 100644
    index 00000000..fb0a5746
    --- /dev/null
    +++ b/SOFTWARE-LICENSE-NOTICES-CASSANDRA.html
    @@ -0,0 +1,9656 @@
    +
    +
    +
    +
    +    
    +
    +
    +
    +    
    +
    +

    ExtendDB Software License Notices

    +

    This page lists licenses for Rust packages distributed in the ExtendDB PostgreSQL container image.

    +
    + +

    Overview of licenses:

    + + +

    All license text:

    +
      +
    • +

      Apache License 2.0

      +

      Used by:

      + +
      +                                 Apache License
      +                           Version 2.0, January 2004
      +                        http://www.apache.org/licenses/
      +
      +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +   1. Definitions.
      +
      +      "License" shall mean the terms and conditions for use, reproduction,
      +      and distribution as defined by Sections 1 through 9 of this document.
      +
      +      "Licensor" shall mean the copyright owner or entity authorized by
      +      the copyright owner that is granting the License.
      +
      +      "Legal Entity" shall mean the union of the acting entity and all
      +      other entities that control, are controlled by, or are under common
      +      control with that entity. For the purposes of this definition,
      +      "control" means (i) the power, direct or indirect, to cause the
      +      direction or management of such entity, whether by contract or
      +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +      outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +      "You" (or "Your") shall mean an individual or Legal Entity
      +      exercising permissions granted by this License.
      +
      +      "Source" form shall mean the preferred form for making modifications,
      +      including but not limited to software source code, documentation
      +      source, and configuration files.
      +
      +      "Object" form shall mean any form resulting from mechanical
      +      transformation or translation of a Source form, including but
      +      not limited to compiled object code, generated documentation,
      +      and conversions to other media types.
      +
      +      "Work" shall mean the work of authorship, whether in Source or
      +      Object form, made available under the License, as indicated by a
      +      copyright notice that is included in or attached to the work
      +      (an example is provided in the Appendix below).
      +
      +      "Derivative Works" shall mean any work, whether in Source or Object
      +      form, that is based on (or derived from) the Work and for which the
      +      editorial revisions, annotations, elaborations, or other modifications
      +      represent, as a whole, an original work of authorship. For the purposes
      +      of this License, Derivative Works shall not include works that remain
      +      separable from, or merely link (or bind by name) to the interfaces of,
      +      the Work and Derivative Works thereof.
      +
      +      "Contribution" shall mean any work of authorship, including
      +      the original version of the Work and any modifications or additions
      +      to that Work or Derivative Works thereof, that is intentionally
      +      submitted to Licensor for inclusion in the Work by the copyright owner
      +      or by an individual or Legal Entity authorized to submit on behalf of
      +      the copyright owner. For the purposes of this definition, "submitted"
      +      means any form of electronic, verbal, or written communication sent
      +      to the Licensor or its representatives, including but not limited to
      +      communication on electronic mailing lists, source code control systems,
      +      and issue tracking systems that are managed by, or on behalf of, the
      +      Licensor for the purpose of discussing and improving the Work, but
      +      excluding communication that is conspicuously marked or otherwise
      +      designated in writing by the copyright owner as "Not a Contribution."
      +
      +      "Contributor" shall mean Licensor and any individual or Legal Entity
      +      on behalf of whom a Contribution has been received by Licensor and
      +      subsequently incorporated within the Work.
      +
      +   2. Grant of Copyright License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      copyright license to reproduce, prepare Derivative Works of,
      +      publicly display, publicly perform, sublicense, and distribute the
      +      Work and such Derivative Works in Source or Object form.
      +
      +   3. Grant of Patent License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      (except as stated in this section) patent license to make, have made,
      +      use, offer to sell, sell, import, and otherwise transfer the Work,
      +      where such license applies only to those patent claims licensable
      +      by such Contributor that are necessarily infringed by their
      +      Contribution(s) alone or by combination of their Contribution(s)
      +      with the Work to which such Contribution(s) was submitted. If You
      +      institute patent litigation against any entity (including a
      +      cross-claim or counterclaim in a lawsuit) alleging that the Work
      +      or a Contribution incorporated within the Work constitutes direct
      +      or contributory patent infringement, then any patent licenses
      +      granted to You under this License for that Work shall terminate
      +      as of the date such litigation is filed.
      +
      +   4. Redistribution. You may reproduce and distribute copies of the
      +      Work or Derivative Works thereof in any medium, with or without
      +      modifications, and in Source or Object form, provided that You
      +      meet the following conditions:
      +
      +      (a) You must give any other recipients of the Work or
      +          Derivative Works a copy of this License; and
      +
      +      (b) You must cause any modified files to carry prominent notices
      +          stating that You changed the files; and
      +
      +      (c) You must retain, in the Source form of any Derivative Works
      +          that You distribute, all copyright, patent, trademark, and
      +          attribution notices from the Source form of the Work,
      +          excluding those notices that do not pertain to any part of
      +          the Derivative Works; and
      +
      +      (d) If the Work includes a "NOTICE" text file as part of its
      +          distribution, then any Derivative Works that You distribute must
      +          include a readable copy of the attribution notices contained
      +          within such NOTICE file, excluding those notices that do not
      +          pertain to any part of the Derivative Works, in at least one
      +          of the following places: within a NOTICE text file distributed
      +          as part of the Derivative Works; within the Source form or
      +          documentation, if provided along with the Derivative Works; or,
      +          within a display generated by the Derivative Works, if and
      +          wherever such third-party notices normally appear. The contents
      +          of the NOTICE file are for informational purposes only and
      +          do not modify the License. You may add Your own attribution
      +          notices within Derivative Works that You distribute, alongside
      +          or as an addendum to the NOTICE text from the Work, provided
      +          that such additional attribution notices cannot be construed
      +          as modifying the License.
      +
      +      You may add Your own copyright statement to Your modifications and
      +      may provide additional or different license terms and conditions
      +      for use, reproduction, or distribution of Your modifications, or
      +      for any such Derivative Works as a whole, provided Your use,
      +      reproduction, and distribution of the Work otherwise complies with
      +      the conditions stated in this License.
      +
      +   5. Submission of Contributions. Unless You explicitly state otherwise,
      +      any Contribution intentionally submitted for inclusion in the Work
      +      by You to the Licensor shall be under the terms and conditions of
      +      this License, without any additional terms or conditions.
      +      Notwithstanding the above, nothing herein shall supersede or modify
      +      the terms of any separate license agreement you may have executed
      +      with Licensor regarding such Contributions.
      +
      +   6. Trademarks. This License does not grant permission to use the trade
      +      names, trademarks, service marks, or product names of the Licensor,
      +      except as required for reasonable and customary use in describing the
      +      origin of the Work and reproducing the content of the NOTICE file.
      +
      +   7. Disclaimer of Warranty. Unless required by applicable law or
      +      agreed to in writing, Licensor provides the Work (and each
      +      Contributor provides its Contributions) on an "AS IS" BASIS,
      +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +      implied, including, without limitation, any warranties or conditions
      +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +      PARTICULAR PURPOSE. You are solely responsible for determining the
      +      appropriateness of using or redistributing the Work and assume any
      +      risks associated with Your exercise of permissions under this License.
      +
      +   8. Limitation of Liability. In no event and under no legal theory,
      +      whether in tort (including negligence), contract, or otherwise,
      +      unless required by applicable law (such as deliberate and grossly
      +      negligent acts) or agreed to in writing, shall any Contributor be
      +      liable to You for damages, including any direct, indirect, special,
      +      incidental, or consequential damages of any character arising as a
      +      result of this License or out of the use or inability to use the
      +      Work (including but not limited to damages for loss of goodwill,
      +      work stoppage, computer failure or malfunction, or any and all
      +      other commercial damages or losses), even if such Contributor
      +      has been advised of the possibility of such damages.
      +
      +   9. Accepting Warranty or Additional Liability. While redistributing
      +      the Work or Derivative Works thereof, You may choose to offer,
      +      and charge a fee for, acceptance of support, warranty, indemnity,
      +      or other liability obligations and/or rights consistent with this
      +      License. However, in accepting such obligations, You may act only
      +      on Your own behalf and on Your sole responsibility, not on behalf
      +      of any other Contributor, and only if You agree to indemnify,
      +      defend, and hold each Contributor harmless for any liability
      +      incurred by, or claims asserted against, such Contributor by reason
      +      of your accepting any such warranty or additional liability.
      +
      +   END OF TERMS AND CONDITIONS
      +
      +   APPENDIX: How to apply the Apache License to your work.
      +
      +      To apply the Apache License to your work, attach the following
      +      boilerplate notice, with the fields enclosed by brackets "[]"
      +      replaced with your own identifying information. (Don't include
      +      the brackets!)  The text should be enclosed in the appropriate
      +      comment syntax for the file format. We also recommend that a
      +      file or class name and description of purpose be included on the
      +      same "printed page" as the copyright notice for easier
      +      identification within third-party archives.
      +
      +   Copyright 2023 Jacob Pratt et al.
      +
      +   Licensed under the Apache License, Version 2.0 (the "License");
      +   you may not use this file except in compliance with the License.
      +   You may obtain a copy of the License at
      +
      +       http://www.apache.org/licenses/LICENSE-2.0
      +
      +   Unless required by applicable law or agreed to in writing, software
      +   distributed under the License is distributed on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +   See the License for the specific language governing permissions and
      +   limitations under the License.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
      +                                 Apache License
      +                           Version 2.0, January 2004
      +                        http://www.apache.org/licenses/
      +
      +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +   1. Definitions.
      +
      +      "License" shall mean the terms and conditions for use, reproduction,
      +      and distribution as defined by Sections 1 through 9 of this document.
      +
      +      "Licensor" shall mean the copyright owner or entity authorized by
      +      the copyright owner that is granting the License.
      +
      +      "Legal Entity" shall mean the union of the acting entity and all
      +      other entities that control, are controlled by, or are under common
      +      control with that entity. For the purposes of this definition,
      +      "control" means (i) the power, direct or indirect, to cause the
      +      direction or management of such entity, whether by contract or
      +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +      outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +      "You" (or "Your") shall mean an individual or Legal Entity
      +      exercising permissions granted by this License.
      +
      +      "Source" form shall mean the preferred form for making modifications,
      +      including but not limited to software source code, documentation
      +      source, and configuration files.
      +
      +      "Object" form shall mean any form resulting from mechanical
      +      transformation or translation of a Source form, including but
      +      not limited to compiled object code, generated documentation,
      +      and conversions to other media types.
      +
      +      "Work" shall mean the work of authorship, whether in Source or
      +      Object form, made available under the License, as indicated by a
      +      copyright notice that is included in or attached to the work
      +      (an example is provided in the Appendix below).
      +
      +      "Derivative Works" shall mean any work, whether in Source or Object
      +      form, that is based on (or derived from) the Work and for which the
      +      editorial revisions, annotations, elaborations, or other modifications
      +      represent, as a whole, an original work of authorship. For the purposes
      +      of this License, Derivative Works shall not include works that remain
      +      separable from, or merely link (or bind by name) to the interfaces of,
      +      the Work and Derivative Works thereof.
      +
      +      "Contribution" shall mean any work of authorship, including
      +      the original version of the Work and any modifications or additions
      +      to that Work or Derivative Works thereof, that is intentionally
      +      submitted to Licensor for inclusion in the Work by the copyright owner
      +      or by an individual or Legal Entity authorized to submit on behalf of
      +      the copyright owner. For the purposes of this definition, "submitted"
      +      means any form of electronic, verbal, or written communication sent
      +      to the Licensor or its representatives, including but not limited to
      +      communication on electronic mailing lists, source code control systems,
      +      and issue tracking systems that are managed by, or on behalf of, the
      +      Licensor for the purpose of discussing and improving the Work, but
      +      excluding communication that is conspicuously marked or otherwise
      +      designated in writing by the copyright owner as "Not a Contribution."
      +
      +      "Contributor" shall mean Licensor and any individual or Legal Entity
      +      on behalf of whom a Contribution has been received by Licensor and
      +      subsequently incorporated within the Work.
      +
      +   2. Grant of Copyright License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      copyright license to reproduce, prepare Derivative Works of,
      +      publicly display, publicly perform, sublicense, and distribute the
      +      Work and such Derivative Works in Source or Object form.
      +
      +   3. Grant of Patent License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      (except as stated in this section) patent license to make, have made,
      +      use, offer to sell, sell, import, and otherwise transfer the Work,
      +      where such license applies only to those patent claims licensable
      +      by such Contributor that are necessarily infringed by their
      +      Contribution(s) alone or by combination of their Contribution(s)
      +      with the Work to which such Contribution(s) was submitted. If You
      +      institute patent litigation against any entity (including a
      +      cross-claim or counterclaim in a lawsuit) alleging that the Work
      +      or a Contribution incorporated within the Work constitutes direct
      +      or contributory patent infringement, then any patent licenses
      +      granted to You under this License for that Work shall terminate
      +      as of the date such litigation is filed.
      +
      +   4. Redistribution. You may reproduce and distribute copies of the
      +      Work or Derivative Works thereof in any medium, with or without
      +      modifications, and in Source or Object form, provided that You
      +      meet the following conditions:
      +
      +      (a) You must give any other recipients of the Work or
      +          Derivative Works a copy of this License; and
      +
      +      (b) You must cause any modified files to carry prominent notices
      +          stating that You changed the files; and
      +
      +      (c) You must retain, in the Source form of any Derivative Works
      +          that You distribute, all copyright, patent, trademark, and
      +          attribution notices from the Source form of the Work,
      +          excluding those notices that do not pertain to any part of
      +          the Derivative Works; and
      +
      +      (d) If the Work includes a "NOTICE" text file as part of its
      +          distribution, then any Derivative Works that You distribute must
      +          include a readable copy of the attribution notices contained
      +          within such NOTICE file, excluding those notices that do not
      +          pertain to any part of the Derivative Works, in at least one
      +          of the following places: within a NOTICE text file distributed
      +          as part of the Derivative Works; within the Source form or
      +          documentation, if provided along with the Derivative Works; or,
      +          within a display generated by the Derivative Works, if and
      +          wherever such third-party notices normally appear. The contents
      +          of the NOTICE file are for informational purposes only and
      +          do not modify the License. You may add Your own attribution
      +          notices within Derivative Works that You distribute, alongside
      +          or as an addendum to the NOTICE text from the Work, provided
      +          that such additional attribution notices cannot be construed
      +          as modifying the License.
      +
      +      You may add Your own copyright statement to Your modifications and
      +      may provide additional or different license terms and conditions
      +      for use, reproduction, or distribution of Your modifications, or
      +      for any such Derivative Works as a whole, provided Your use,
      +      reproduction, and distribution of the Work otherwise complies with
      +      the conditions stated in this License.
      +
      +   5. Submission of Contributions. Unless You explicitly state otherwise,
      +      any Contribution intentionally submitted for inclusion in the Work
      +      by You to the Licensor shall be under the terms and conditions of
      +      this License, without any additional terms or conditions.
      +      Notwithstanding the above, nothing herein shall supersede or modify
      +      the terms of any separate license agreement you may have executed
      +      with Licensor regarding such Contributions.
      +
      +   6. Trademarks. This License does not grant permission to use the trade
      +      names, trademarks, service marks, or product names of the Licensor,
      +      except as required for reasonable and customary use in describing the
      +      origin of the Work and reproducing the content of the NOTICE file.
      +
      +   7. Disclaimer of Warranty. Unless required by applicable law or
      +      agreed to in writing, Licensor provides the Work (and each
      +      Contributor provides its Contributions) on an "AS IS" BASIS,
      +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +      implied, including, without limitation, any warranties or conditions
      +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +      PARTICULAR PURPOSE. You are solely responsible for determining the
      +      appropriateness of using or redistributing the Work and assume any
      +      risks associated with Your exercise of permissions under this License.
      +
      +   8. Limitation of Liability. In no event and under no legal theory,
      +      whether in tort (including negligence), contract, or otherwise,
      +      unless required by applicable law (such as deliberate and grossly
      +      negligent acts) or agreed to in writing, shall any Contributor be
      +      liable to You for damages, including any direct, indirect, special,
      +      incidental, or consequential damages of any character arising as a
      +      result of this License or out of the use or inability to use the
      +      Work (including but not limited to damages for loss of goodwill,
      +      work stoppage, computer failure or malfunction, or any and all
      +      other commercial damages or losses), even if such Contributor
      +      has been advised of the possibility of such damages.
      +
      +   9. Accepting Warranty or Additional Liability. While redistributing
      +      the Work or Derivative Works thereof, You may choose to offer,
      +      and charge a fee for, acceptance of support, warranty, indemnity,
      +      or other liability obligations and/or rights consistent with this
      +      License. However, in accepting such obligations, You may act only
      +      on Your own behalf and on Your sole responsibility, not on behalf
      +      of any other Contributor, and only if You agree to indemnify,
      +      defend, and hold each Contributor harmless for any liability
      +      incurred by, or claims asserted against, such Contributor by reason
      +      of your accepting any such warranty or additional liability.
      +
      +   END OF TERMS AND CONDITIONS
      +
      +   APPENDIX: How to apply the Apache License to your work.
      +
      +      To apply the Apache License to your work, attach the following
      +      boilerplate notice, with the fields enclosed by brackets "[]"
      +      replaced with your own identifying information. (Don't include
      +      the brackets!)  The text should be enclosed in the appropriate
      +      comment syntax for the file format. We also recommend that a
      +      file or class name and description of purpose be included on the
      +      same "printed page" as the copyright notice for easier
      +      identification within third-party archives.
      +
      +   Copyright 2024 Jacob Pratt et al.
      +
      +   Licensed under the Apache License, Version 2.0 (the "License");
      +   you may not use this file except in compliance with the License.
      +   You may obtain a copy of the License at
      +
      +       http://www.apache.org/licenses/LICENSE-2.0
      +
      +   Unless required by applicable law or agreed to in writing, software
      +   distributed under the License is distributed on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +   See the License for the specific language governing permissions and
      +   limitations under the License.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
      +                                 Apache License
      +                           Version 2.0, January 2004
      +                        http://www.apache.org/licenses/
      +
      +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +   1. Definitions.
      +
      +      "License" shall mean the terms and conditions for use, reproduction,
      +      and distribution as defined by Sections 1 through 9 of this document.
      +
      +      "Licensor" shall mean the copyright owner or entity authorized by
      +      the copyright owner that is granting the License.
      +
      +      "Legal Entity" shall mean the union of the acting entity and all
      +      other entities that control, are controlled by, or are under common
      +      control with that entity. For the purposes of this definition,
      +      "control" means (i) the power, direct or indirect, to cause the
      +      direction or management of such entity, whether by contract or
      +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +      outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +      "You" (or "Your") shall mean an individual or Legal Entity
      +      exercising permissions granted by this License.
      +
      +      "Source" form shall mean the preferred form for making modifications,
      +      including but not limited to software source code, documentation
      +      source, and configuration files.
      +
      +      "Object" form shall mean any form resulting from mechanical
      +      transformation or translation of a Source form, including but
      +      not limited to compiled object code, generated documentation,
      +      and conversions to other media types.
      +
      +      "Work" shall mean the work of authorship, whether in Source or
      +      Object form, made available under the License, as indicated by a
      +      copyright notice that is included in or attached to the work
      +      (an example is provided in the Appendix below).
      +
      +      "Derivative Works" shall mean any work, whether in Source or Object
      +      form, that is based on (or derived from) the Work and for which the
      +      editorial revisions, annotations, elaborations, or other modifications
      +      represent, as a whole, an original work of authorship. For the purposes
      +      of this License, Derivative Works shall not include works that remain
      +      separable from, or merely link (or bind by name) to the interfaces of,
      +      the Work and Derivative Works thereof.
      +
      +      "Contribution" shall mean any work of authorship, including
      +      the original version of the Work and any modifications or additions
      +      to that Work or Derivative Works thereof, that is intentionally
      +      submitted to Licensor for inclusion in the Work by the copyright owner
      +      or by an individual or Legal Entity authorized to submit on behalf of
      +      the copyright owner. For the purposes of this definition, "submitted"
      +      means any form of electronic, verbal, or written communication sent
      +      to the Licensor or its representatives, including but not limited to
      +      communication on electronic mailing lists, source code control systems,
      +      and issue tracking systems that are managed by, or on behalf of, the
      +      Licensor for the purpose of discussing and improving the Work, but
      +      excluding communication that is conspicuously marked or otherwise
      +      designated in writing by the copyright owner as "Not a Contribution."
      +
      +      "Contributor" shall mean Licensor and any individual or Legal Entity
      +      on behalf of whom a Contribution has been received by Licensor and
      +      subsequently incorporated within the Work.
      +
      +   2. Grant of Copyright License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      copyright license to reproduce, prepare Derivative Works of,
      +      publicly display, publicly perform, sublicense, and distribute the
      +      Work and such Derivative Works in Source or Object form.
      +
      +   3. Grant of Patent License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      (except as stated in this section) patent license to make, have made,
      +      use, offer to sell, sell, import, and otherwise transfer the Work,
      +      where such license applies only to those patent claims licensable
      +      by such Contributor that are necessarily infringed by their
      +      Contribution(s) alone or by combination of their Contribution(s)
      +      with the Work to which such Contribution(s) was submitted. If You
      +      institute patent litigation against any entity (including a
      +      cross-claim or counterclaim in a lawsuit) alleging that the Work
      +      or a Contribution incorporated within the Work constitutes direct
      +      or contributory patent infringement, then any patent licenses
      +      granted to You under this License for that Work shall terminate
      +      as of the date such litigation is filed.
      +
      +   4. Redistribution. You may reproduce and distribute copies of the
      +      Work or Derivative Works thereof in any medium, with or without
      +      modifications, and in Source or Object form, provided that You
      +      meet the following conditions:
      +
      +      (a) You must give any other recipients of the Work or
      +          Derivative Works a copy of this License; and
      +
      +      (b) You must cause any modified files to carry prominent notices
      +          stating that You changed the files; and
      +
      +      (c) You must retain, in the Source form of any Derivative Works
      +          that You distribute, all copyright, patent, trademark, and
      +          attribution notices from the Source form of the Work,
      +          excluding those notices that do not pertain to any part of
      +          the Derivative Works; and
      +
      +      (d) If the Work includes a "NOTICE" text file as part of its
      +          distribution, then any Derivative Works that You distribute must
      +          include a readable copy of the attribution notices contained
      +          within such NOTICE file, excluding those notices that do not
      +          pertain to any part of the Derivative Works, in at least one
      +          of the following places: within a NOTICE text file distributed
      +          as part of the Derivative Works; within the Source form or
      +          documentation, if provided along with the Derivative Works; or,
      +          within a display generated by the Derivative Works, if and
      +          wherever such third-party notices normally appear. The contents
      +          of the NOTICE file are for informational purposes only and
      +          do not modify the License. You may add Your own attribution
      +          notices within Derivative Works that You distribute, alongside
      +          or as an addendum to the NOTICE text from the Work, provided
      +          that such additional attribution notices cannot be construed
      +          as modifying the License.
      +
      +      You may add Your own copyright statement to Your modifications and
      +      may provide additional or different license terms and conditions
      +      for use, reproduction, or distribution of Your modifications, or
      +      for any such Derivative Works as a whole, provided Your use,
      +      reproduction, and distribution of the Work otherwise complies with
      +      the conditions stated in this License.
      +
      +   5. Submission of Contributions. Unless You explicitly state otherwise,
      +      any Contribution intentionally submitted for inclusion in the Work
      +      by You to the Licensor shall be under the terms and conditions of
      +      this License, without any additional terms or conditions.
      +      Notwithstanding the above, nothing herein shall supersede or modify
      +      the terms of any separate license agreement you may have executed
      +      with Licensor regarding such Contributions.
      +
      +   6. Trademarks. This License does not grant permission to use the trade
      +      names, trademarks, service marks, or product names of the Licensor,
      +      except as required for reasonable and customary use in describing the
      +      origin of the Work and reproducing the content of the NOTICE file.
      +
      +   7. Disclaimer of Warranty. Unless required by applicable law or
      +      agreed to in writing, Licensor provides the Work (and each
      +      Contributor provides its Contributions) on an "AS IS" BASIS,
      +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +      implied, including, without limitation, any warranties or conditions
      +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +      PARTICULAR PURPOSE. You are solely responsible for determining the
      +      appropriateness of using or redistributing the Work and assume any
      +      risks associated with Your exercise of permissions under this License.
      +
      +   8. Limitation of Liability. In no event and under no legal theory,
      +      whether in tort (including negligence), contract, or otherwise,
      +      unless required by applicable law (such as deliberate and grossly
      +      negligent acts) or agreed to in writing, shall any Contributor be
      +      liable to You for damages, including any direct, indirect, special,
      +      incidental, or consequential damages of any character arising as a
      +      result of this License or out of the use or inability to use the
      +      Work (including but not limited to damages for loss of goodwill,
      +      work stoppage, computer failure or malfunction, or any and all
      +      other commercial damages or losses), even if such Contributor
      +      has been advised of the possibility of such damages.
      +
      +   9. Accepting Warranty or Additional Liability. While redistributing
      +      the Work or Derivative Works thereof, You may choose to offer,
      +      and charge a fee for, acceptance of support, warranty, indemnity,
      +      or other liability obligations and/or rights consistent with this
      +      License. However, in accepting such obligations, You may act only
      +      on Your own behalf and on Your sole responsibility, not on behalf
      +      of any other Contributor, and only if You agree to indemnify,
      +      defend, and hold each Contributor harmless for any liability
      +      incurred by, or claims asserted against, such Contributor by reason
      +      of your accepting any such warranty or additional liability.
      +
      +   END OF TERMS AND CONDITIONS
      +
      +   APPENDIX: How to apply the Apache License to your work.
      +
      +      To apply the Apache License to your work, attach the following
      +      boilerplate notice, with the fields enclosed by brackets "[]"
      +      replaced with your own identifying information. (Don't include
      +      the brackets!)  The text should be enclosed in the appropriate
      +      comment syntax for the file format. We also recommend that a
      +      file or class name and description of purpose be included on the
      +      same "printed page" as the copyright notice for easier
      +      identification within third-party archives.
      +
      +   Copyright [yyyy] [name of copyright owner]
      +
      +   Licensed under the Apache License, Version 2.0 (the "License");
      +   you may not use this file except in compliance with the License.
      +   You may obtain a copy of the License at
      +
      +       http://www.apache.org/licenses/LICENSE-2.0
      +
      +   Unless required by applicable law or agreed to in writing, software
      +   distributed under the License is distributed on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +   See the License for the specific language governing permissions and
      +   limitations under the License.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
      +                                 Apache License
      +                           Version 2.0, January 2004
      +                        http://www.apache.org/licenses/
      +
      +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +   1. Definitions.
      +
      +      "License" shall mean the terms and conditions for use, reproduction,
      +      and distribution as defined by Sections 1 through 9 of this document.
      +
      +      "Licensor" shall mean the copyright owner or entity authorized by
      +      the copyright owner that is granting the License.
      +
      +      "Legal Entity" shall mean the union of the acting entity and all
      +      other entities that control, are controlled by, or are under common
      +      control with that entity. For the purposes of this definition,
      +      "control" means (i) the power, direct or indirect, to cause the
      +      direction or management of such entity, whether by contract or
      +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +      outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +      "You" (or "Your") shall mean an individual or Legal Entity
      +      exercising permissions granted by this License.
      +
      +      "Source" form shall mean the preferred form for making modifications,
      +      including but not limited to software source code, documentation
      +      source, and configuration files.
      +
      +      "Object" form shall mean any form resulting from mechanical
      +      transformation or translation of a Source form, including but
      +      not limited to compiled object code, generated documentation,
      +      and conversions to other media types.
      +
      +      "Work" shall mean the work of authorship, whether in Source or
      +      Object form, made available under the License, as indicated by a
      +      copyright notice that is included in or attached to the work
      +      (an example is provided in the Appendix below).
      +
      +      "Derivative Works" shall mean any work, whether in Source or Object
      +      form, that is based on (or derived from) the Work and for which the
      +      editorial revisions, annotations, elaborations, or other modifications
      +      represent, as a whole, an original work of authorship. For the purposes
      +      of this License, Derivative Works shall not include works that remain
      +      separable from, or merely link (or bind by name) to the interfaces of,
      +      the Work and Derivative Works thereof.
      +
      +      "Contribution" shall mean any work of authorship, including
      +      the original version of the Work and any modifications or additions
      +      to that Work or Derivative Works thereof, that is intentionally
      +      submitted to Licensor for inclusion in the Work by the copyright owner
      +      or by an individual or Legal Entity authorized to submit on behalf of
      +      the copyright owner. For the purposes of this definition, "submitted"
      +      means any form of electronic, verbal, or written communication sent
      +      to the Licensor or its representatives, including but not limited to
      +      communication on electronic mailing lists, source code control systems,
      +      and issue tracking systems that are managed by, or on behalf of, the
      +      Licensor for the purpose of discussing and improving the Work, but
      +      excluding communication that is conspicuously marked or otherwise
      +      designated in writing by the copyright owner as "Not a Contribution."
      +
      +      "Contributor" shall mean Licensor and any individual or Legal Entity
      +      on behalf of whom a Contribution has been received by Licensor and
      +      subsequently incorporated within the Work.
      +
      +   2. Grant of Copyright License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      copyright license to reproduce, prepare Derivative Works of,
      +      publicly display, publicly perform, sublicense, and distribute the
      +      Work and such Derivative Works in Source or Object form.
      +
      +   3. Grant of Patent License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      (except as stated in this section) patent license to make, have made,
      +      use, offer to sell, sell, import, and otherwise transfer the Work,
      +      where such license applies only to those patent claims licensable
      +      by such Contributor that are necessarily infringed by their
      +      Contribution(s) alone or by combination of their Contribution(s)
      +      with the Work to which such Contribution(s) was submitted. If You
      +      institute patent litigation against any entity (including a
      +      cross-claim or counterclaim in a lawsuit) alleging that the Work
      +      or a Contribution incorporated within the Work constitutes direct
      +      or contributory patent infringement, then any patent licenses
      +      granted to You under this License for that Work shall terminate
      +      as of the date such litigation is filed.
      +
      +   4. Redistribution. You may reproduce and distribute copies of the
      +      Work or Derivative Works thereof in any medium, with or without
      +      modifications, and in Source or Object form, provided that You
      +      meet the following conditions:
      +
      +      (a) You must give any other recipients of the Work or
      +          Derivative Works a copy of this License; and
      +
      +      (b) You must cause any modified files to carry prominent notices
      +          stating that You changed the files; and
      +
      +      (c) You must retain, in the Source form of any Derivative Works
      +          that You distribute, all copyright, patent, trademark, and
      +          attribution notices from the Source form of the Work,
      +          excluding those notices that do not pertain to any part of
      +          the Derivative Works; and
      +
      +      (d) If the Work includes a "NOTICE" text file as part of its
      +          distribution, then any Derivative Works that You distribute must
      +          include a readable copy of the attribution notices contained
      +          within such NOTICE file, excluding those notices that do not
      +          pertain to any part of the Derivative Works, in at least one
      +          of the following places: within a NOTICE text file distributed
      +          as part of the Derivative Works; within the Source form or
      +          documentation, if provided along with the Derivative Works; or,
      +          within a display generated by the Derivative Works, if and
      +          wherever such third-party notices normally appear. The contents
      +          of the NOTICE file are for informational purposes only and
      +          do not modify the License. You may add Your own attribution
      +          notices within Derivative Works that You distribute, alongside
      +          or as an addendum to the NOTICE text from the Work, provided
      +          that such additional attribution notices cannot be construed
      +          as modifying the License.
      +
      +      You may add Your own copyright statement to Your modifications and
      +      may provide additional or different license terms and conditions
      +      for use, reproduction, or distribution of Your modifications, or
      +      for any such Derivative Works as a whole, provided Your use,
      +      reproduction, and distribution of the Work otherwise complies with
      +      the conditions stated in this License.
      +
      +   5. Submission of Contributions. Unless You explicitly state otherwise,
      +      any Contribution intentionally submitted for inclusion in the Work
      +      by You to the Licensor shall be under the terms and conditions of
      +      this License, without any additional terms or conditions.
      +      Notwithstanding the above, nothing herein shall supersede or modify
      +      the terms of any separate license agreement you may have executed
      +      with Licensor regarding such Contributions.
      +
      +   6. Trademarks. This License does not grant permission to use the trade
      +      names, trademarks, service marks, or product names of the Licensor,
      +      except as required for reasonable and customary use in describing the
      +      origin of the Work and reproducing the content of the NOTICE file.
      +
      +   7. Disclaimer of Warranty. Unless required by applicable law or
      +      agreed to in writing, Licensor provides the Work (and each
      +      Contributor provides its Contributions) on an "AS IS" BASIS,
      +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +      implied, including, without limitation, any warranties or conditions
      +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +      PARTICULAR PURPOSE. You are solely responsible for determining the
      +      appropriateness of using or redistributing the Work and assume any
      +      risks associated with Your exercise of permissions under this License.
      +
      +   8. Limitation of Liability. In no event and under no legal theory,
      +      whether in tort (including negligence), contract, or otherwise,
      +      unless required by applicable law (such as deliberate and grossly
      +      negligent acts) or agreed to in writing, shall any Contributor be
      +      liable to You for damages, including any direct, indirect, special,
      +      incidental, or consequential damages of any character arising as a
      +      result of this License or out of the use or inability to use the
      +      Work (including but not limited to damages for loss of goodwill,
      +      work stoppage, computer failure or malfunction, or any and all
      +      other commercial damages or losses), even if such Contributor
      +      has been advised of the possibility of such damages.
      +
      +   9. Accepting Warranty or Additional Liability. While redistributing
      +      the Work or Derivative Works thereof, You may choose to offer,
      +      and charge a fee for, acceptance of support, warranty, indemnity,
      +      or other liability obligations and/or rights consistent with this
      +      License. However, in accepting such obligations, You may act only
      +      on Your own behalf and on Your sole responsibility, not on behalf
      +      of any other Contributor, and only if You agree to indemnify,
      +      defend, and hold each Contributor harmless for any liability
      +      incurred by, or claims asserted against, such Contributor by reason
      +      of your accepting any such warranty or additional liability.
      +
      +   END OF TERMS AND CONDITIONS
      +
      +   APPENDIX: How to apply the Apache License to your work.
      +
      +      To apply the Apache License to your work, attach the following
      +      boilerplate notice, with the fields enclosed by brackets "[]"
      +      replaced with your own identifying information. (Don't include
      +      the brackets!)  The text should be enclosed in the appropriate
      +      comment syntax for the file format. We also recommend that a
      +      file or class name and description of purpose be included on the
      +      same "printed page" as the copyright notice for easier
      +      identification within third-party archives.
      +
      +   Copyright [yyyy] [name of copyright owner]
      +
      +   Licensed under the Apache License, Version 2.0 (the "License");
      +   you may not use this file except in compliance with the License.
      +   You may obtain a copy of the License at
      +
      +       http://www.apache.org/licenses/LICENSE-2.0
      +
      +   Unless required by applicable law or agreed to in writing, software
      +   distributed under the License is distributed on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +   See the License for the specific language governing permissions and
      +   limitations under the License.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                       Apache License
      +                           Version 2.0, January 2004
      +                        http://www.apache.org/licenses/
      +
      +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +   1. Definitions.
      +
      +      "License" shall mean the terms and conditions for use, reproduction,
      +      and distribution as defined by Sections 1 through 9 of this document.
      +
      +      "Licensor" shall mean the copyright owner or entity authorized by
      +      the copyright owner that is granting the License.
      +
      +      "Legal Entity" shall mean the union of the acting entity and all
      +      other entities that control, are controlled by, or are under common
      +      control with that entity. For the purposes of this definition,
      +      "control" means (i) the power, direct or indirect, to cause the
      +      direction or management of such entity, whether by contract or
      +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +      outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +      "You" (or "Your") shall mean an individual or Legal Entity
      +      exercising permissions granted by this License.
      +
      +      "Source" form shall mean the preferred form for making modifications,
      +      including but not limited to software source code, documentation
      +      source, and configuration files.
      +
      +      "Object" form shall mean any form resulting from mechanical
      +      transformation or translation of a Source form, including but
      +      not limited to compiled object code, generated documentation,
      +      and conversions to other media types.
      +
      +      "Work" shall mean the work of authorship, whether in Source or
      +      Object form, made available under the License, as indicated by a
      +      copyright notice that is included in or attached to the work
      +      (an example is provided in the Appendix below).
      +
      +      "Derivative Works" shall mean any work, whether in Source or Object
      +      form, that is based on (or derived from) the Work and for which the
      +      editorial revisions, annotations, elaborations, or other modifications
      +      represent, as a whole, an original work of authorship. For the purposes
      +      of this License, Derivative Works shall not include works that remain
      +      separable from, or merely link (or bind by name) to the interfaces of,
      +      the Work and Derivative Works thereof.
      +
      +      "Contribution" shall mean any work of authorship, including
      +      the original version of the Work and any modifications or additions
      +      to that Work or Derivative Works thereof, that is intentionally
      +      submitted to Licensor for inclusion in the Work by the copyright owner
      +      or by an individual or Legal Entity authorized to submit on behalf of
      +      the copyright owner. For the purposes of this definition, "submitted"
      +      means any form of electronic, verbal, or written communication sent
      +      to the Licensor or its representatives, including but not limited to
      +      communication on electronic mailing lists, source code control systems,
      +      and issue tracking systems that are managed by, or on behalf of, the
      +      Licensor for the purpose of discussing and improving the Work, but
      +      excluding communication that is conspicuously marked or otherwise
      +      designated in writing by the copyright owner as "Not a Contribution."
      +
      +      "Contributor" shall mean Licensor and any individual or Legal Entity
      +      on behalf of whom a Contribution has been received by Licensor and
      +      subsequently incorporated within the Work.
      +
      +   2. Grant of Copyright License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      copyright license to reproduce, prepare Derivative Works of,
      +      publicly display, publicly perform, sublicense, and distribute the
      +      Work and such Derivative Works in Source or Object form.
      +
      +   3. Grant of Patent License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      (except as stated in this section) patent license to make, have made,
      +      use, offer to sell, sell, import, and otherwise transfer the Work,
      +      where such license applies only to those patent claims licensable
      +      by such Contributor that are necessarily infringed by their
      +      Contribution(s) alone or by combination of their Contribution(s)
      +      with the Work to which such Contribution(s) was submitted. If You
      +      institute patent litigation against any entity (including a
      +      cross-claim or counterclaim in a lawsuit) alleging that the Work
      +      or a Contribution incorporated within the Work constitutes direct
      +      or contributory patent infringement, then any patent licenses
      +      granted to You under this License for that Work shall terminate
      +      as of the date such litigation is filed.
      +
      +   4. Redistribution. You may reproduce and distribute copies of the
      +      Work or Derivative Works thereof in any medium, with or without
      +      modifications, and in Source or Object form, provided that You
      +      meet the following conditions:
      +
      +      (a) You must give any other recipients of the Work or
      +          Derivative Works a copy of this License; and
      +
      +      (b) You must cause any modified files to carry prominent notices
      +          stating that You changed the files; and
      +
      +      (c) You must retain, in the Source form of any Derivative Works
      +          that You distribute, all copyright, patent, trademark, and
      +          attribution notices from the Source form of the Work,
      +          excluding those notices that do not pertain to any part of
      +          the Derivative Works; and
      +
      +      (d) If the Work includes a "NOTICE" text file as part of its
      +          distribution, then any Derivative Works that You distribute must
      +          include a readable copy of the attribution notices contained
      +          within such NOTICE file, excluding those notices that do not
      +          pertain to any part of the Derivative Works, in at least one
      +          of the following places: within a NOTICE text file distributed
      +          as part of the Derivative Works; within the Source form or
      +          documentation, if provided along with the Derivative Works; or,
      +          within a display generated by the Derivative Works, if and
      +          wherever such third-party notices normally appear. The contents
      +          of the NOTICE file are for informational purposes only and
      +          do not modify the License. You may add Your own attribution
      +          notices within Derivative Works that You distribute, alongside
      +          or as an addendum to the NOTICE text from the Work, provided
      +          that such additional attribution notices cannot be construed
      +          as modifying the License.
      +
      +      You may add Your own copyright statement to Your modifications and
      +      may provide additional or different license terms and conditions
      +      for use, reproduction, or distribution of Your modifications, or
      +      for any such Derivative Works as a whole, provided Your use,
      +      reproduction, and distribution of the Work otherwise complies with
      +      the conditions stated in this License.
      +
      +   5. Submission of Contributions. Unless You explicitly state otherwise,
      +      any Contribution intentionally submitted for inclusion in the Work
      +      by You to the Licensor shall be under the terms and conditions of
      +      this License, without any additional terms or conditions.
      +      Notwithstanding the above, nothing herein shall supersede or modify
      +      the terms of any separate license agreement you may have executed
      +      with Licensor regarding such Contributions.
      +
      +   6. Trademarks. This License does not grant permission to use the trade
      +      names, trademarks, service marks, or product names of the Licensor,
      +      except as required for reasonable and customary use in describing the
      +      origin of the Work and reproducing the content of the NOTICE file.
      +
      +   7. Disclaimer of Warranty. Unless required by applicable law or
      +      agreed to in writing, Licensor provides the Work (and each
      +      Contributor provides its Contributions) on an "AS IS" BASIS,
      +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +      implied, including, without limitation, any warranties or conditions
      +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +      PARTICULAR PURPOSE. You are solely responsible for determining the
      +      appropriateness of using or redistributing the Work and assume any
      +      risks associated with Your exercise of permissions under this License.
      +
      +   8. Limitation of Liability. In no event and under no legal theory,
      +      whether in tort (including negligence), contract, or otherwise,
      +      unless required by applicable law (such as deliberate and grossly
      +      negligent acts) or agreed to in writing, shall any Contributor be
      +      liable to You for damages, including any direct, indirect, special,
      +      incidental, or consequential damages of any character arising as a
      +      result of this License or out of the use or inability to use the
      +      Work (including but not limited to damages for loss of goodwill,
      +      work stoppage, computer failure or malfunction, or any and all
      +      other commercial damages or losses), even if such Contributor
      +      has been advised of the possibility of such damages.
      +
      +   9. Accepting Warranty or Additional Liability. While redistributing
      +      the Work or Derivative Works thereof, You may choose to offer,
      +      and charge a fee for, acceptance of support, warranty, indemnity,
      +      or other liability obligations and/or rights consistent with this
      +      License. However, in accepting such obligations, You may act only
      +      on Your own behalf and on Your sole responsibility, not on behalf
      +      of any other Contributor, and only if You agree to indemnify,
      +      defend, and hold each Contributor harmless for any liability
      +      incurred by, or claims asserted against, such Contributor by reason
      +      of your accepting any such warranty or additional liability.
      +
      +   END OF TERMS AND CONDITIONS
      +
      +   APPENDIX: How to apply the Apache License to your work.
      +
      +      To apply the Apache License to your work, attach the following
      +      boilerplate notice, with the fields enclosed by brackets "[]"
      +      replaced with your own identifying information. (Don't include
      +      the brackets!)  The text should be enclosed in the appropriate
      +      comment syntax for the file format. We also recommend that a
      +      file or class name and description of purpose be included on the
      +      same "printed page" as the copyright notice for easier
      +      identification within third-party archives.
      +
      +   Copyright 2019 Akhil Velagapudi
      +
      +   Licensed under the Apache License, Version 2.0 (the "License");
      +   you may not use this file except in compliance with the License.
      +   You may obtain a copy of the License at
      +
      +       http://www.apache.org/licenses/LICENSE-2.0
      +
      +   Unless required by applicable law or agreed to in writing, software
      +   distributed under the License is distributed on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +   See the License for the specific language governing permissions and
      +   limitations under the License.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                       Apache License
      +                           Version 2.0, January 2004
      +                        http://www.apache.org/licenses/
      +
      +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +   1. Definitions.
      +
      +      "License" shall mean the terms and conditions for use, reproduction,
      +      and distribution as defined by Sections 1 through 9 of this document.
      +
      +      "Licensor" shall mean the copyright owner or entity authorized by
      +      the copyright owner that is granting the License.
      +
      +      "Legal Entity" shall mean the union of the acting entity and all
      +      other entities that control, are controlled by, or are under common
      +      control with that entity. For the purposes of this definition,
      +      "control" means (i) the power, direct or indirect, to cause the
      +      direction or management of such entity, whether by contract or
      +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +      outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +      "You" (or "Your") shall mean an individual or Legal Entity
      +      exercising permissions granted by this License.
      +
      +      "Source" form shall mean the preferred form for making modifications,
      +      including but not limited to software source code, documentation
      +      source, and configuration files.
      +
      +      "Object" form shall mean any form resulting from mechanical
      +      transformation or translation of a Source form, including but
      +      not limited to compiled object code, generated documentation,
      +      and conversions to other media types.
      +
      +      "Work" shall mean the work of authorship, whether in Source or
      +      Object form, made available under the License, as indicated by a
      +      copyright notice that is included in or attached to the work
      +      (an example is provided in the Appendix below).
      +
      +      "Derivative Works" shall mean any work, whether in Source or Object
      +      form, that is based on (or derived from) the Work and for which the
      +      editorial revisions, annotations, elaborations, or other modifications
      +      represent, as a whole, an original work of authorship. For the purposes
      +      of this License, Derivative Works shall not include works that remain
      +      separable from, or merely link (or bind by name) to the interfaces of,
      +      the Work and Derivative Works thereof.
      +
      +      "Contribution" shall mean any work of authorship, including
      +      the original version of the Work and any modifications or additions
      +      to that Work or Derivative Works thereof, that is intentionally
      +      submitted to Licensor for inclusion in the Work by the copyright owner
      +      or by an individual or Legal Entity authorized to submit on behalf of
      +      the copyright owner. For the purposes of this definition, "submitted"
      +      means any form of electronic, verbal, or written communication sent
      +      to the Licensor or its representatives, including but not limited to
      +      communication on electronic mailing lists, source code control systems,
      +      and issue tracking systems that are managed by, or on behalf of, the
      +      Licensor for the purpose of discussing and improving the Work, but
      +      excluding communication that is conspicuously marked or otherwise
      +      designated in writing by the copyright owner as "Not a Contribution."
      +
      +      "Contributor" shall mean Licensor and any individual or Legal Entity
      +      on behalf of whom a Contribution has been received by Licensor and
      +      subsequently incorporated within the Work.
      +
      +   2. Grant of Copyright License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      copyright license to reproduce, prepare Derivative Works of,
      +      publicly display, publicly perform, sublicense, and distribute the
      +      Work and such Derivative Works in Source or Object form.
      +
      +   3. Grant of Patent License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      (except as stated in this section) patent license to make, have made,
      +      use, offer to sell, sell, import, and otherwise transfer the Work,
      +      where such license applies only to those patent claims licensable
      +      by such Contributor that are necessarily infringed by their
      +      Contribution(s) alone or by combination of their Contribution(s)
      +      with the Work to which such Contribution(s) was submitted. If You
      +      institute patent litigation against any entity (including a
      +      cross-claim or counterclaim in a lawsuit) alleging that the Work
      +      or a Contribution incorporated within the Work constitutes direct
      +      or contributory patent infringement, then any patent licenses
      +      granted to You under this License for that Work shall terminate
      +      as of the date such litigation is filed.
      +
      +   4. Redistribution. You may reproduce and distribute copies of the
      +      Work or Derivative Works thereof in any medium, with or without
      +      modifications, and in Source or Object form, provided that You
      +      meet the following conditions:
      +
      +      (a) You must give any other recipients of the Work or
      +          Derivative Works a copy of this License; and
      +
      +      (b) You must cause any modified files to carry prominent notices
      +          stating that You changed the files; and
      +
      +      (c) You must retain, in the Source form of any Derivative Works
      +          that You distribute, all copyright, patent, trademark, and
      +          attribution notices from the Source form of the Work,
      +          excluding those notices that do not pertain to any part of
      +          the Derivative Works; and
      +
      +      (d) If the Work includes a "NOTICE" text file as part of its
      +          distribution, then any Derivative Works that You distribute must
      +          include a readable copy of the attribution notices contained
      +          within such NOTICE file, excluding those notices that do not
      +          pertain to any part of the Derivative Works, in at least one
      +          of the following places: within a NOTICE text file distributed
      +          as part of the Derivative Works; within the Source form or
      +          documentation, if provided along with the Derivative Works; or,
      +          within a display generated by the Derivative Works, if and
      +          wherever such third-party notices normally appear. The contents
      +          of the NOTICE file are for informational purposes only and
      +          do not modify the License. You may add Your own attribution
      +          notices within Derivative Works that You distribute, alongside
      +          or as an addendum to the NOTICE text from the Work, provided
      +          that such additional attribution notices cannot be construed
      +          as modifying the License.
      +
      +      You may add Your own copyright statement to Your modifications and
      +      may provide additional or different license terms and conditions
      +      for use, reproduction, or distribution of Your modifications, or
      +      for any such Derivative Works as a whole, provided Your use,
      +      reproduction, and distribution of the Work otherwise complies with
      +      the conditions stated in this License.
      +
      +   5. Submission of Contributions. Unless You explicitly state otherwise,
      +      any Contribution intentionally submitted for inclusion in the Work
      +      by You to the Licensor shall be under the terms and conditions of
      +      this License, without any additional terms or conditions.
      +      Notwithstanding the above, nothing herein shall supersede or modify
      +      the terms of any separate license agreement you may have executed
      +      with Licensor regarding such Contributions.
      +
      +   6. Trademarks. This License does not grant permission to use the trade
      +      names, trademarks, service marks, or product names of the Licensor,
      +      except as required for reasonable and customary use in describing the
      +      origin of the Work and reproducing the content of the NOTICE file.
      +
      +   7. Disclaimer of Warranty. Unless required by applicable law or
      +      agreed to in writing, Licensor provides the Work (and each
      +      Contributor provides its Contributions) on an "AS IS" BASIS,
      +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +      implied, including, without limitation, any warranties or conditions
      +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +      PARTICULAR PURPOSE. You are solely responsible for determining the
      +      appropriateness of using or redistributing the Work and assume any
      +      risks associated with Your exercise of permissions under this License.
      +
      +   8. Limitation of Liability. In no event and under no legal theory,
      +      whether in tort (including negligence), contract, or otherwise,
      +      unless required by applicable law (such as deliberate and grossly
      +      negligent acts) or agreed to in writing, shall any Contributor be
      +      liable to You for damages, including any direct, indirect, special,
      +      incidental, or consequential damages of any character arising as a
      +      result of this License or out of the use or inability to use the
      +      Work (including but not limited to damages for loss of goodwill,
      +      work stoppage, computer failure or malfunction, or any and all
      +      other commercial damages or losses), even if such Contributor
      +      has been advised of the possibility of such damages.
      +
      +   9. Accepting Warranty or Additional Liability. While redistributing
      +      the Work or Derivative Works thereof, You may choose to offer,
      +      and charge a fee for, acceptance of support, warranty, indemnity,
      +      or other liability obligations and/or rights consistent with this
      +      License. However, in accepting such obligations, You may act only
      +      on Your own behalf and on Your sole responsibility, not on behalf
      +      of any other Contributor, and only if You agree to indemnify,
      +      defend, and hold each Contributor harmless for any liability
      +      incurred by, or claims asserted against, such Contributor by reason
      +      of your accepting any such warranty or additional liability.
      +
      +   END OF TERMS AND CONDITIONS
      +
      +   APPENDIX: How to apply the Apache License to your work.
      +
      +      To apply the Apache License to your work, attach the following
      +      boilerplate notice, with the fields enclosed by brackets "[]"
      +      replaced with your own identifying information. (Don't include
      +      the brackets!)  The text should be enclosed in the appropriate
      +      comment syntax for the file format. We also recommend that a
      +      file or class name and description of purpose be included on the
      +      same "printed page" as the copyright notice for easier
      +      identification within third-party archives.
      +
      +   Copyright 2020 - 2026 Tatsuya Kawano
      +
      +   Licensed under the Apache License, Version 2.0 (the "License");
      +   you may not use this file except in compliance with the License.
      +   You may obtain a copy of the License at
      +
      +       http://www.apache.org/licenses/LICENSE-2.0
      +
      +   Unless required by applicable law or agreed to in writing, software
      +   distributed under the License is distributed on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +   See the License for the specific language governing permissions and
      +   limitations under the License.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                       Apache License
      +                           Version 2.0, January 2004
      +                        http://www.apache.org/licenses/
      +
      +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +   1. Definitions.
      +
      +      "License" shall mean the terms and conditions for use, reproduction,
      +      and distribution as defined by Sections 1 through 9 of this document.
      +
      +      "Licensor" shall mean the copyright owner or entity authorized by
      +      the copyright owner that is granting the License.
      +
      +      "Legal Entity" shall mean the union of the acting entity and all
      +      other entities that control, are controlled by, or are under common
      +      control with that entity. For the purposes of this definition,
      +      "control" means (i) the power, direct or indirect, to cause the
      +      direction or management of such entity, whether by contract or
      +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +      outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +      "You" (or "Your") shall mean an individual or Legal Entity
      +      exercising permissions granted by this License.
      +
      +      "Source" form shall mean the preferred form for making modifications,
      +      including but not limited to software source code, documentation
      +      source, and configuration files.
      +
      +      "Object" form shall mean any form resulting from mechanical
      +      transformation or translation of a Source form, including but
      +      not limited to compiled object code, generated documentation,
      +      and conversions to other media types.
      +
      +      "Work" shall mean the work of authorship, whether in Source or
      +      Object form, made available under the License, as indicated by a
      +      copyright notice that is included in or attached to the work
      +      (an example is provided in the Appendix below).
      +
      +      "Derivative Works" shall mean any work, whether in Source or Object
      +      form, that is based on (or derived from) the Work and for which the
      +      editorial revisions, annotations, elaborations, or other modifications
      +      represent, as a whole, an original work of authorship. For the purposes
      +      of this License, Derivative Works shall not include works that remain
      +      separable from, or merely link (or bind by name) to the interfaces of,
      +      the Work and Derivative Works thereof.
      +
      +      "Contribution" shall mean any work of authorship, including
      +      the original version of the Work and any modifications or additions
      +      to that Work or Derivative Works thereof, that is intentionally
      +      submitted to Licensor for inclusion in the Work by the copyright owner
      +      or by an individual or Legal Entity authorized to submit on behalf of
      +      the copyright owner. For the purposes of this definition, "submitted"
      +      means any form of electronic, verbal, or written communication sent
      +      to the Licensor or its representatives, including but not limited to
      +      communication on electronic mailing lists, source code control systems,
      +      and issue tracking systems that are managed by, or on behalf of, the
      +      Licensor for the purpose of discussing and improving the Work, but
      +      excluding communication that is conspicuously marked or otherwise
      +      designated in writing by the copyright owner as "Not a Contribution."
      +
      +      "Contributor" shall mean Licensor and any individual or Legal Entity
      +      on behalf of whom a Contribution has been received by Licensor and
      +      subsequently incorporated within the Work.
      +
      +   2. Grant of Copyright License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      copyright license to reproduce, prepare Derivative Works of,
      +      publicly display, publicly perform, sublicense, and distribute the
      +      Work and such Derivative Works in Source or Object form.
      +
      +   3. Grant of Patent License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      (except as stated in this section) patent license to make, have made,
      +      use, offer to sell, sell, import, and otherwise transfer the Work,
      +      where such license applies only to those patent claims licensable
      +      by such Contributor that are necessarily infringed by their
      +      Contribution(s) alone or by combination of their Contribution(s)
      +      with the Work to which such Contribution(s) was submitted. If You
      +      institute patent litigation against any entity (including a
      +      cross-claim or counterclaim in a lawsuit) alleging that the Work
      +      or a Contribution incorporated within the Work constitutes direct
      +      or contributory patent infringement, then any patent licenses
      +      granted to You under this License for that Work shall terminate
      +      as of the date such litigation is filed.
      +
      +   4. Redistribution. You may reproduce and distribute copies of the
      +      Work or Derivative Works thereof in any medium, with or without
      +      modifications, and in Source or Object form, provided that You
      +      meet the following conditions:
      +
      +      (a) You must give any other recipients of the Work or
      +          Derivative Works a copy of this License; and
      +
      +      (b) You must cause any modified files to carry prominent notices
      +          stating that You changed the files; and
      +
      +      (c) You must retain, in the Source form of any Derivative Works
      +          that You distribute, all copyright, patent, trademark, and
      +          attribution notices from the Source form of the Work,
      +          excluding those notices that do not pertain to any part of
      +          the Derivative Works; and
      +
      +      (d) If the Work includes a "NOTICE" text file as part of its
      +          distribution, then any Derivative Works that You distribute must
      +          include a readable copy of the attribution notices contained
      +          within such NOTICE file, excluding those notices that do not
      +          pertain to any part of the Derivative Works, in at least one
      +          of the following places: within a NOTICE text file distributed
      +          as part of the Derivative Works; within the Source form or
      +          documentation, if provided along with the Derivative Works; or,
      +          within a display generated by the Derivative Works, if and
      +          wherever such third-party notices normally appear. The contents
      +          of the NOTICE file are for informational purposes only and
      +          do not modify the License. You may add Your own attribution
      +          notices within Derivative Works that You distribute, alongside
      +          or as an addendum to the NOTICE text from the Work, provided
      +          that such additional attribution notices cannot be construed
      +          as modifying the License.
      +
      +      You may add Your own copyright statement to Your modifications and
      +      may provide additional or different license terms and conditions
      +      for use, reproduction, or distribution of Your modifications, or
      +      for any such Derivative Works as a whole, provided Your use,
      +      reproduction, and distribution of the Work otherwise complies with
      +      the conditions stated in this License.
      +
      +   5. Submission of Contributions. Unless You explicitly state otherwise,
      +      any Contribution intentionally submitted for inclusion in the Work
      +      by You to the Licensor shall be under the terms and conditions of
      +      this License, without any additional terms or conditions.
      +      Notwithstanding the above, nothing herein shall supersede or modify
      +      the terms of any separate license agreement you may have executed
      +      with Licensor regarding such Contributions.
      +
      +   6. Trademarks. This License does not grant permission to use the trade
      +      names, trademarks, service marks, or product names of the Licensor,
      +      except as required for reasonable and customary use in describing the
      +      origin of the Work and reproducing the content of the NOTICE file.
      +
      +   7. Disclaimer of Warranty. Unless required by applicable law or
      +      agreed to in writing, Licensor provides the Work (and each
      +      Contributor provides its Contributions) on an "AS IS" BASIS,
      +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +      implied, including, without limitation, any warranties or conditions
      +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +      PARTICULAR PURPOSE. You are solely responsible for determining the
      +      appropriateness of using or redistributing the Work and assume any
      +      risks associated with Your exercise of permissions under this License.
      +
      +   8. Limitation of Liability. In no event and under no legal theory,
      +      whether in tort (including negligence), contract, or otherwise,
      +      unless required by applicable law (such as deliberate and grossly
      +      negligent acts) or agreed to in writing, shall any Contributor be
      +      liable to You for damages, including any direct, indirect, special,
      +      incidental, or consequential damages of any character arising as a
      +      result of this License or out of the use or inability to use the
      +      Work (including but not limited to damages for loss of goodwill,
      +      work stoppage, computer failure or malfunction, or any and all
      +      other commercial damages or losses), even if such Contributor
      +      has been advised of the possibility of such damages.
      +
      +   9. Accepting Warranty or Additional Liability. While redistributing
      +      the Work or Derivative Works thereof, You may choose to offer,
      +      and charge a fee for, acceptance of support, warranty, indemnity,
      +      or other liability obligations and/or rights consistent with this
      +      License. However, in accepting such obligations, You may act only
      +      on Your own behalf and on Your sole responsibility, not on behalf
      +      of any other Contributor, and only if You agree to indemnify,
      +      defend, and hold each Contributor harmless for any liability
      +      incurred by, or claims asserted against, such Contributor by reason
      +      of your accepting any such warranty or additional liability.
      +
      +   END OF TERMS AND CONDITIONS
      +
      +   APPENDIX: How to apply the Apache License to your work.
      +
      +      To apply the Apache License to your work, attach the following
      +      boilerplate notice, with the fields enclosed by brackets "[]"
      +      replaced with your own identifying information. (Don't include
      +      the brackets!)  The text should be enclosed in the appropriate
      +      comment syntax for the file format. We also recommend that a
      +      file or class name and description of purpose be included on the
      +      same "printed page" as the copyright notice for easier
      +      identification within third-party archives.
      +
      +   Copyright 2023 The Fuchsia Authors
      +
      +   Licensed under the Apache License, Version 2.0 (the "License");
      +   you may not use this file except in compliance with the License.
      +   You may obtain a copy of the License at
      +
      +       http://www.apache.org/licenses/LICENSE-2.0
      +
      +   Unless required by applicable law or agreed to in writing, software
      +   distributed under the License is distributed on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +   See the License for the specific language governing permissions and
      +   limitations under the License.
      +
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                       Apache License
      +                           Version 2.0, January 2004
      +                        http://www.apache.org/licenses/
      +
      +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +   1. Definitions.
      +
      +      "License" shall mean the terms and conditions for use, reproduction,
      +      and distribution as defined by Sections 1 through 9 of this document.
      +
      +      "Licensor" shall mean the copyright owner or entity authorized by
      +      the copyright owner that is granting the License.
      +
      +      "Legal Entity" shall mean the union of the acting entity and all
      +      other entities that control, are controlled by, or are under common
      +      control with that entity. For the purposes of this definition,
      +      "control" means (i) the power, direct or indirect, to cause the
      +      direction or management of such entity, whether by contract or
      +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +      outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +      "You" (or "Your") shall mean an individual or Legal Entity
      +      exercising permissions granted by this License.
      +
      +      "Source" form shall mean the preferred form for making modifications,
      +      including but not limited to software source code, documentation
      +      source, and configuration files.
      +
      +      "Object" form shall mean any form resulting from mechanical
      +      transformation or translation of a Source form, including but
      +      not limited to compiled object code, generated documentation,
      +      and conversions to other media types.
      +
      +      "Work" shall mean the work of authorship, whether in Source or
      +      Object form, made available under the License, as indicated by a
      +      copyright notice that is included in or attached to the work
      +      (an example is provided in the Appendix below).
      +
      +      "Derivative Works" shall mean any work, whether in Source or Object
      +      form, that is based on (or derived from) the Work and for which the
      +      editorial revisions, annotations, elaborations, or other modifications
      +      represent, as a whole, an original work of authorship. For the purposes
      +      of this License, Derivative Works shall not include works that remain
      +      separable from, or merely link (or bind by name) to the interfaces of,
      +      the Work and Derivative Works thereof.
      +
      +      "Contribution" shall mean any work of authorship, including
      +      the original version of the Work and any modifications or additions
      +      to that Work or Derivative Works thereof, that is intentionally
      +      submitted to Licensor for inclusion in the Work by the copyright owner
      +      or by an individual or Legal Entity authorized to submit on behalf of
      +      the copyright owner. For the purposes of this definition, "submitted"
      +      means any form of electronic, verbal, or written communication sent
      +      to the Licensor or its representatives, including but not limited to
      +      communication on electronic mailing lists, source code control systems,
      +      and issue tracking systems that are managed by, or on behalf of, the
      +      Licensor for the purpose of discussing and improving the Work, but
      +      excluding communication that is conspicuously marked or otherwise
      +      designated in writing by the copyright owner as "Not a Contribution."
      +
      +      "Contributor" shall mean Licensor and any individual or Legal Entity
      +      on behalf of whom a Contribution has been received by Licensor and
      +      subsequently incorporated within the Work.
      +
      +   2. Grant of Copyright License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      copyright license to reproduce, prepare Derivative Works of,
      +      publicly display, publicly perform, sublicense, and distribute the
      +      Work and such Derivative Works in Source or Object form.
      +
      +   3. Grant of Patent License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      (except as stated in this section) patent license to make, have made,
      +      use, offer to sell, sell, import, and otherwise transfer the Work,
      +      where such license applies only to those patent claims licensable
      +      by such Contributor that are necessarily infringed by their
      +      Contribution(s) alone or by combination of their Contribution(s)
      +      with the Work to which such Contribution(s) was submitted. If You
      +      institute patent litigation against any entity (including a
      +      cross-claim or counterclaim in a lawsuit) alleging that the Work
      +      or a Contribution incorporated within the Work constitutes direct
      +      or contributory patent infringement, then any patent licenses
      +      granted to You under this License for that Work shall terminate
      +      as of the date such litigation is filed.
      +
      +   4. Redistribution. You may reproduce and distribute copies of the
      +      Work or Derivative Works thereof in any medium, with or without
      +      modifications, and in Source or Object form, provided that You
      +      meet the following conditions:
      +
      +      (a) You must give any other recipients of the Work or
      +          Derivative Works a copy of this License; and
      +
      +      (b) You must cause any modified files to carry prominent notices
      +          stating that You changed the files; and
      +
      +      (c) You must retain, in the Source form of any Derivative Works
      +          that You distribute, all copyright, patent, trademark, and
      +          attribution notices from the Source form of the Work,
      +          excluding those notices that do not pertain to any part of
      +          the Derivative Works; and
      +
      +      (d) If the Work includes a "NOTICE" text file as part of its
      +          distribution, then any Derivative Works that You distribute must
      +          include a readable copy of the attribution notices contained
      +          within such NOTICE file, excluding those notices that do not
      +          pertain to any part of the Derivative Works, in at least one
      +          of the following places: within a NOTICE text file distributed
      +          as part of the Derivative Works; within the Source form or
      +          documentation, if provided along with the Derivative Works; or,
      +          within a display generated by the Derivative Works, if and
      +          wherever such third-party notices normally appear. The contents
      +          of the NOTICE file are for informational purposes only and
      +          do not modify the License. You may add Your own attribution
      +          notices within Derivative Works that You distribute, alongside
      +          or as an addendum to the NOTICE text from the Work, provided
      +          that such additional attribution notices cannot be construed
      +          as modifying the License.
      +
      +      You may add Your own copyright statement to Your modifications and
      +      may provide additional or different license terms and conditions
      +      for use, reproduction, or distribution of Your modifications, or
      +      for any such Derivative Works as a whole, provided Your use,
      +      reproduction, and distribution of the Work otherwise complies with
      +      the conditions stated in this License.
      +
      +   5. Submission of Contributions. Unless You explicitly state otherwise,
      +      any Contribution intentionally submitted for inclusion in the Work
      +      by You to the Licensor shall be under the terms and conditions of
      +      this License, without any additional terms or conditions.
      +      Notwithstanding the above, nothing herein shall supersede or modify
      +      the terms of any separate license agreement you may have executed
      +      with Licensor regarding such Contributions.
      +
      +   6. Trademarks. This License does not grant permission to use the trade
      +      names, trademarks, service marks, or product names of the Licensor,
      +      except as required for reasonable and customary use in describing the
      +      origin of the Work and reproducing the content of the NOTICE file.
      +
      +   7. Disclaimer of Warranty. Unless required by applicable law or
      +      agreed to in writing, Licensor provides the Work (and each
      +      Contributor provides its Contributions) on an "AS IS" BASIS,
      +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +      implied, including, without limitation, any warranties or conditions
      +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +      PARTICULAR PURPOSE. You are solely responsible for determining the
      +      appropriateness of using or redistributing the Work and assume any
      +      risks associated with Your exercise of permissions under this License.
      +
      +   8. Limitation of Liability. In no event and under no legal theory,
      +      whether in tort (including negligence), contract, or otherwise,
      +      unless required by applicable law (such as deliberate and grossly
      +      negligent acts) or agreed to in writing, shall any Contributor be
      +      liable to You for damages, including any direct, indirect, special,
      +      incidental, or consequential damages of any character arising as a
      +      result of this License or out of the use or inability to use the
      +      Work (including but not limited to damages for loss of goodwill,
      +      work stoppage, computer failure or malfunction, or any and all
      +      other commercial damages or losses), even if such Contributor
      +      has been advised of the possibility of such damages.
      +
      +   9. Accepting Warranty or Additional Liability. While redistributing
      +      the Work or Derivative Works thereof, You may choose to offer,
      +      and charge a fee for, acceptance of support, warranty, indemnity,
      +      or other liability obligations and/or rights consistent with this
      +      License. However, in accepting such obligations, You may act only
      +      on Your own behalf and on Your sole responsibility, not on behalf
      +      of any other Contributor, and only if You agree to indemnify,
      +      defend, and hold each Contributor harmless for any liability
      +      incurred by, or claims asserted against, such Contributor by reason
      +      of your accepting any such warranty or additional liability.
      +
      +   END OF TERMS AND CONDITIONS
      +
      +   APPENDIX: How to apply the Apache License to your work.
      +
      +      To apply the Apache License to your work, attach the following
      +      boilerplate notice, with the fields enclosed by brackets "{}"
      +      replaced with your own identifying information. (Don't include
      +      the brackets!)  The text should be enclosed in the appropriate
      +      comment syntax for the file format. We also recommend that a
      +      file or class name and description of purpose be included on the
      +      same "printed page" as the copyright notice for easier
      +      identification within third-party archives.
      +
      +   Copyright Individual contributors
      +
      +   Licensed under the Apache License, Version 2.0 (the "License");
      +   you may not use this file except in compliance with the License.
      +   You may obtain a copy of the License at
      +
      +       http://www.apache.org/licenses/LICENSE-2.0
      +
      +   Unless required by applicable law or agreed to in writing, software
      +   distributed under the License is distributed on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +   See the License for the specific language governing permissions and
      +   limitations under the License.
      +
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                       Apache License
      +                           Version 2.0, January 2004
      +                        http://www.apache.org/licenses/
      +
      +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +   1. Definitions.
      +
      +      "License" shall mean the terms and conditions for use, reproduction,
      +      and distribution as defined by Sections 1 through 9 of this document.
      +
      +      "Licensor" shall mean the copyright owner or entity authorized by
      +      the copyright owner that is granting the License.
      +
      +      "Legal Entity" shall mean the union of the acting entity and all
      +      other entities that control, are controlled by, or are under common
      +      control with that entity. For the purposes of this definition,
      +      "control" means (i) the power, direct or indirect, to cause the
      +      direction or management of such entity, whether by contract or
      +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +      outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +      "You" (or "Your") shall mean an individual or Legal Entity
      +      exercising permissions granted by this License.
      +
      +      "Source" form shall mean the preferred form for making modifications,
      +      including but not limited to software source code, documentation
      +      source, and configuration files.
      +
      +      "Object" form shall mean any form resulting from mechanical
      +      transformation or translation of a Source form, including but
      +      not limited to compiled object code, generated documentation,
      +      and conversions to other media types.
      +
      +      "Work" shall mean the work of authorship, whether in Source or
      +      Object form, made available under the License, as indicated by a
      +      copyright notice that is included in or attached to the work
      +      (an example is provided in the Appendix below).
      +
      +      "Derivative Works" shall mean any work, whether in Source or Object
      +      form, that is based on (or derived from) the Work and for which the
      +      editorial revisions, annotations, elaborations, or other modifications
      +      represent, as a whole, an original work of authorship. For the purposes
      +      of this License, Derivative Works shall not include works that remain
      +      separable from, or merely link (or bind by name) to the interfaces of,
      +      the Work and Derivative Works thereof.
      +
      +      "Contribution" shall mean any work of authorship, including
      +      the original version of the Work and any modifications or additions
      +      to that Work or Derivative Works thereof, that is intentionally
      +      submitted to Licensor for inclusion in the Work by the copyright owner
      +      or by an individual or Legal Entity authorized to submit on behalf of
      +      the copyright owner. For the purposes of this definition, "submitted"
      +      means any form of electronic, verbal, or written communication sent
      +      to the Licensor or its representatives, including but not limited to
      +      communication on electronic mailing lists, source code control systems,
      +      and issue tracking systems that are managed by, or on behalf of, the
      +      Licensor for the purpose of discussing and improving the Work, but
      +      excluding communication that is conspicuously marked or otherwise
      +      designated in writing by the copyright owner as "Not a Contribution."
      +
      +      "Contributor" shall mean Licensor and any individual or Legal Entity
      +      on behalf of whom a Contribution has been received by Licensor and
      +      subsequently incorporated within the Work.
      +
      +   2. Grant of Copyright License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      copyright license to reproduce, prepare Derivative Works of,
      +      publicly display, publicly perform, sublicense, and distribute the
      +      Work and such Derivative Works in Source or Object form.
      +
      +   3. Grant of Patent License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      (except as stated in this section) patent license to make, have made,
      +      use, offer to sell, sell, import, and otherwise transfer the Work,
      +      where such license applies only to those patent claims licensable
      +      by such Contributor that are necessarily infringed by their
      +      Contribution(s) alone or by combination of their Contribution(s)
      +      with the Work to which such Contribution(s) was submitted. If You
      +      institute patent litigation against any entity (including a
      +      cross-claim or counterclaim in a lawsuit) alleging that the Work
      +      or a Contribution incorporated within the Work constitutes direct
      +      or contributory patent infringement, then any patent licenses
      +      granted to You under this License for that Work shall terminate
      +      as of the date such litigation is filed.
      +
      +   4. Redistribution. You may reproduce and distribute copies of the
      +      Work or Derivative Works thereof in any medium, with or without
      +      modifications, and in Source or Object form, provided that You
      +      meet the following conditions:
      +
      +      (a) You must give any other recipients of the Work or
      +          Derivative Works a copy of this License; and
      +
      +      (b) You must cause any modified files to carry prominent notices
      +          stating that You changed the files; and
      +
      +      (c) You must retain, in the Source form of any Derivative Works
      +          that You distribute, all copyright, patent, trademark, and
      +          attribution notices from the Source form of the Work,
      +          excluding those notices that do not pertain to any part of
      +          the Derivative Works; and
      +
      +      (d) If the Work includes a "NOTICE" text file as part of its
      +          distribution, then any Derivative Works that You distribute must
      +          include a readable copy of the attribution notices contained
      +          within such NOTICE file, excluding those notices that do not
      +          pertain to any part of the Derivative Works, in at least one
      +          of the following places: within a NOTICE text file distributed
      +          as part of the Derivative Works; within the Source form or
      +          documentation, if provided along with the Derivative Works; or,
      +          within a display generated by the Derivative Works, if and
      +          wherever such third-party notices normally appear. The contents
      +          of the NOTICE file are for informational purposes only and
      +          do not modify the License. You may add Your own attribution
      +          notices within Derivative Works that You distribute, alongside
      +          or as an addendum to the NOTICE text from the Work, provided
      +          that such additional attribution notices cannot be construed
      +          as modifying the License.
      +
      +      You may add Your own copyright statement to Your modifications and
      +      may provide additional or different license terms and conditions
      +      for use, reproduction, or distribution of Your modifications, or
      +      for any such Derivative Works as a whole, provided Your use,
      +      reproduction, and distribution of the Work otherwise complies with
      +      the conditions stated in this License.
      +
      +   5. Submission of Contributions. Unless You explicitly state otherwise,
      +      any Contribution intentionally submitted for inclusion in the Work
      +      by You to the Licensor shall be under the terms and conditions of
      +      this License, without any additional terms or conditions.
      +      Notwithstanding the above, nothing herein shall supersede or modify
      +      the terms of any separate license agreement you may have executed
      +      with Licensor regarding such Contributions.
      +
      +   6. Trademarks. This License does not grant permission to use the trade
      +      names, trademarks, service marks, or product names of the Licensor,
      +      except as required for reasonable and customary use in describing the
      +      origin of the Work and reproducing the content of the NOTICE file.
      +
      +   7. Disclaimer of Warranty. Unless required by applicable law or
      +      agreed to in writing, Licensor provides the Work (and each
      +      Contributor provides its Contributions) on an "AS IS" BASIS,
      +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +      implied, including, without limitation, any warranties or conditions
      +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +      PARTICULAR PURPOSE. You are solely responsible for determining the
      +      appropriateness of using or redistributing the Work and assume any
      +      risks associated with Your exercise of permissions under this License.
      +
      +   8. Limitation of Liability. In no event and under no legal theory,
      +      whether in tort (including negligence), contract, or otherwise,
      +      unless required by applicable law (such as deliberate and grossly
      +      negligent acts) or agreed to in writing, shall any Contributor be
      +      liable to You for damages, including any direct, indirect, special,
      +      incidental, or consequential damages of any character arising as a
      +      result of this License or out of the use or inability to use the
      +      Work (including but not limited to damages for loss of goodwill,
      +      work stoppage, computer failure or malfunction, or any and all
      +      other commercial damages or losses), even if such Contributor
      +      has been advised of the possibility of such damages.
      +
      +   9. Accepting Warranty or Additional Liability. While redistributing
      +      the Work or Derivative Works thereof, You may choose to offer,
      +      and charge a fee for, acceptance of support, warranty, indemnity,
      +      or other liability obligations and/or rights consistent with this
      +      License. However, in accepting such obligations, You may act only
      +      on Your own behalf and on Your sole responsibility, not on behalf
      +      of any other Contributor, and only if You agree to indemnify,
      +      defend, and hold each Contributor harmless for any liability
      +      incurred by, or claims asserted against, such Contributor by reason
      +      of your accepting any such warranty or additional liability.
      +
      +   END OF TERMS AND CONDITIONS
      +
      +   APPENDIX: How to apply the Apache License to your work.
      +
      +      To apply the Apache License to your work, attach the following
      +      boilerplate notice, with the fields enclosed by brackets "{}"
      +      replaced with your own identifying information. (Don't include
      +      the brackets!)  The text should be enclosed in the appropriate
      +      comment syntax for the file format. We also recommend that a
      +      file or class name and description of purpose be included on the
      +      same "printed page" as the copyright notice for easier
      +      identification within third-party archives.
      +
      +   Copyright {yyyy} {name of copyright owner}
      +
      +   Licensed under the Apache License, Version 2.0 (the "License");
      +   you may not use this file except in compliance with the License.
      +   You may obtain a copy of the License at
      +
      +       http://www.apache.org/licenses/LICENSE-2.0
      +
      +   Unless required by applicable law or agreed to in writing, software
      +   distributed under the License is distributed on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +   See the License for the specific language governing permissions and
      +   limitations under the License.
      +
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0 January 2004
      +                     http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright [yyyy] [name of copyright owner]
      +
      +Licensed under the Apache License, Version 2.0 (the "License");
      +you may not use this file except in compliance with the License.
      +You may obtain a copy of the License at
      +
      +	http://www.apache.org/licenses/LICENSE-2.0
      +
      +Unless required by applicable law or agreed to in writing, software
      +distributed under the License is distributed on an "AS IS" BASIS,
      +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +See the License for the specific language governing permissions and
      +limitations under the License.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright (c) 2016 Alex Crichton
      +Copyright (c) 2017 The Tokio Authors
      +
      +Licensed under the Apache License, Version 2.0 (the "License");
      +you may not use this file except in compliance with the License.
      +You may obtain a copy of the License at
      +
      +	http://www.apache.org/licenses/LICENSE-2.0
      +
      +Unless required by applicable law or agreed to in writing, software
      +distributed under the License is distributed on an "AS IS" BASIS,
      +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +See the License for the specific language governing permissions and
      +limitations under the License.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright (c) 2019 Matthias Einwag
      +
      +Licensed under the Apache License, Version 2.0 (the "License");
      +you may not use this file except in compliance with the License.
      +You may obtain a copy of the License at
      +
      +	http://www.apache.org/licenses/LICENSE-2.0
      +
      +Unless required by applicable law or agreed to in writing, software
      +distributed under the License is distributed on an "AS IS" BASIS,
      +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +See the License for the specific language governing permissions and
      +limitations under the License.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright 2014 Paho Lurie-Gregg
      +
      +Licensed under the Apache License, Version 2.0 (the "License");
      +you may not use this file except in compliance with the License.
      +You may obtain a copy of the License at
      +
      +	http://www.apache.org/licenses/LICENSE-2.0
      +
      +Unless required by applicable law or agreed to in writing, software
      +distributed under the License is distributed on an "AS IS" BASIS,
      +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +See the License for the specific language governing permissions and
      +limitations under the License.
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright 2017 The Rust Project Developers
      +
      +Licensed under the Apache License, Version 2.0 (the "License");
      +you may not use this file except in compliance with the License.
      +You may obtain a copy of the License at
      +
      +	http://www.apache.org/licenses/LICENSE-2.0
      +
      +Unless required by applicable law or agreed to in writing, software
      +distributed under the License is distributed on an "AS IS" BASIS,
      +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +See the License for the specific language governing permissions and
      +limitations under the License.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright 2017 http-rs authors
      +
      +Licensed under the Apache License, Version 2.0 (the "License");
      +you may not use this file except in compliance with the License.
      +You may obtain a copy of the License at
      +
      +	http://www.apache.org/licenses/LICENSE-2.0
      +
      +Unless required by applicable law or agreed to in writing, software
      +distributed under the License is distributed on an "AS IS" BASIS,
      +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +See the License for the specific language governing permissions and
      +limitations under the License.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright 2017 quininer kel
      +
      +Licensed under the Apache License, Version 2.0 (the "License");
      +you may not use this file except in compliance with the License.
      +You may obtain a copy of the License at
      +
      +	http://www.apache.org/licenses/LICENSE-2.0
      +
      +Unless required by applicable law or agreed to in writing, software
      +distributed under the License is distributed on an "AS IS" BASIS,
      +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +See the License for the specific language governing permissions and
      +limitations under the License.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright 2019 The CryptoCorrosion Contributors
      +
      +Licensed under the Apache License, Version 2.0 (the "License");
      +you may not use this file except in compliance with the License.
      +You may obtain a copy of the License at
      +
      +   http://www.apache.org/licenses/LICENSE-2.0
      +
      +Unless required by applicable law or agreed to in writing, software
      +distributed under the License is distributed on an "AS IS" BASIS,
      +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +See the License for the specific language governing permissions and
      +limitations under the License.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright 2020 Andrew Straw
      +
      +Licensed under the Apache License, Version 2.0 (the "License");
      +you may not use this file except in compliance with the License.
      +You may obtain a copy of the License at
      +
      +	http://www.apache.org/licenses/LICENSE-2.0
      +
      +Unless required by applicable law or agreed to in writing, software
      +distributed under the License is distributed on an "AS IS" BASIS,
      +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +See the License for the specific language governing permissions and
      +limitations under the License.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright 2023 Dirkjan Ochtman
      +
      +Licensed under the Apache License, Version 2.0 (the "License");
      +you may not use this file except in compliance with the License.
      +You may obtain a copy of the License at
      +
      +	http://www.apache.org/licenses/LICENSE-2.0
      +
      +Unless required by applicable law or agreed to in writing, software
      +distributed under the License is distributed on an "AS IS" BASIS,
      +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +See the License for the specific language governing permissions and
      +limitations under the License.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright 2023 The BigDecimal-rs Contributors
      +
      +
      +Licensed under the Apache License, Version 2.0 (the "License");
      +you may not use this file except in compliance with the License.
      +You may obtain a copy of the License at
      +
      +	http://www.apache.org/licenses/LICENSE-2.0
      +
      +Unless required by applicable law or agreed to in writing, software
      +distributed under the License is distributed on an "AS IS" BASIS,
      +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +See the License for the specific language governing permissions and
      +limitations under the License.
      +
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright 2024 Liam Gray <gmail@liamg.me>
      +
      +Licensed under the Apache License, Version 2.0 (the "License");
      +you may not use this file except in compliance with the License.
      +You may obtain a copy of the License at
      +
      +	http://www.apache.org/licenses/LICENSE-2.0
      +
      +Unless required by applicable law or agreed to in writing, software
      +distributed under the License is distributed on an "AS IS" BASIS,
      +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +See the License for the specific language governing permissions and
      +limitations under the License.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright [yyyy] [name of copyright owner]
      +
      +Licensed under the Apache License, Version 2.0 (the "License");
      +you may not use this file except in compliance with the License.
      +You may obtain a copy of the License at
      +
      +	http://www.apache.org/licenses/LICENSE-2.0
      +
      +Unless required by applicable law or agreed to in writing, software
      +distributed under the License is distributed on an "AS IS" BASIS,
      +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +See the License for the specific language governing permissions and
      +limitations under the License.
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright [yyyy] [name of copyright owner]
      +
      +Licensed under the Apache License, Version 2.0 (the "License");
      +you may not use this file except in compliance with the License.
      +You may obtain a copy of the License at
      +
      +	http://www.apache.org/licenses/LICENSE-2.0
      +
      +Unless required by applicable law or agreed to in writing, software
      +distributed under the License is distributed on an "AS IS" BASIS,
      +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +See the License for the specific language governing permissions and
      +limitations under the License.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright [yyyy] [name of copyright owner]
      +
      +Licensed under the Apache License, Version 2.0 (the "License");
      +you may not use this file except in compliance with the License.
      +You may obtain a copy of the License at
      +
      +    http://www.apache.org/licenses/LICENSE-2.0
      +
      +Unless required by applicable law or agreed to in writing, software
      +distributed under the License is distributed on an "AS IS" BASIS,
      +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +See the License for the specific language governing permissions and
      +limitations under the License.
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright [yyyy] [name of copyright owner]
      +
      +Licensed under the Apache License, Version 2.0 (the "License");
      +you may not use this file except in compliance with the License.
      +You may obtain a copy of the License at
      +
      +    http://www.apache.org/licenses/LICENSE-2.0
      +
      +Unless required by applicable law or agreed to in writing, software
      +distributed under the License is distributed on an "AS IS" BASIS,
      +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +See the License for the specific language governing permissions and
      +limitations under the License.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright [yyyy] [name of copyright owner]
      +
      +Licensed under the Apache License, Version 2.0 (the "License");
      +you may not use this file except in compliance with the License.
      +You may obtain a copy of the License at
      +
      +   http://www.apache.org/licenses/LICENSE-2.0
      +
      +Unless required by applicable law or agreed to in writing, software
      +distributed under the License is distributed on an "AS IS" BASIS,
      +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +See the License for the specific language governing permissions and
      +limitations under the License.
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright [yyyy] [name of copyright owner]
      +
      +Licensed under the Apache License, Version 2.0 (the "License");
      +you may not use this file except in compliance with the License.
      +You may obtain a copy of the License at
      +
      +   http://www.apache.org/licenses/LICENSE-2.0
      +
      +Unless required by applicable law or agreed to in writing, software
      +distributed under the License is distributed on an "AS IS" BASIS,
      +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +See the License for the specific language governing permissions and
      +limitations under the License.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright [yyyy] [name of copyright owner]
      +
      +Licensed under the Apache License, Version 2.0 (the "License");
      +you may not use this file except in compliance with the License.
      +You may obtain a copy of the License at
      +
      +   http://www.apache.org/licenses/LICENSE-2.0
      +
      +Unless required by applicable law or agreed to in writing, software
      +distributed under the License is distributed on an "AS IS" BASIS,
      +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +See the License for the specific language governing permissions and
      +limitations under the License.
      +
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     https://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     https://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright [yyyy] [name of copyright owner]
      +
      +Licensed under the Apache License, Version 2.0 (the "License");
      +you may not use this file except in compliance with the License.
      +You may obtain a copy of the License at
      +
      +	https://www.apache.org/licenses/LICENSE-2.0
      +
      +Unless required by applicable law or agreed to in writing, software
      +distributed under the License is distributed on an "AS IS" BASIS,
      +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +See the License for the specific language governing permissions and
      +limitations under the License.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     https://www.apache.org/licenses/LICENSE-2.0
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright [yyyy] [name of copyright owner]
      +
      +Licensed under the Apache License, Version 2.0 (the "License");
      +you may not use this file except in compliance with the License.
      +You may obtain a copy of the License at
      +
      +	https://www.apache.org/licenses/LICENSE-2.0
      +
      +Unless required by applicable law or agreed to in writing, software
      +distributed under the License is distributed on an "AS IS" BASIS,
      +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +See the License for the specific language governing permissions and
      +limitations under the License.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright [yyyy] [name of copyright owner]
      +
      +Licensed under the Apache License, Version 2.0 (the "License");
      +you may not use this file except in compliance with the License.
      +You may obtain a copy of the License at
      +
      +	http://www.apache.org/licenses/LICENSE-2.0
      +
      +Unless required by applicable law or agreed to in writing, software
      +distributed under the License is distributed on an "AS IS" BASIS,
      +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +See the License for the specific language governing permissions and
      +limitations under the License.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
      Apache License
      +Version 2.0, January 2004
      +http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +    1. Definitions.
      +
      +        "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
      +
      +        "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
      +
      +        "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +        "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
      +
      +        "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
      +
      +        "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
      +
      +        "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
      +
      +        "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
      +
      +        "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
      +
      +        "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
      +    2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
      +    3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
      +    4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
      +        (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
      +        (b) You must cause any modified files to carry prominent notices stating that You changed the files; and
      +        (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
      +        (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
      +
      +        You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
      +    5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
      +    6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
      +    7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
      +    8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
      +    9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
      +
      +Copyright [yyyy] [name of copyright owner]
      +
      +Licensed under the Apache License, Version 2.0 (the "License");
      +you may not use this file except in compliance with the License.
      +You may obtain a copy of the License at
      +
      +http://www.apache.org/licenses/LICENSE-2.0
      +
      +Unless required by applicable law or agreed to in writing, software
      +distributed under the License is distributed on an "AS IS" BASIS,
      +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +See the License for the specific language governing permissions and
      +limitations under the License.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
      Apache License
      +Version 2.0, January 2004
      +http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +"License" shall mean the terms and conditions for use, reproduction,
      +and distribution as defined by Sections 1 through 9 of this document.
      +
      +"Licensor" shall mean the copyright owner or entity authorized by
      +the copyright owner that is granting the License.
      +
      +"Legal Entity" shall mean the union of the acting entity and all
      +other entities that control, are controlled by, or are under common
      +control with that entity. For the purposes of this definition,
      +"control" means (i) the power, direct or indirect, to cause the
      +direction or management of such entity, whether by contract or
      +otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +"You" (or "Your") shall mean an individual or Legal Entity
      +exercising permissions granted by this License.
      +
      +"Source" form shall mean the preferred form for making modifications,
      +including but not limited to software source code, documentation
      +source, and configuration files.
      +
      +"Object" form shall mean any form resulting from mechanical
      +transformation or translation of a Source form, including but
      +not limited to compiled object code, generated documentation,
      +and conversions to other media types.
      +
      +"Work" shall mean the work of authorship, whether in Source or
      +Object form, made available under the License, as indicated by a
      +copyright notice that is included in or attached to the work
      +(an example is provided in the Appendix below).
      +
      +"Derivative Works" shall mean any work, whether in Source or Object
      +form, that is based on (or derived from) the Work and for which the
      +editorial revisions, annotations, elaborations, or other modifications
      +represent, as a whole, an original work of authorship. For the purposes
      +of this License, Derivative Works shall not include works that remain
      +separable from, or merely link (or bind by name) to the interfaces of,
      +the Work and Derivative Works thereof.
      +
      +"Contribution" shall mean any work of authorship, including
      +the original version of the Work and any modifications or additions
      +to that Work or Derivative Works thereof, that is intentionally
      +submitted to Licensor for inclusion in the Work by the copyright owner
      +or by an individual or Legal Entity authorized to submit on behalf of
      +the copyright owner. For the purposes of this definition, "submitted"
      +means any form of electronic, verbal, or written communication sent
      +to the Licensor or its representatives, including but not limited to
      +communication on electronic mailing lists, source code control systems,
      +and issue tracking systems that are managed by, or on behalf of, the
      +Licensor for the purpose of discussing and improving the Work, but
      +excluding communication that is conspicuously marked or otherwise
      +designated in writing by the copyright owner as "Not a Contribution."
      +
      +"Contributor" shall mean Licensor and any individual or Legal Entity
      +on behalf of whom a Contribution has been received by Licensor and
      +subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +this License, each Contributor hereby grants to You a perpetual,
      +worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +copyright license to reproduce, prepare Derivative Works of,
      +publicly display, publicly perform, sublicense, and distribute the
      +Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +this License, each Contributor hereby grants to You a perpetual,
      +worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +(except as stated in this section) patent license to make, have made,
      +use, offer to sell, sell, import, and otherwise transfer the Work,
      +where such license applies only to those patent claims licensable
      +by such Contributor that are necessarily infringed by their
      +Contribution(s) alone or by combination of their Contribution(s)
      +with the Work to which such Contribution(s) was submitted. If You
      +institute patent litigation against any entity (including a
      +cross-claim or counterclaim in a lawsuit) alleging that the Work
      +or a Contribution incorporated within the Work constitutes direct
      +or contributory patent infringement, then any patent licenses
      +granted to You under this License for that Work shall terminate
      +as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +Work or Derivative Works thereof in any medium, with or without
      +modifications, and in Source or Object form, provided that You
      +meet the following conditions:
      +
      +(a) You must give any other recipients of the Work or
      +Derivative Works a copy of this License; and
      +
      +(b) You must cause any modified files to carry prominent notices
      +stating that You changed the files; and
      +
      +(c) You must retain, in the Source form of any Derivative Works
      +that You distribute, all copyright, patent, trademark, and
      +attribution notices from the Source form of the Work,
      +excluding those notices that do not pertain to any part of
      +the Derivative Works; and
      +
      +(d) If the Work includes a "NOTICE" text file as part of its
      +distribution, then any Derivative Works that You distribute must
      +include a readable copy of the attribution notices contained
      +within such NOTICE file, excluding those notices that do not
      +pertain to any part of the Derivative Works, in at least one
      +of the following places: within a NOTICE text file distributed
      +as part of the Derivative Works; within the Source form or
      +documentation, if provided along with the Derivative Works; or,
      +within a display generated by the Derivative Works, if and
      +wherever such third-party notices normally appear. The contents
      +of the NOTICE file are for informational purposes only and
      +do not modify the License. You may add Your own attribution
      +notices within Derivative Works that You distribute, alongside
      +or as an addendum to the NOTICE text from the Work, provided
      +that such additional attribution notices cannot be construed
      +as modifying the License.
      +
      +You may add Your own copyright statement to Your modifications and
      +may provide additional or different license terms and conditions
      +for use, reproduction, or distribution of Your modifications, or
      +for any such Derivative Works as a whole, provided Your use,
      +reproduction, and distribution of the Work otherwise complies with
      +the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +any Contribution intentionally submitted for inclusion in the Work
      +by You to the Licensor shall be under the terms and conditions of
      +this License, without any additional terms or conditions.
      +Notwithstanding the above, nothing herein shall supersede or modify
      +the terms of any separate license agreement you may have executed
      +with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +names, trademarks, service marks, or product names of the Licensor,
      +except as required for reasonable and customary use in describing the
      +origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +agreed to in writing, Licensor provides the Work (and each
      +Contributor provides its Contributions) on an "AS IS" BASIS,
      +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +implied, including, without limitation, any warranties or conditions
      +of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +PARTICULAR PURPOSE. You are solely responsible for determining the
      +appropriateness of using or redistributing the Work and assume any
      +risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +whether in tort (including negligence), contract, or otherwise,
      +unless required by applicable law (such as deliberate and grossly
      +negligent acts) or agreed to in writing, shall any Contributor be
      +liable to You for damages, including any direct, indirect, special,
      +incidental, or consequential damages of any character arising as a
      +result of this License or out of the use or inability to use the
      +Work (including but not limited to damages for loss of goodwill,
      +work stoppage, computer failure or malfunction, or any and all
      +other commercial damages or losses), even if such Contributor
      +has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +the Work or Derivative Works thereof, You may choose to offer,
      +and charge a fee for, acceptance of support, warranty, indemnity,
      +or other liability obligations and/or rights consistent with this
      +License. However, in accepting such obligations, You may act only
      +on Your own behalf and on Your sole responsibility, not on behalf
      +of any other Contributor, and only if You agree to indemnify,
      +defend, and hold each Contributor harmless for any liability
      +incurred by, or claims asserted against, such Contributor by reason
      +of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +To apply the Apache License to your work, attach the following
      +boilerplate notice, with the fields enclosed by brackets "[]"
      +replaced with your own identifying information. (Don't include
      +the brackets!)  The text should be enclosed in the appropriate
      +comment syntax for the file format. We also recommend that a
      +file or class name and description of purpose be included on the
      +same "printed page" as the copyright notice for easier
      +identification within third-party archives.
      +
      +Copyright 2020 LaunchBadge, LLC
      +
      +Licensed under the Apache License, Version 2.0 (the "License");
      +you may not use this file except in compliance with the License.
      +You may obtain a copy of the License at
      +
      +http://www.apache.org/licenses/LICENSE-2.0
      +
      +Unless required by applicable law or agreed to in writing, software
      +distributed under the License is distributed on an "AS IS" BASIS,
      +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +See the License for the specific language governing permissions and
      +limitations under the License.
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
      Apache License
      +Version 2.0, January 2004
      +http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +"License" shall mean the terms and conditions for use, reproduction,
      +and distribution as defined by Sections 1 through 9 of this document.
      +
      +"Licensor" shall mean the copyright owner or entity authorized by
      +the copyright owner that is granting the License.
      +
      +"Legal Entity" shall mean the union of the acting entity and all
      +other entities that control, are controlled by, or are under common
      +control with that entity. For the purposes of this definition,
      +"control" means (i) the power, direct or indirect, to cause the
      +direction or management of such entity, whether by contract or
      +otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +"You" (or "Your") shall mean an individual or Legal Entity
      +exercising permissions granted by this License.
      +
      +"Source" form shall mean the preferred form for making modifications,
      +including but not limited to software source code, documentation
      +source, and configuration files.
      +
      +"Object" form shall mean any form resulting from mechanical
      +transformation or translation of a Source form, including but
      +not limited to compiled object code, generated documentation,
      +and conversions to other media types.
      +
      +"Work" shall mean the work of authorship, whether in Source or
      +Object form, made available under the License, as indicated by a
      +copyright notice that is included in or attached to the work
      +(an example is provided in the Appendix below).
      +
      +"Derivative Works" shall mean any work, whether in Source or Object
      +form, that is based on (or derived from) the Work and for which the
      +editorial revisions, annotations, elaborations, or other modifications
      +represent, as a whole, an original work of authorship. For the purposes
      +of this License, Derivative Works shall not include works that remain
      +separable from, or merely link (or bind by name) to the interfaces of,
      +the Work and Derivative Works thereof.
      +
      +"Contribution" shall mean any work of authorship, including
      +the original version of the Work and any modifications or additions
      +to that Work or Derivative Works thereof, that is intentionally
      +submitted to Licensor for inclusion in the Work by the copyright owner
      +or by an individual or Legal Entity authorized to submit on behalf of
      +the copyright owner. For the purposes of this definition, "submitted"
      +means any form of electronic, verbal, or written communication sent
      +to the Licensor or its representatives, including but not limited to
      +communication on electronic mailing lists, source code control systems,
      +and issue tracking systems that are managed by, or on behalf of, the
      +Licensor for the purpose of discussing and improving the Work, but
      +excluding communication that is conspicuously marked or otherwise
      +designated in writing by the copyright owner as "Not a Contribution."
      +
      +"Contributor" shall mean Licensor and any individual or Legal Entity
      +on behalf of whom a Contribution has been received by Licensor and
      +subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +this License, each Contributor hereby grants to You a perpetual,
      +worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +copyright license to reproduce, prepare Derivative Works of,
      +publicly display, publicly perform, sublicense, and distribute the
      +Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +this License, each Contributor hereby grants to You a perpetual,
      +worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +(except as stated in this section) patent license to make, have made,
      +use, offer to sell, sell, import, and otherwise transfer the Work,
      +where such license applies only to those patent claims licensable
      +by such Contributor that are necessarily infringed by their
      +Contribution(s) alone or by combination of their Contribution(s)
      +with the Work to which such Contribution(s) was submitted. If You
      +institute patent litigation against any entity (including a
      +cross-claim or counterclaim in a lawsuit) alleging that the Work
      +or a Contribution incorporated within the Work constitutes direct
      +or contributory patent infringement, then any patent licenses
      +granted to You under this License for that Work shall terminate
      +as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +Work or Derivative Works thereof in any medium, with or without
      +modifications, and in Source or Object form, provided that You
      +meet the following conditions:
      +
      +(a) You must give any other recipients of the Work or
      +Derivative Works a copy of this License; and
      +
      +(b) You must cause any modified files to carry prominent notices
      +stating that You changed the files; and
      +
      +(c) You must retain, in the Source form of any Derivative Works
      +that You distribute, all copyright, patent, trademark, and
      +attribution notices from the Source form of the Work,
      +excluding those notices that do not pertain to any part of
      +the Derivative Works; and
      +
      +(d) If the Work includes a "NOTICE" text file as part of its
      +distribution, then any Derivative Works that You distribute must
      +include a readable copy of the attribution notices contained
      +within such NOTICE file, excluding those notices that do not
      +pertain to any part of the Derivative Works, in at least one
      +of the following places: within a NOTICE text file distributed
      +as part of the Derivative Works; within the Source form or
      +documentation, if provided along with the Derivative Works; or,
      +within a display generated by the Derivative Works, if and
      +wherever such third-party notices normally appear. The contents
      +of the NOTICE file are for informational purposes only and
      +do not modify the License. You may add Your own attribution
      +notices within Derivative Works that You distribute, alongside
      +or as an addendum to the NOTICE text from the Work, provided
      +that such additional attribution notices cannot be construed
      +as modifying the License.
      +
      +You may add Your own copyright statement to Your modifications and
      +may provide additional or different license terms and conditions
      +for use, reproduction, or distribution of Your modifications, or
      +for any such Derivative Works as a whole, provided Your use,
      +reproduction, and distribution of the Work otherwise complies with
      +the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +any Contribution intentionally submitted for inclusion in the Work
      +by You to the Licensor shall be under the terms and conditions of
      +this License, without any additional terms or conditions.
      +Notwithstanding the above, nothing herein shall supersede or modify
      +the terms of any separate license agreement you may have executed
      +with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +names, trademarks, service marks, or product names of the Licensor,
      +except as required for reasonable and customary use in describing the
      +origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +agreed to in writing, Licensor provides the Work (and each
      +Contributor provides its Contributions) on an "AS IS" BASIS,
      +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +implied, including, without limitation, any warranties or conditions
      +of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +PARTICULAR PURPOSE. You are solely responsible for determining the
      +appropriateness of using or redistributing the Work and assume any
      +risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +whether in tort (including negligence), contract, or otherwise,
      +unless required by applicable law (such as deliberate and grossly
      +negligent acts) or agreed to in writing, shall any Contributor be
      +liable to You for damages, including any direct, indirect, special,
      +incidental, or consequential damages of any character arising as a
      +result of this License or out of the use or inability to use the
      +Work (including but not limited to damages for loss of goodwill,
      +work stoppage, computer failure or malfunction, or any and all
      +other commercial damages or losses), even if such Contributor
      +has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +the Work or Derivative Works thereof, You may choose to offer,
      +and charge a fee for, acceptance of support, warranty, indemnity,
      +or other liability obligations and/or rights consistent with this
      +License. However, in accepting such obligations, You may act only
      +on Your own behalf and on Your sole responsibility, not on behalf
      +of any other Contributor, and only if You agree to indemnify,
      +defend, and hold each Contributor harmless for any liability
      +incurred by, or claims asserted against, such Contributor by reason
      +of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +To apply the Apache License to your work, attach the following
      +boilerplate notice, with the fields enclosed by brackets "[]"
      +replaced with your own identifying information. (Don't include
      +the brackets!)  The text should be enclosed in the appropriate
      +comment syntax for the file format. We also recommend that a
      +file or class name and description of purpose be included on the
      +same "printed page" as the copyright notice for easier
      +identification within third-party archives.
      +
      +Copyright [yyyy] [name of copyright owner]
      +
      +Licensed under the Apache License, Version 2.0 (the "License");
      +you may not use this file except in compliance with the License.
      +You may obtain a copy of the License at
      +
      +http://www.apache.org/licenses/LICENSE-2.0
      +
      +Unless required by applicable law or agreed to in writing, software
      +distributed under the License is distributed on an "AS IS" BASIS,
      +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +See the License for the specific language governing permissions and
      +limitations under the License.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
      Apache License
      +Version 2.0, January 2004
      +http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
      +
      +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
      +
      +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
      +
      +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
      +
      +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
      +
      +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
      +
      +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
      +
      +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
      +
      +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
      +
      +     (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
      +
      +     (b) You must cause any modified files to carry prominent notices stating that You changed the files; and
      +
      +     (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
      +
      +     (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
      +
      +     You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!)  The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
      +
      +Copyright [yyyy] [name of copyright owner]
      +
      +Licensed under the Apache License, Version 2.0 (the "License");
      +you may not use this file except in compliance with the License.
      +You may obtain a copy of the License at
      +
      +http://www.apache.org/licenses/LICENSE-2.0
      +
      +Unless required by applicable law or agreed to in writing, software
      +distributed under the License is distributed on an "AS IS" BASIS,
      +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +See the License for the specific language governing permissions and
      +limitations under the License.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
      Rust-chrono is dual-licensed under The MIT License [1] and
      +Apache 2.0 License [2]. Copyright (c) 2014--2026, Kang Seonghoon and
      +contributors.
      +
      +Nota Bene: This is same as the Rust Project's own license.
      +
      +
      +[1]: <http://opensource.org/licenses/MIT>, which is reproduced below:
      +
      +~~~~
      +The MIT License (MIT)
      +
      +Copyright (c) 2014, Kang Seonghoon.
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in
      +all copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
      +THE SOFTWARE.
      +~~~~
      +
      +
      +[2]: <http://www.apache.org/licenses/LICENSE-2.0>, which is reproduced below:
      +
      +~~~~
      +                              Apache License
      +                        Version 2.0, January 2004
      +                     http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright [yyyy] [name of copyright owner]
      +
      +Licensed under the Apache License, Version 2.0 (the "License");
      +you may not use this file except in compliance with the License.
      +You may obtain a copy of the License at
      +
      +	http://www.apache.org/licenses/LICENSE-2.0
      +
      +Unless required by applicable law or agreed to in writing, software
      +distributed under the License is distributed on an "AS IS" BASIS,
      +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +See the License for the specific language governing permissions and
      +limitations under the License.
      +~~~~
      +
      +
      +
    • +
    • +

      BSD 3-Clause "New" or "Revised" License

      +

      Used by:

      + +
      BSD 3-Clause License
      +
      +Copyright (c) 2013, Julien Schmidt
      +All rights reserved.
      +
      +Redistribution and use in source and binary forms, with or without
      +modification, are permitted provided that the following conditions are met:
      +
      +1. Redistributions of source code must retain the above copyright notice, this
      +   list of conditions and the following disclaimer.
      +
      +2. Redistributions in binary form must reproduce the above copyright notice,
      +   this list of conditions and the following disclaimer in the documentation
      +   and/or other materials provided with the distribution.
      +
      +3. Neither the name of the copyright holder nor the names of its
      +   contributors may be used to endorse or promote products derived from
      +   this software without specific prior written permission.
      +
      +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
      +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
      +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
      +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
      +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
      +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
      +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
      +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
      +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
      +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
      +
      +
    • +
    • +

      BSD 3-Clause "New" or "Revised" License

      +

      Used by:

      + +
      Copyright (c) 2016-2017 Isis Agora Lovecruft, Henry de Valence. All rights reserved.
      +Copyright (c) 2016-2024 Isis Agora Lovecruft. All rights reserved.
      +
      +Redistribution and use in source and binary forms, with or without
      +modification, are permitted provided that the following conditions are
      +met:
      +
      +1. Redistributions of source code must retain the above copyright
      +notice, this list of conditions and the following disclaimer.
      +
      +2. Redistributions in binary form must reproduce the above copyright
      +notice, this list of conditions and the following disclaimer in the
      +documentation and/or other materials provided with the distribution.
      +
      +3. Neither the name of the copyright holder nor the names of its
      +contributors may be used to endorse or promote products derived from
      +this software without specific prior written permission.
      +
      +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
      +IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
      +TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
      +PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
      +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
      +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
      +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
      +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
      +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
      +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
      +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 
      +
      +
    • +
    • +

      BSD 3-Clause "New" or "Revised" License

      +

      Used by:

      + +
      Copyright (c) <year> <owner>. 
      +
      +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
      +
      +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
      +
      +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
      +
      +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
      +
      +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
      +
      +
    • +
    • +

      BSD 3-Clause "New" or "Revised" License

      +

      Used by:

      + +
      Copyright 2011, The Snappy-Rust Authors. All rights reserved.
      +
      +Redistribution and use in source and binary forms, with or without
      +modification, are permitted provided that the following conditions are
      +met:
      +
      +    * Redistributions of source code must retain the above copyright
      +notice, this list of conditions and the following disclaimer.
      +    * Redistributions in binary form must reproduce the above
      +copyright notice, this list of conditions and the following disclaimer
      +in the documentation and/or other materials provided with the
      +distribution.
      +    * Neither the name of the copyright holder nor the names of its
      +contributors may be used to endorse or promote products derived from
      +this software without specific prior written permission.
      +
      +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
      +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
      +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
      +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
      +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
      +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
      +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
      +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
      +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
      +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
      +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
      +
      +
    • +
    • +

      BSD 3-Clause "New" or "Revised" License

      +

      Used by:

      + +
      Copyright © WHATWG (Apple, Google, Mozilla, Microsoft).
      +
      +Redistribution and use in source and binary forms, with or without
      +modification, are permitted provided that the following conditions are met:
      +
      +1. Redistributions of source code must retain the above copyright notice, this
      +   list of conditions and the following disclaimer.
      +
      +2. Redistributions in binary form must reproduce the above copyright notice,
      +   this list of conditions and the following disclaimer in the documentation
      +   and/or other materials provided with the distribution.
      +
      +3. Neither the name of the copyright holder nor the names of its
      +   contributors may be used to endorse or promote products derived from
      +   this software without specific prior written permission.
      +
      +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
      +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
      +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
      +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
      +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
      +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
      +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
      +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
      +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
      +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
      +
      +
    • +
    • +

      Creative Commons Zero v1.0 Universal

      +

      Used by:

      + +
      Creative Commons Legal Code
      +
      +CC0 1.0 Universal
      +
      +    CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
      +    LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
      +    ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
      +    INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
      +    REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
      +    PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
      +    THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
      +    HEREUNDER.
      +
      +Statement of Purpose
      +
      +The laws of most jurisdictions throughout the world automatically confer
      +exclusive Copyright and Related Rights (defined below) upon the creator
      +and subsequent owner(s) (each and all, an "owner") of an original work of
      +authorship and/or a database (each, a "Work").
      +
      +Certain owners wish to permanently relinquish those rights to a Work for
      +the purpose of contributing to a commons of creative, cultural and
      +scientific works ("Commons") that the public can reliably and without fear
      +of later claims of infringement build upon, modify, incorporate in other
      +works, reuse and redistribute as freely as possible in any form whatsoever
      +and for any purposes, including without limitation commercial purposes.
      +These owners may contribute to the Commons to promote the ideal of a free
      +culture and the further production of creative, cultural and scientific
      +works, or to gain reputation or greater distribution for their Work in
      +part through the use and efforts of others.
      +
      +For these and/or other purposes and motivations, and without any
      +expectation of additional consideration or compensation, the person
      +associating CC0 with a Work (the "Affirmer"), to the extent that he or she
      +is an owner of Copyright and Related Rights in the Work, voluntarily
      +elects to apply CC0 to the Work and publicly distribute the Work under its
      +terms, with knowledge of his or her Copyright and Related Rights in the
      +Work and the meaning and intended legal effect of CC0 on those rights.
      +
      +1. Copyright and Related Rights. A Work made available under CC0 may be
      +protected by copyright and related or neighboring rights ("Copyright and
      +Related Rights"). Copyright and Related Rights include, but are not
      +limited to, the following:
      +
      +  i. the right to reproduce, adapt, distribute, perform, display,
      +     communicate, and translate a Work;
      + ii. moral rights retained by the original author(s) and/or performer(s);
      +iii. publicity and privacy rights pertaining to a person's image or
      +     likeness depicted in a Work;
      + iv. rights protecting against unfair competition in regards to a Work,
      +     subject to the limitations in paragraph 4(a), below;
      +  v. rights protecting the extraction, dissemination, use and reuse of data
      +     in a Work;
      + vi. database rights (such as those arising under Directive 96/9/EC of the
      +     European Parliament and of the Council of 11 March 1996 on the legal
      +     protection of databases, and under any national implementation
      +     thereof, including any amended or successor version of such
      +     directive); and
      +vii. other similar, equivalent or corresponding rights throughout the
      +     world based on applicable law or treaty, and any national
      +     implementations thereof.
      +
      +2. Waiver. To the greatest extent permitted by, but not in contravention
      +of, applicable law, Affirmer hereby overtly, fully, permanently,
      +irrevocably and unconditionally waives, abandons, and surrenders all of
      +Affirmer's Copyright and Related Rights and associated claims and causes
      +of action, whether now known or unknown (including existing as well as
      +future claims and causes of action), in the Work (i) in all territories
      +worldwide, (ii) for the maximum duration provided by applicable law or
      +treaty (including future time extensions), (iii) in any current or future
      +medium and for any number of copies, and (iv) for any purpose whatsoever,
      +including without limitation commercial, advertising or promotional
      +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
      +member of the public at large and to the detriment of Affirmer's heirs and
      +successors, fully intending that such Waiver shall not be subject to
      +revocation, rescission, cancellation, termination, or any other legal or
      +equitable action to disrupt the quiet enjoyment of the Work by the public
      +as contemplated by Affirmer's express Statement of Purpose.
      +
      +3. Public License Fallback. Should any part of the Waiver for any reason
      +be judged legally invalid or ineffective under applicable law, then the
      +Waiver shall be preserved to the maximum extent permitted taking into
      +account Affirmer's express Statement of Purpose. In addition, to the
      +extent the Waiver is so judged Affirmer hereby grants to each affected
      +person a royalty-free, non transferable, non sublicensable, non exclusive,
      +irrevocable and unconditional license to exercise Affirmer's Copyright and
      +Related Rights in the Work (i) in all territories worldwide, (ii) for the
      +maximum duration provided by applicable law or treaty (including future
      +time extensions), (iii) in any current or future medium and for any number
      +of copies, and (iv) for any purpose whatsoever, including without
      +limitation commercial, advertising or promotional purposes (the
      +"License"). The License shall be deemed effective as of the date CC0 was
      +applied by Affirmer to the Work. Should any part of the License for any
      +reason be judged legally invalid or ineffective under applicable law, such
      +partial invalidity or ineffectiveness shall not invalidate the remainder
      +of the License, and in such case Affirmer hereby affirms that he or she
      +will not (i) exercise any of his or her remaining Copyright and Related
      +Rights in the Work or (ii) assert any associated claims and causes of
      +action with respect to the Work, in either case contrary to Affirmer's
      +express Statement of Purpose.
      +
      +4. Limitations and Disclaimers.
      +
      + a. No trademark or patent rights held by Affirmer are waived, abandoned,
      +    surrendered, licensed or otherwise affected by this document.
      + b. Affirmer offers the Work as-is and makes no representations or
      +    warranties of any kind concerning the Work, express, implied,
      +    statutory or otherwise, including without limitation warranties of
      +    title, merchantability, fitness for a particular purpose, non
      +    infringement, or the absence of latent or other defects, accuracy, or
      +    the present or absence of errors, whether or not discoverable, all to
      +    the greatest extent permissible under applicable law.
      + c. Affirmer disclaims responsibility for clearing rights of other persons
      +    that may apply to the Work or any use thereof, including without
      +    limitation any person's Copyright and Related Rights in the Work.
      +    Further, Affirmer disclaims responsibility for obtaining any necessary
      +    consents, permissions or other rights required for any use of the
      +    Work.
      + d. Affirmer understands and acknowledges that Creative Commons is not a
      +    party to this document and has no duty or obligation with respect to
      +    this CC0 or use of the Work.
      +
      +
    • +
    • +

      Community Data License Agreement Permissive 2.0

      +

      Used by:

      + +
      # Community Data License Agreement - Permissive - Version 2.0
      +
      +This is the Community Data License Agreement - Permissive, Version
      +2.0 (the "agreement"). Data Provider(s) and Data Recipient(s) agree
      +as follows:
      +
      +## 1. Provision of the Data
      +
      +1.1. A Data Recipient may use, modify, and share the Data made
      +available by Data Provider(s) under this agreement if that Data
      +Recipient follows the terms of this agreement.
      +
      +1.2. This agreement does not impose any restriction on a Data
      +Recipient's use, modification, or sharing of any portions of the
      +Data that are in the public domain or that may be used, modified,
      +or shared under any other legal exception or limitation.
      +
      +## 2. Conditions for Sharing Data
      +
      +2.1. A Data Recipient may share Data, with or without modifications, so
      +long as the Data Recipient makes available the text of this agreement
      +with the shared Data.
      +
      +## 3. No Restrictions on Results
      +
      +3.1. This agreement does not impose any restriction or obligations
      +with respect to the use, modification, or sharing of Results.
      +
      +## 4. No Warranty; Limitation of Liability
      +
      +4.1. All Data Recipients receive the Data subject to the following
      +terms:
      +
      +THE DATA IS PROVIDED ON AN "AS IS" BASIS, WITHOUT REPRESENTATIONS,
      +WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED
      +INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF TITLE,
      +NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE.
      +
      +NO DATA PROVIDER SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT,
      +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING
      +WITHOUT LIMITATION LOST PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF
      +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
      +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE DATA OR RESULTS,
      +EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
      +
      +## 5. Definitions
      +
      +5.1. "Data" means the material received by a Data Recipient under
      +this agreement.
      +
      +5.2. "Data Provider" means any person who is the source of Data
      +provided under this agreement and in reliance on a Data Recipient's
      +agreement to its terms.
      +
      +5.3. "Data Recipient" means any person who receives Data directly
      +or indirectly from a Data Provider and agrees to the terms of this
      +agreement.
      +
      +5.4. "Results" means any outcome obtained by computational analysis
      +of Data, including for example machine learning models and models'
      +insights.
      +
      +
    • +
    • +

      ISC License

      +

      Used by:

      + +
      // Copyright 2015-2016 Brian Smith.
      +//
      +// Permission to use, copy, modify, and/or distribute this software for any
      +// purpose with or without fee is hereby granted, provided that the above
      +// copyright notice and this permission notice appear in all copies.
      +//
      +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHORS DISCLAIM ALL WARRANTIES
      +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
      +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR
      +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
      +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
      +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
      +// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
      +
      +
    • +
    • +

      ISC License

      +

      Used by:

      + +
      Copyright 2018 Callum Oakley
      +
      +Permission to use, copy, modify, and/or distribute this software for any
      +purpose with or without fee is hereby granted, provided that the above
      +copyright notice and this permission notice appear in all copies.
      +
      +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
      +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
      +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
      +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
      +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
      +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
      +PERFORMANCE OF THIS SOFTWARE.
      +
      +
    • +
    • +

      ISC License

      +

      Used by:

      + +
      Except as otherwise noted, this project is licensed under the following
      +(ISC-style) terms:
      +
      +Copyright 2015 Brian Smith.
      +
      +Permission to use, copy, modify, and/or distribute this software for any
      +purpose with or without fee is hereby granted, provided that the above
      +copyright notice and this permission notice appear in all copies.
      +
      +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHORS DISCLAIM ALL WARRANTIES
      +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
      +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR
      +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
      +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
      +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
      +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
      +
      +The files under third-party/chromium are licensed as described in
      +third-party/chromium/LICENSE.
      +
      +
    • +
    • +

      ISC License

      +

      Used by:

      + +
      ISC License:
      +
      +Copyright (c) 2004-2010 by Internet Systems Consortium, Inc. ("ISC")
      +Copyright (c) 1995-2003 by Internet Software Consortium
      +
      +Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
      +
      +THE SOFTWARE IS PROVIDED "AS IS" AND ISC DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL ISC BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      # The MIT License (MIT)
      +
      +Copyright (c) 2014 Santiago Lapresta and contributors
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in
      +all copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
      +THE SOFTWARE.
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright (c) 2014 Carl Lerche and other MIO contributors
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in
      +all copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
      +THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright (c) 2014-2019 Geoffroy Couprie
      +
      +Permission is hereby granted, free of charge, to any person obtaining
      +a copy of this software and associated documentation files (the
      +"Software"), to deal in the Software without restriction, including
      +without limitation the rights to use, copy, modify, merge, publish,
      +distribute, sublicense, and/or sell copies of the Software, and to
      +permit persons to whom the Software is furnished to do so, subject to
      +the following conditions:
      +
      +The above copyright notice and this permission notice shall be
      +included in all copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
      +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
      +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
      +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
      +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
      +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
      +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright (c) 2014-2026 Sean McArthur
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in
      +all copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
      +THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright (c) 2017 h2 authors
      +
      +Permission is hereby granted, free of charge, to any
      +person obtaining a copy of this software and associated
      +documentation files (the "Software"), to deal in the
      +Software without restriction, including without
      +limitation the rights to use, copy, modify, merge,
      +publish, distribute, sublicense, and/or sell copies of
      +the Software, and to permit persons to whom the Software
      +is furnished to do so, subject to the following
      +conditions:
      +
      +The above copyright notice and this permission notice
      +shall be included in all copies or substantial portions
      +of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
      +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
      +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
      +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
      +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
      +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
      +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
      +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
      +DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright (c) 2018 Carl Lerche
      +
      +Permission is hereby granted, free of charge, to any
      +person obtaining a copy of this software and associated
      +documentation files (the "Software"), to deal in the
      +Software without restriction, including without
      +limitation the rights to use, copy, modify, merge,
      +publish, distribute, sublicense, and/or sell copies of
      +the Software, and to permit persons to whom the Software
      +is furnished to do so, subject to the following
      +conditions:
      +
      +The above copyright notice and this permission notice
      +shall be included in all copies or substantial portions
      +of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
      +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
      +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
      +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
      +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
      +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
      +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
      +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
      +DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright (c) 2019 Carl Lerche
      +
      +Permission is hereby granted, free of charge, to any
      +person obtaining a copy of this software and associated
      +documentation files (the "Software"), to deal in the
      +Software without restriction, including without
      +limitation the rights to use, copy, modify, merge,
      +publish, distribute, sublicense, and/or sell copies of
      +the Software, and to permit persons to whom the Software
      +is furnished to do so, subject to the following
      +conditions:
      +
      +The above copyright notice and this permission notice
      +shall be included in all copies or substantial portions
      +of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
      +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
      +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
      +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
      +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
      +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
      +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
      +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
      +DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright (c) 2019 Eliza Weisman
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in
      +all copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
      +THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright (c) 2019 Eliza Weisman
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright (c) 2019 Tokio Contributors
      +
      +Permission is hereby granted, free of charge, to any
      +person obtaining a copy of this software and associated
      +documentation files (the "Software"), to deal in the
      +Software without restriction, including without
      +limitation the rights to use, copy, modify, merge,
      +publish, distribute, sublicense, and/or sell copies of
      +the Software, and to permit persons to whom the Software
      +is furnished to do so, subject to the following
      +conditions:
      +
      +The above copyright notice and this permission notice
      +shall be included in all copies or substantial portions
      +of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
      +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
      +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
      +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
      +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
      +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
      +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
      +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
      +DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright (c) 2019 Tower Contributors
      +
      +Permission is hereby granted, free of charge, to any
      +person obtaining a copy of this software and associated
      +documentation files (the "Software"), to deal in the
      +Software without restriction, including without
      +limitation the rights to use, copy, modify, merge,
      +publish, distribute, sublicense, and/or sell copies of
      +the Software, and to permit persons to whom the Software
      +is furnished to do so, subject to the following
      +conditions:
      +
      +The above copyright notice and this permission notice
      +shall be included in all copies or substantial portions
      +of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
      +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
      +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
      +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
      +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
      +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
      +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
      +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
      +DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright (c) 2019 axum Contributors
      +
      +Permission is hereby granted, free of charge, to any
      +person obtaining a copy of this software and associated
      +documentation files (the "Software"), to deal in the
      +Software without restriction, including without
      +limitation the rights to use, copy, modify, merge,
      +publish, distribute, sublicense, and/or sell copies of
      +the Software, and to permit persons to whom the Software
      +is furnished to do so, subject to the following
      +conditions:
      +
      +The above copyright notice and this permission notice
      +shall be included in all copies or substantial portions
      +of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
      +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
      +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
      +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
      +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
      +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
      +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
      +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
      +DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright (c) 2019-2021 Tower Contributors
      +
      +Permission is hereby granted, free of charge, to any
      +person obtaining a copy of this software and associated
      +documentation files (the "Software"), to deal in the
      +Software without restriction, including without
      +limitation the rights to use, copy, modify, merge,
      +publish, distribute, sublicense, and/or sell copies of
      +the Software, and to permit persons to whom the Software
      +is furnished to do so, subject to the following
      +conditions:
      +
      +The above copyright notice and this permission notice
      +shall be included in all copies or substantial portions
      +of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
      +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
      +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
      +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
      +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
      +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
      +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
      +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
      +DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright (c) 2019-2026 Sean McArthur & Hyper Contributors
      +
      +Permission is hereby granted, free of charge, to any
      +person obtaining a copy of this software and associated
      +documentation files (the "Software"), to deal in the
      +Software without restriction, including without
      +limitation the rights to use, copy, modify, merge,
      +publish, distribute, sublicense, and/or sell copies of
      +the Software, and to permit persons to whom the Software
      +is furnished to do so, subject to the following
      +conditions:
      +
      +The above copyright notice and this permission notice
      +shall be included in all copies or substantial portions
      +of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
      +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
      +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
      +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
      +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
      +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
      +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
      +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
      +DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright (c) 2021 Metrics Contributors
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright (c) 2023-2025 Sean McArthur
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in
      +all copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
      +THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright 2016 Nika Layzell
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright 2021 Axum Server Contributors
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright 2021 axum Contributors
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) 2017 
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) 2017 Denis Kurilenko
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) 2018 sgodwincs
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) 2019 Yoshua Wuyts
      +Copyright (c) Tokio Contributors
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) 2019–2025 axum Contributors
      +
      +Permission is hereby granted, free of charge, to any
      +person obtaining a copy of this software and associated
      +documentation files (the "Software"), to deal in the
      +Software without restriction, including without
      +limitation the rights to use, copy, modify, merge,
      +publish, distribute, sublicense, and/or sell copies of
      +the Software, and to permit persons to whom the Software
      +is furnished to do so, subject to the following
      +conditions:
      +
      +The above copyright notice and this permission notice
      +shall be included in all copies or substantial portions
      +of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
      +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
      +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
      +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
      +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
      +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
      +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
      +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
      +DEALINGS IN THE SOFTWARE.
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) 2020 David Purdum
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) 2022 Ibraheem Ahmed
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) 2025 rutrum
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) <year> <copyright holders>
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
      +associated documentation files (the "Software"), to deal in the Software without restriction, including
      +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
      +following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all copies or substantial
      +portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
      +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
      +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
      +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
      +USE OR OTHER DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) Tokio Contributors
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) [2021] [Marvin Countryman]
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Permission is hereby granted, free of charge, to any
      +person obtaining a copy of this software and associated
      +documentation files (the "Software"), to deal in the
      +Software without restriction, including without
      +limitation the rights to use, copy, modify, merge,
      +publish, distribute, sublicense, and/or sell copies of
      +the Software, and to permit persons to whom the Software
      +is furnished to do so, subject to the following
      +conditions:
      +
      +The above copyright notice and this permission notice
      +shall be included in all copies or substantial portions
      +of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
      +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
      +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
      +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
      +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
      +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
      +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
      +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
      +DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Permission is hereby granted, free of charge, to any person obtaining
      +a copy of this software and associated documentation files (the
      +"Software"), to deal in the Software without restriction, including
      +without limitation the rights to use, copy, modify, merge, publish,
      +distribute, sublicense, and/or sell copies of the Software, and to
      +permit persons to whom the Software is furnished to do so, subject to
      +the following conditions:
      +
      +The above copyright notice and this permission notice shall be
      +included in all copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
      +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
      +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
      +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
      +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
      +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
      +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      The MIT License (MIT)
      +
      +Copyright (c) 2014 Y. T. CHUNG
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      The MIT License (MIT)
      +
      +Copyright (c) 2015 Andrew Gallant
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in
      +all copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
      +THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      The MIT License (MIT)
      +
      +Copyright (c) 2015 Danny Guo
      +Copyright (c) 2016 Titus Wormer <tituswormer@gmail.com>
      +Copyright (c) 2018 Akash Kurdekar
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      The MIT License (MIT)
      +
      +Copyright (c) 2015 Jake Goulding
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      The MIT License (MIT)
      +
      +Copyright (c) 2015-2020 the fiat-crypto authors (see
      +https://github.com/mit-plv/fiat-crypto/blob/master/AUTHORS).
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      The MIT License (MIT)
      +
      +Copyright (c) 2016 Google Inc. (lewinb@google.com) -- though not an official
      +Google product or in any way related!
      +Copyright (c) 2018-2020 Lewin Bormann (lbo@spheniscida.de)
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to
      +deal in the Software without restriction, including without limitation the
      +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
      +sell copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in
      +all copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
      +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
      +IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      The MIT License (MIT)
      +
      +Copyright (c) 2016 Jelte Fennema
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      The MIT License (MIT)
      +
      +Copyright (c) 2016 Jonathan Creekmore
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      The MIT License (MIT)
      +
      +Copyright (c) 2020 Pascal Seitz
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy of
      +this software and associated documentation files (the "Software"), to deal in
      +the Software without restriction, including without limitation the rights to
      +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
      +the Software, and to permit persons to whom the Software is furnished to do so,
      +subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
      +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
      +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
      +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
      +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      The MIT License (MIT)
      +
      +Copyright (c) 2020 Vincent Prouillet
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      The MIT License (MIT)
      +
      +Copyright 2017-2023 Eira Fransham.
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      The MIT License (MIT)
      +
      +Copyright (c) 2014 Benjamin Sago
      +Copyright (c) 2021-2022 The Nushell Project Developers
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      The MIT License (MIT)
      +
      +Copyright (c) 2015 Bartłomiej Kamiński
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
    • +
    • +

      Unicode License v3

      +

      Used by:

      + +
      UNICODE LICENSE V3
      +
      +COPYRIGHT AND PERMISSION NOTICE
      +
      +Copyright © 1991-2023 Unicode, Inc.
      +
      +NOTICE TO USER: Carefully read the following legal agreement. BY
      +DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR
      +SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE
      +TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT
      +DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE.
      +
      +Permission is hereby granted, free of charge, to any person obtaining a
      +copy of data files and any associated documentation (the "Data Files") or
      +software and any associated documentation (the "Software") to deal in the
      +Data Files or Software without restriction, including without limitation
      +the rights to use, copy, modify, merge, publish, distribute, and/or sell
      +copies of the Data Files or Software, and to permit persons to whom the
      +Data Files or Software are furnished to do so, provided that either (a)
      +this copyright and permission notice appear with all copies of the Data
      +Files or Software, or (b) this copyright and permission notice appear in
      +associated Documentation.
      +
      +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
      +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
      +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
      +THIRD PARTY RIGHTS.
      +
      +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE
      +BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES,
      +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
      +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
      +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA
      +FILES OR SOFTWARE.
      +
      +Except as contained in this notice, the name of a copyright holder shall
      +not be used in advertising or otherwise to promote the sale, use or other
      +dealings in these Data Files or Software without prior written
      +authorization of the copyright holder.
      +
      +
    • +
    • +

      Unicode License v3

      +

      Used by:

      + +
      UNICODE LICENSE V3
      +
      +COPYRIGHT AND PERMISSION NOTICE
      +
      +Copyright © 2020-2024 Unicode, Inc.
      +
      +NOTICE TO USER: Carefully read the following legal agreement. BY
      +DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR
      +SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE
      +TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT
      +DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE.
      +
      +Permission is hereby granted, free of charge, to any person obtaining a
      +copy of data files and any associated documentation (the "Data Files") or
      +software and any associated documentation (the "Software") to deal in the
      +Data Files or Software without restriction, including without limitation
      +the rights to use, copy, modify, merge, publish, distribute, and/or sell
      +copies of the Data Files or Software, and to permit persons to whom the
      +Data Files or Software are furnished to do so, provided that either (a)
      +this copyright and permission notice appear with all copies of the Data
      +Files or Software, or (b) this copyright and permission notice appear in
      +associated Documentation.
      +
      +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
      +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
      +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
      +THIRD PARTY RIGHTS.
      +
      +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE
      +BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES,
      +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
      +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
      +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA
      +FILES OR SOFTWARE.
      +
      +Except as contained in this notice, the name of a copyright holder shall
      +not be used in advertising or otherwise to promote the sale, use or other
      +dealings in these Data Files or Software without prior written
      +authorization of the copyright holder.
      +
      +SPDX-License-Identifier: Unicode-3.0
      +
      +—
      +
      +Portions of ICU4X may have been adapted from ICU4C and/or ICU4J.
      +ICU 1.8.1 to ICU 57.1 © 1995-2016 International Business Machines Corporation and others.
      +
      +
    • +
    • +

      zlib License

      +

      Used by:

      + +
      Copyright (c) 2024 Orson Peters
      +
      +This software is provided 'as-is', without any express or implied warranty. In
      +no event will the authors be held liable for any damages arising from the use of
      +this software.
      +
      +Permission is granted to anyone to use this software for any purpose, including
      +commercial applications, and to alter it and redistribute it freely, subject to
      +the following restrictions:
      +
      +1. The origin of this software must not be misrepresented; you must not claim
      +    that you wrote the original software. If you use this software in a product,
      +    an acknowledgment in the product documentation would be appreciated but is
      +    not required.
      +
      +2. Altered source versions must be plainly marked as such, and must not be
      +    misrepresented as being the original software.
      +
      +3. This notice may not be removed or altered from any source distribution.
      +
    • +
    +
    + + + diff --git a/SOFTWARE-LICENSE-NOTICES-DEV.html b/SOFTWARE-LICENSE-NOTICES-DEV.html index fb714ce4..9be73545 100644 --- a/SOFTWARE-LICENSE-NOTICES-DEV.html +++ b/SOFTWARE-LICENSE-NOTICES-DEV.html @@ -49,7 +49,7 @@

    ExtendDB Software License Notices

    Overview of licenses:

      -
    • Apache License 2.0 (215)
    • +
    • Apache License 2.0 (221)
    • MIT License (54)
    • Unicode License v3 (19)
    • ISC License (5)
    • @@ -485,11 +485,14 @@

      Used by:

      Apache License 2.0

      Used by:

                                        Apache License
      @@ -908,7 +911,7 @@ 

      Used by:

      Apache License 2.0

      Used by:

                                       Apache License
                                  Version 2.0, January 2004
      @@ -1117,7 +1120,7 @@ 

      Used by:

      Apache License 2.0

      Used by:

                                       Apache License
                                  Version 2.0, January 2004
      @@ -1307,7 +1310,7 @@ 

      Used by:

      same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2020 Tomasz "Soveu" Marx + Copyright 2023 The Fuchsia Authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -1327,7 +1330,10 @@

      Used by:

      Apache License 2.0

      Used by:

                                       Apache License
                                  Version 2.0, January 2004
      @@ -1509,7 +1515,7 @@ 

      Used by:

      APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" + boilerplate notice, with the fields enclosed by brackets "{}" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a @@ -1517,7 +1523,7 @@

      Used by:

      same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2023 The Fuchsia Authors + Copyright Individual contributors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -1541,13 +1547,9 @@

      Used by:

    • anstyle-parse 1.0.0
    • anstyle-query 1.1.5
    • anstyle 1.0.14
    • -
    • clap 4.6.1
    • -
    • clap_builder 4.6.0
    • -
    • clap_derive 4.6.1
    • -
    • clap_lex 1.1.0
    • colorchoice 1.0.5
    • config 0.14.1
    • -
    • crc32fast 1.5.0
    • +
    • crc32fast 1.5.2
    • hex 0.4.3
    • is_terminal_polyfill 1.70.2
    • serde_spanned 0.6.9
    • @@ -1974,15 +1976,15 @@

      Used by:

      Apache License 2.0

      Used by:

                                    Apache License
                               Version 2.0, January 2004
      @@ -2401,7 +2403,7 @@ 

      Used by:

      Apache License 2.0

      Used by:

                                    Apache License
                               Version 2.0, January 2004
      @@ -2818,7 +2820,7 @@ 

      Used by:

      Apache License 2.0

      Used by:

                                    Apache License
                               Version 2.0, January 2004
      @@ -3027,7 +3029,7 @@ 

      Used by:

      Apache License 2.0

      Used by:

                                    Apache License
                               Version 2.0, January 2004
      @@ -3445,7 +3447,7 @@ 

      Used by:

      Apache License 2.0

      Used by:

                                    Apache License
                               Version 2.0, January 2004
      @@ -3865,7 +3867,7 @@ 

      Used by:

      Apache License 2.0

      Used by:

                                    Apache License
                               Version 2.0, January 2004
      @@ -4069,39 +4071,248 @@ 

      Used by:

      See the License for the specific language governing permissions and limitations under the License.
      + +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright [yyyy] [name of copyright owner]
      +
      +Licensed under the Apache License, Version 2.0 (the "License");
      +you may not use this file except in compliance with the License.
      +You may obtain a copy of the License at
      +
      +	http://www.apache.org/licenses/LICENSE-2.0
      +
      +Unless required by applicable law or agreed to in writing, software
      +distributed under the License is distributed on an "AS IS" BASIS,
      +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +See the License for the specific language governing permissions and
      +limitations under the License.
    • Apache License 2.0

      Used by:

      @@ -5616,7 +5827,7 @@

      Used by:

                                    Apache License
      @@ -6036,7 +6247,7 @@ 

      Used by:

      Apache License 2.0

      Used by:

                                    Apache License
                               Version 2.0, January 2004
      @@ -7096,43 +7307,45 @@ 

      Used by:

    • extenddb-storage 0.1.12
    • extenddb-storage-sqlite 0.1.12
    • allocator-api2 0.2.21
    • -
    • anyhow 1.0.103
    • +
    • anyhow 1.0.104
    • arraydeque 0.5.1
    • -
    • async-trait 0.1.89
    • -
    • aws-lc-sys 0.41.0
    • +
    • async-trait 0.1.92
    • +
    • aws-lc-sys 0.45.0
    • daemonize 0.5.0
    • dlv-list 0.5.2
    • dunce 1.0.5
    • itoa 1.0.18
    • -
    • libc 0.2.186
    • -
    • miniz_oxide 0.8.9
    • +
    • libc 0.2.189
    • +
    • miniz_oxide 0.9.1
    • num-conv 0.2.2
    • pin-project-lite 0.2.17
    • -
    • portable-atomic 1.13.1
    • -
    • proc-macro2 1.0.106
    • -
    • quote 1.0.45
    • -
    • rand 0.8.6
    • -
    • rand 0.9.4
    • +
    • portable-atomic 1.15.0
    • +
    • proc-macro2 1.0.107
    • +
    • quote 1.0.47
    • +
    • rand 0.8.8
    • +
    • rand 0.9.5
    • rand_chacha 0.9.0
    • -
    • rcgen 0.14.8
    • -
    • rustversion 1.0.22
    • +
    • rcgen 0.14.10
    • +
    • rustversion 1.0.23
    • ryu 1.0.23
    • -
    • serde 1.0.228
    • -
    • serde_core 1.0.228
    • -
    • serde_derive 1.0.228
    • -
    • serde_json 1.0.150
    • +
    • serde 1.0.229
    • +
    • serde_core 1.0.229
    • +
    • serde_derive 1.0.229
    • +
    • serde_json 1.0.151
    • serde_path_to_error 0.1.20
    • serde_urlencoded 0.7.1
    • -
    • shlex 1.3.0
    • -
    • syn 2.0.117
    • +
    • shlex 2.0.1
    • +
    • simdutf8 0.1.5
    • +
    • syn 2.0.119
    • +
    • syn 3.0.6
    • sync_wrapper 1.0.2
    • tagptr 0.2.0
    • -
    • thiserror-impl 2.0.18
    • -
    • thiserror 2.0.18
    • -
    • time-core 0.1.8
    • -
    • time-macros 0.2.27
    • -
    • time 0.3.47
    • -
    • unicode-ident 1.0.24
    • +
    • thiserror-impl 2.0.20
    • +
    • thiserror 2.0.20
    • +
    • time-core 0.1.9
    • +
    • time-macros 0.2.32
    • +
    • time 0.3.55
    • +
    • unicode-ident 1.0.26
    • utf8parse 0.2.2
    • whoami 1.6.1
    • yaml-rust2 0.8.1
    • @@ -7290,7 +7503,7 @@

      Used by:

      BSD 3-Clause "New" or "Revised" License

      Used by:

      Copyright (c) <year> <owner>. 
       
      @@ -7309,7 +7522,7 @@ 

      Used by:

      BSD 3-Clause "New" or "Revised" License

      Used by:

      Copyright © WHATWG (Apple, Google, Mozilla, Microsoft).
       
      @@ -7473,7 +7686,7 @@ 

      Community Data License Agreement Permissive 2.0

      Used by:
      # Community Data License Agreement - Permissive - Version 2.0
       
      @@ -7584,7 +7797,7 @@ 

      Used by:

      ISC License

      Used by:

      Except as otherwise noted, this project is licensed under the following
       (ISC-style) terms:
      @@ -7611,8 +7824,8 @@ 

      Used by:

      ISC License

      Used by:

      ISC License:
       
      @@ -7656,7 +7869,7 @@ 

      Used by:

      MIT License

      Used by:

      Copyright (c) 2014 Carl Lerche and other MIO contributors
       
      @@ -7738,7 +7951,7 @@ 

      Used by:

      MIT License

      Used by:

      Copyright (c) 2014-2026 Sean McArthur
       
      @@ -7765,7 +7978,7 @@ 

      Used by:

      MIT License

      Used by:

      Copyright (c) 2017 h2 authors
       
      @@ -7798,7 +8011,7 @@ 

      Used by:

      MIT License

      Used by:

      Copyright (c) 2018 Carl Lerche
       
      @@ -8057,42 +8270,10 @@ 

      Used by:

      MIT License

      Used by:

      -
      Copyright (c) 2019-2024 Sean McArthur & Hyper Contributors
      -
      -Permission is hereby granted, free of charge, to any
      -person obtaining a copy of this software and associated
      -documentation files (the "Software"), to deal in the
      -Software without restriction, including without
      -limitation the rights to use, copy, modify, merge,
      -publish, distribute, sublicense, and/or sell copies of
      -the Software, and to permit persons to whom the Software
      -is furnished to do so, subject to the following
      -conditions:
      -
      -The above copyright notice and this permission notice
      -shall be included in all copies or substantial portions
      -of the Software.
      -
      -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
      -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
      -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
      -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
      -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
      -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
      -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
      -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
      -DEALINGS IN THE SOFTWARE.
      -
      -
    • -
    • -

      MIT License

      -

      Used by:

      - -
      Copyright (c) 2019-2025 Sean McArthur & Hyper Contributors
      +                
      Copyright (c) 2019-2026 Sean McArthur & Hyper Contributors
       
       Permission is hereby granted, free of charge, to any
       person obtaining a copy of this software and associated
      @@ -8177,7 +8358,7 @@ 

      Used by:

      MIT License

      Used by:

      Copyright 2016 Nika Layzell
       
      @@ -8321,7 +8502,7 @@ 

      Used by:

      MIT License

      Used by:

      MIT License
       
      @@ -8469,9 +8650,9 @@ 

      Used by:

      MIT License

      Used by:

      MIT License
       
      @@ -8500,7 +8681,7 @@ 

      Used by:

      MIT License

      Used by:

      MIT License
       
      @@ -8530,7 +8711,7 @@ 

      MIT License

      Used by:

      Permission is hereby granted, free of charge, to any
       person obtaining a copy of this software and associated
      @@ -8587,7 +8768,7 @@ 

      Used by:

      MIT License

      Used by:

      The MIT License (MIT)
       
      @@ -8633,7 +8814,7 @@ 

      MIT License

      Used by:

      The MIT License (MIT)
       
      @@ -8693,7 +8874,7 @@ 

      Used by:

      MIT License

      Used by:

      The MIT License (MIT)
       
      @@ -8723,7 +8904,7 @@ 

      Used by:

      MIT License

      Used by:

      The MIT License (MIT)
       
      @@ -8868,7 +9049,7 @@ 

      Used by:

      Unicode License v3

      Used by:

      UNICODE LICENSE V3
       
      @@ -8915,24 +9096,24 @@ 

      Used by:

      Unicode License v3

      Used by:

      UNICODE LICENSE V3
       
      diff --git a/SOFTWARE-LICENSE-NOTICES-MONGODB.html b/SOFTWARE-LICENSE-NOTICES-MONGODB.html
      index ac63de23..059787fb 100644
      --- a/SOFTWARE-LICENSE-NOTICES-MONGODB.html
      +++ b/SOFTWARE-LICENSE-NOTICES-MONGODB.html
      @@ -49,7 +49,7 @@ 

      ExtendDB Software License Notices

      Overview of licenses:

        -
      • Apache License 2.0 (248)
      • +
      • Apache License 2.0 (255)
      • MIT License (72)
      • Unicode License v3 (19)
      • ISC License (6)
      • @@ -487,12 +487,15 @@

        Used by:

                                          Apache License
        @@ -911,7 +914,7 @@ 

        Used by:

        Apache License 2.0

        Used by:

                                         Apache License
                                    Version 2.0, January 2004
        @@ -1120,7 +1123,7 @@ 

        Used by:

        Apache License 2.0

        Used by:

                                         Apache License
                                    Version 2.0, January 2004
        @@ -1310,7 +1313,7 @@ 

        Used by:

        same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2020 Tomasz "Soveu" Marx + Copyright 2023 The Fuchsia Authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -1330,7 +1333,8 @@

        Used by:

        Apache License 2.0

        Used by:

                                         Apache License
                                    Version 2.0, January 2004
        @@ -1512,7 +1516,7 @@ 

        Used by:

        APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" + boilerplate notice, with the fields enclosed by brackets "{}" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a @@ -1520,7 +1524,7 @@

        Used by:

        same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2023 The Fuchsia Authors + Copyright 2017 Juniper Networks, Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -1533,15 +1537,16 @@

        Used by:

        WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. -
      • Apache License 2.0

        Used by:

                                         Apache License
                                    Version 2.0, January 2004
        @@ -1731,7 +1736,7 @@ 

        Used by:

        same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2017 Juniper Networks, Inc. + Copyright Individual contributors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -1744,6 +1749,7 @@

        Used by:

        WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. +
      • @@ -1754,13 +1760,9 @@

        Used by:

      • anstyle-parse 1.0.0
      • anstyle-query 1.1.5
      • anstyle 1.0.14
      • -
      • clap 4.6.1
      • -
      • clap_builder 4.6.0
      • -
      • clap_derive 4.6.1
      • -
      • clap_lex 1.1.0
      • colorchoice 1.0.5
      • config 0.14.1
      • -
      • crc32fast 1.5.0
      • +
      • crc32fast 1.5.2
      • hex 0.4.3
      • is_terminal_polyfill 1.70.2
      • resolv-conf 0.7.6
      • @@ -1979,9 +1981,9 @@

        Used by:

        Apache License 2.0

        Used by:

                                         Apache License
                                    Version 2.0, January 2004
        @@ -2400,15 +2402,15 @@ 

        Used by:

        Apache License 2.0

        Used by:

                                      Apache License
                                 Version 2.0, January 2004
        @@ -2827,7 +2829,7 @@ 

        Used by:

        Apache License 2.0

        Used by:

                                      Apache License
                                 Version 2.0, January 2004
        @@ -3244,7 +3246,7 @@ 

        Used by:

        Apache License 2.0

        Used by:

                                      Apache License
                                 Version 2.0, January 2004
        @@ -3453,7 +3455,7 @@ 

        Used by:

        Apache License 2.0

        Used by:

                                      Apache License
                                 Version 2.0, January 2004
        @@ -3871,7 +3873,7 @@ 

        Used by:

        Apache License 2.0

        Used by:

                                      Apache License
                                 Version 2.0, January 2004
        @@ -4291,7 +4293,7 @@ 

        Used by:

        Apache License 2.0

        Used by:

                                      Apache License
                                 Version 2.0, January 2004
        @@ -4495,41 +4497,250 @@ 

        Used by:

        See the License for the specific language governing permissions and limitations under the License.
        + +
      • +

        Apache License 2.0

        +

        Used by:

        + +
                                      Apache License
        +                        Version 2.0, January 2004
        +                     http://www.apache.org/licenses/
        +
        +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
        +
        +1. Definitions.
        +
        +   "License" shall mean the terms and conditions for use, reproduction,
        +   and distribution as defined by Sections 1 through 9 of this document.
        +
        +   "Licensor" shall mean the copyright owner or entity authorized by
        +   the copyright owner that is granting the License.
        +
        +   "Legal Entity" shall mean the union of the acting entity and all
        +   other entities that control, are controlled by, or are under common
        +   control with that entity. For the purposes of this definition,
        +   "control" means (i) the power, direct or indirect, to cause the
        +   direction or management of such entity, whether by contract or
        +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
        +   outstanding shares, or (iii) beneficial ownership of such entity.
        +
        +   "You" (or "Your") shall mean an individual or Legal Entity
        +   exercising permissions granted by this License.
        +
        +   "Source" form shall mean the preferred form for making modifications,
        +   including but not limited to software source code, documentation
        +   source, and configuration files.
        +
        +   "Object" form shall mean any form resulting from mechanical
        +   transformation or translation of a Source form, including but
        +   not limited to compiled object code, generated documentation,
        +   and conversions to other media types.
        +
        +   "Work" shall mean the work of authorship, whether in Source or
        +   Object form, made available under the License, as indicated by a
        +   copyright notice that is included in or attached to the work
        +   (an example is provided in the Appendix below).
        +
        +   "Derivative Works" shall mean any work, whether in Source or Object
        +   form, that is based on (or derived from) the Work and for which the
        +   editorial revisions, annotations, elaborations, or other modifications
        +   represent, as a whole, an original work of authorship. For the purposes
        +   of this License, Derivative Works shall not include works that remain
        +   separable from, or merely link (or bind by name) to the interfaces of,
        +   the Work and Derivative Works thereof.
        +
        +   "Contribution" shall mean any work of authorship, including
        +   the original version of the Work and any modifications or additions
        +   to that Work or Derivative Works thereof, that is intentionally
        +   submitted to Licensor for inclusion in the Work by the copyright owner
        +   or by an individual or Legal Entity authorized to submit on behalf of
        +   the copyright owner. For the purposes of this definition, "submitted"
        +   means any form of electronic, verbal, or written communication sent
        +   to the Licensor or its representatives, including but not limited to
        +   communication on electronic mailing lists, source code control systems,
        +   and issue tracking systems that are managed by, or on behalf of, the
        +   Licensor for the purpose of discussing and improving the Work, but
        +   excluding communication that is conspicuously marked or otherwise
        +   designated in writing by the copyright owner as "Not a Contribution."
        +
        +   "Contributor" shall mean Licensor and any individual or Legal Entity
        +   on behalf of whom a Contribution has been received by Licensor and
        +   subsequently incorporated within the Work.
        +
        +2. Grant of Copyright License. Subject to the terms and conditions of
        +   this License, each Contributor hereby grants to You a perpetual,
        +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
        +   copyright license to reproduce, prepare Derivative Works of,
        +   publicly display, publicly perform, sublicense, and distribute the
        +   Work and such Derivative Works in Source or Object form.
        +
        +3. Grant of Patent License. Subject to the terms and conditions of
        +   this License, each Contributor hereby grants to You a perpetual,
        +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
        +   (except as stated in this section) patent license to make, have made,
        +   use, offer to sell, sell, import, and otherwise transfer the Work,
        +   where such license applies only to those patent claims licensable
        +   by such Contributor that are necessarily infringed by their
        +   Contribution(s) alone or by combination of their Contribution(s)
        +   with the Work to which such Contribution(s) was submitted. If You
        +   institute patent litigation against any entity (including a
        +   cross-claim or counterclaim in a lawsuit) alleging that the Work
        +   or a Contribution incorporated within the Work constitutes direct
        +   or contributory patent infringement, then any patent licenses
        +   granted to You under this License for that Work shall terminate
        +   as of the date such litigation is filed.
        +
        +4. Redistribution. You may reproduce and distribute copies of the
        +   Work or Derivative Works thereof in any medium, with or without
        +   modifications, and in Source or Object form, provided that You
        +   meet the following conditions:
        +
        +   (a) You must give any other recipients of the Work or
        +       Derivative Works a copy of this License; and
        +
        +   (b) You must cause any modified files to carry prominent notices
        +       stating that You changed the files; and
        +
        +   (c) You must retain, in the Source form of any Derivative Works
        +       that You distribute, all copyright, patent, trademark, and
        +       attribution notices from the Source form of the Work,
        +       excluding those notices that do not pertain to any part of
        +       the Derivative Works; and
        +
        +   (d) If the Work includes a "NOTICE" text file as part of its
        +       distribution, then any Derivative Works that You distribute must
        +       include a readable copy of the attribution notices contained
        +       within such NOTICE file, excluding those notices that do not
        +       pertain to any part of the Derivative Works, in at least one
        +       of the following places: within a NOTICE text file distributed
        +       as part of the Derivative Works; within the Source form or
        +       documentation, if provided along with the Derivative Works; or,
        +       within a display generated by the Derivative Works, if and
        +       wherever such third-party notices normally appear. The contents
        +       of the NOTICE file are for informational purposes only and
        +       do not modify the License. You may add Your own attribution
        +       notices within Derivative Works that You distribute, alongside
        +       or as an addendum to the NOTICE text from the Work, provided
        +       that such additional attribution notices cannot be construed
        +       as modifying the License.
        +
        +   You may add Your own copyright statement to Your modifications and
        +   may provide additional or different license terms and conditions
        +   for use, reproduction, or distribution of Your modifications, or
        +   for any such Derivative Works as a whole, provided Your use,
        +   reproduction, and distribution of the Work otherwise complies with
        +   the conditions stated in this License.
        +
        +5. Submission of Contributions. Unless You explicitly state otherwise,
        +   any Contribution intentionally submitted for inclusion in the Work
        +   by You to the Licensor shall be under the terms and conditions of
        +   this License, without any additional terms or conditions.
        +   Notwithstanding the above, nothing herein shall supersede or modify
        +   the terms of any separate license agreement you may have executed
        +   with Licensor regarding such Contributions.
        +
        +6. Trademarks. This License does not grant permission to use the trade
        +   names, trademarks, service marks, or product names of the Licensor,
        +   except as required for reasonable and customary use in describing the
        +   origin of the Work and reproducing the content of the NOTICE file.
        +
        +7. Disclaimer of Warranty. Unless required by applicable law or
        +   agreed to in writing, Licensor provides the Work (and each
        +   Contributor provides its Contributions) on an "AS IS" BASIS,
        +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
        +   implied, including, without limitation, any warranties or conditions
        +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
        +   PARTICULAR PURPOSE. You are solely responsible for determining the
        +   appropriateness of using or redistributing the Work and assume any
        +   risks associated with Your exercise of permissions under this License.
        +
        +8. Limitation of Liability. In no event and under no legal theory,
        +   whether in tort (including negligence), contract, or otherwise,
        +   unless required by applicable law (such as deliberate and grossly
        +   negligent acts) or agreed to in writing, shall any Contributor be
        +   liable to You for damages, including any direct, indirect, special,
        +   incidental, or consequential damages of any character arising as a
        +   result of this License or out of the use or inability to use the
        +   Work (including but not limited to damages for loss of goodwill,
        +   work stoppage, computer failure or malfunction, or any and all
        +   other commercial damages or losses), even if such Contributor
        +   has been advised of the possibility of such damages.
        +
        +9. Accepting Warranty or Additional Liability. While redistributing
        +   the Work or Derivative Works thereof, You may choose to offer,
        +   and charge a fee for, acceptance of support, warranty, indemnity,
        +   or other liability obligations and/or rights consistent with this
        +   License. However, in accepting such obligations, You may act only
        +   on Your own behalf and on Your sole responsibility, not on behalf
        +   of any other Contributor, and only if You agree to indemnify,
        +   defend, and hold each Contributor harmless for any liability
        +   incurred by, or claims asserted against, such Contributor by reason
        +   of your accepting any such warranty or additional liability.
        +
        +END OF TERMS AND CONDITIONS
        +
        +APPENDIX: How to apply the Apache License to your work.
        +
        +   To apply the Apache License to your work, attach the following
        +   boilerplate notice, with the fields enclosed by brackets "[]"
        +   replaced with your own identifying information. (Don't include
        +   the brackets!)  The text should be enclosed in the appropriate
        +   comment syntax for the file format. We also recommend that a
        +   file or class name and description of purpose be included on the
        +   same "printed page" as the copyright notice for easier
        +   identification within third-party archives.
        +
        +Copyright [yyyy] [name of copyright owner]
        +
        +Licensed under the Apache License, Version 2.0 (the "License");
        +you may not use this file except in compliance with the License.
        +You may obtain a copy of the License at
        +
        +	http://www.apache.org/licenses/LICENSE-2.0
        +
        +Unless required by applicable law or agreed to in writing, software
        +distributed under the License is distributed on an "AS IS" BASIS,
        +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
        +See the License for the specific language governing permissions and
        +limitations under the License.
      • Apache License 2.0

        Used by:

        @@ -5422,11 +5634,11 @@

        Used by:

      • block-buffer 0.10.4
      • block-buffer 0.12.1
      • blowfish 0.9.1
      • -
      • chacha20 0.10.1
      • +
      • chacha20 0.10.2
      • cipher 0.4.4
      • const-oid 0.10.2
      • cpufeatures 0.2.17
      • -
      • cpufeatures 0.3.0
      • +
      • cpufeatures 0.3.1
      • crypto-common 0.1.7
      • crypto-common 0.2.2
      • ctr 0.9.2
      • @@ -5435,7 +5647,7 @@

        Used by:

      • ghash 0.5.1
      • hmac 0.12.1
      • hmac 0.13.0
      • -
      • hybrid-array 0.4.14
      • +
      • hybrid-array 0.4.15
      • inout 0.1.4
      • md-5 0.10.6
      • md-5 0.11.0
      • @@ -6063,7 +6275,7 @@

        Used by:

                                      Apache License
        @@ -6483,7 +6695,7 @@ 

        Used by:

        Apache License 2.0

        Used by:

                                      Apache License
                                 Version 2.0, January 2004
        @@ -7124,49 +7336,51 @@ 

        Used by:

      • extenddb-storage 0.1.12
      • extenddb-storage-mongodb 0.1.12
      • allocator-api2 0.2.21
      • -
      • anyhow 1.0.103
      • +
      • anyhow 1.0.104
      • arraydeque 0.5.1
      • -
      • async-trait 0.1.89
      • -
      • aws-lc-sys 0.41.0
      • +
      • async-trait 0.1.92
      • +
      • aws-lc-sys 0.45.0
      • daemonize 0.5.0
      • dlv-list 0.5.2
      • dunce 1.0.5
      • ident_case 1.0.1
      • itoa 1.0.18
      • -
      • libc 0.2.186
      • -
      • miniz_oxide 0.8.9
      • -
      • mongodb-internal-macros 3.8.0
      • -
      • mongodb 3.8.0
      • +
      • libc 0.2.189
      • +
      • miniz_oxide 0.9.1
      • +
      • mongodb-internal-macros 3.9.1
      • +
      • mongodb 3.9.1
      • num-conv 0.2.2
      • pin-project-lite 0.2.17
      • -
      • portable-atomic 1.13.1
      • -
      • proc-macro2 1.0.106
      • -
      • quote 1.0.45
      • -
      • rand 0.10.2
      • -
      • rand 0.8.6
      • -
      • rand 0.9.4
      • +
      • portable-atomic 1.15.0
      • +
      • proc-macro2 1.0.107
      • +
      • quote 1.0.47
      • +
      • rand 0.10.3
      • +
      • rand 0.8.8
      • +
      • rand 0.9.5
      • rand_chacha 0.9.0
      • -
      • rcgen 0.14.8
      • -
      • rustversion 1.0.22
      • +
      • rcgen 0.14.10
      • +
      • rustversion 1.0.23
      • ryu 1.0.23
      • semver 1.0.28
      • -
      • serde 1.0.228
      • +
      • serde 1.0.229
      • serde_bytes 0.11.19
      • -
      • serde_core 1.0.228
      • -
      • serde_derive 1.0.228
      • -
      • serde_json 1.0.150
      • +
      • serde_core 1.0.229
      • +
      • serde_derive 1.0.229
      • +
      • serde_json 1.0.151
      • serde_path_to_error 0.1.20
      • serde_urlencoded 0.7.1
      • -
      • shlex 1.3.0
      • -
      • syn 2.0.117
      • +
      • shlex 2.0.1
      • +
      • simdutf8 0.1.5
      • +
      • syn 2.0.119
      • +
      • syn 3.0.6
      • sync_wrapper 1.0.2
      • tagptr 0.2.0
      • -
      • thiserror-impl 2.0.18
      • -
      • thiserror 2.0.18
      • -
      • time-core 0.1.8
      • -
      • time-macros 0.2.27
      • -
      • time 0.3.47
      • -
      • unicode-ident 1.0.24
      • +
      • thiserror-impl 2.0.20
      • +
      • thiserror 2.0.20
      • +
      • time-core 0.1.9
      • +
      • time-macros 0.2.32
      • +
      • time 0.3.55
      • +
      • unicode-ident 1.0.26
      • utf8parse 0.2.2
      • whoami 1.6.1
      • yaml-rust2 0.8.1
      • @@ -7324,7 +7538,7 @@

        Used by:

        BSD 3-Clause "New" or "Revised" License

        Used by:

        Copyright (c) <year> <owner>. 
         
        @@ -7343,7 +7557,7 @@ 

        Used by:

        BSD 3-Clause "New" or "Revised" License

        Used by:

        Copyright © WHATWG (Apple, Google, Mozilla, Microsoft).
         
        @@ -7507,7 +7721,7 @@ 

        Community Data License Agreement Permissive 2.0

        Used by:
        # Community Data License Agreement - Permissive - Version 2.0
         
        @@ -7639,7 +7853,7 @@ 

        Used by:

        ISC License

        Used by:

        Except as otherwise noted, this project is licensed under the following
         (ISC-style) terms:
        @@ -7666,8 +7880,8 @@ 

        Used by:

        ISC License

        Used by:

        ISC License:
         
        @@ -7711,7 +7925,7 @@ 

        Used by:

        MIT License

        Used by:

        Copyright (c) 2014 Carl Lerche and other MIO contributors
         
        @@ -7766,7 +7980,7 @@ 

        Used by:

        MIT License

        Used by:

        Copyright (c) 2014-2026 Sean McArthur
         
        @@ -7793,7 +8007,7 @@ 

        Used by:

        MIT License

        Used by:

        Copyright (c) 2017 h2 authors
         
        @@ -7826,7 +8040,7 @@ 

        Used by:

        MIT License

        Used by:

        Copyright (c) 2018 Carl Lerche
         
        @@ -8085,42 +8299,10 @@ 

        Used by:

        MIT License

        Used by:

        -
        Copyright (c) 2019-2024 Sean McArthur & Hyper Contributors
        -
        -Permission is hereby granted, free of charge, to any
        -person obtaining a copy of this software and associated
        -documentation files (the "Software"), to deal in the
        -Software without restriction, including without
        -limitation the rights to use, copy, modify, merge,
        -publish, distribute, sublicense, and/or sell copies of
        -the Software, and to permit persons to whom the Software
        -is furnished to do so, subject to the following
        -conditions:
        -
        -The above copyright notice and this permission notice
        -shall be included in all copies or substantial portions
        -of the Software.
        -
        -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
        -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
        -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
        -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
        -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
        -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
        -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
        -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
        -DEALINGS IN THE SOFTWARE.
        -
        - -
      • -

        MIT License

        -

        Used by:

        - -
        Copyright (c) 2019-2025 Sean McArthur & Hyper Contributors
        +                
        Copyright (c) 2019-2026 Sean McArthur & Hyper Contributors
         
         Permission is hereby granted, free of charge, to any
         person obtaining a copy of this software and associated
        @@ -8205,7 +8387,7 @@ 

        Used by:

        MIT License

        Used by:

        Copyright 2016 Nika Layzell
         
        @@ -8376,9 +8558,9 @@ 

        Used by:

        MIT License

        Used by:

        MIT License
         
        @@ -8495,7 +8677,7 @@ 

        Used by:

        MIT License

        Used by:

        MIT License
         
        @@ -8729,9 +8911,9 @@ 

        Used by:

        MIT License

        Used by:

        MIT License
         
        @@ -8760,7 +8942,7 @@ 

        Used by:

        MIT License

        Used by:

        MIT License
         
        @@ -8790,7 +8972,7 @@ 

        MIT License

        Used by:

        Permission is hereby granted, free of charge, to any
         person obtaining a copy of this software and associated
        @@ -8865,7 +9047,7 @@ 

        MIT License

        Used by:

        The MIT License (MIT)
         
        @@ -8984,7 +9166,7 @@ 

        Used by:

        MIT License

        Used by:

        The MIT License (MIT)
         
        @@ -9014,7 +9196,7 @@ 

        Used by:

        MIT License

        Used by:

        The MIT License (MIT)
         
        @@ -9074,7 +9256,7 @@ 

        Used by:

        MIT License

        Used by:

        The MIT License (MIT)
         
        @@ -9249,7 +9431,7 @@ 

        Used by:

        Unicode License v3

        Used by:

        UNICODE LICENSE V3
         
        @@ -9296,24 +9478,24 @@ 

        Used by:

        Unicode License v3

        Used by:

        UNICODE LICENSE V3
         
        diff --git a/devtools/generate-software-license-notices b/devtools/generate-software-license-notices
        index f3136968..80c37d94 100755
        --- a/devtools/generate-software-license-notices
        +++ b/devtools/generate-software-license-notices
        @@ -28,7 +28,7 @@ while [[ $# -gt 0 ]]; do
                     shift 2
                     ;;
                 *)
        -            echo "usage: devtools/generate-software-license-notices [--check] [--backend postgres|mongodb]" >&2
        +            echo "usage: devtools/generate-software-license-notices [--check] [--backend postgres|mongodb|cassandra]" >&2
                     exit 2
                     ;;
             esac
        @@ -36,8 +36,9 @@ done
         case "$BACKEND" in
             postgres) OUTPUT="SOFTWARE-LICENSE-NOTICES.html" ;;
             mongodb) OUTPUT="SOFTWARE-LICENSE-NOTICES-MONGODB.html" ;;
        +    cassandra) OUTPUT="SOFTWARE-LICENSE-NOTICES-CASSANDRA.html" ;;
             *)
        -        echo "error: --backend must be 'postgres' or 'mongodb'" >&2
        +        echo "error: --backend must be 'postgres', 'mongodb', or 'cassandra'" >&2
                 exit 2
                 ;;
         esac
        
        From 0c4d0765fa894196c1964fb67d32d37d934dbe31 Mon Sep 17 00:00:00 2001
        From: Joel Shepherd 
        Date: Wed, 23 Sep 2026 21:12:56 +0000
        Subject: [PATCH 45/48] chore(storage-cassandra): add license notice check for
         Cassandra
        
        ---
         .github/workflows/licenses.yml | 2 ++
         1 file changed, 2 insertions(+)
        
        diff --git a/.github/workflows/licenses.yml b/.github/workflows/licenses.yml
        index 75d1ff2a..cc05783b 100644
        --- a/.github/workflows/licenses.yml
        +++ b/.github/workflows/licenses.yml
        @@ -62,6 +62,8 @@ jobs:
                 run: ./devtools/generate-software-license-notices --check
               - name: MongoDB notices must be current
                 run: ./devtools/generate-software-license-notices --check --backend mongodb
        +      - name: Cassandra notices must be current
        +        run: ./devtools/generate-software-license-notices --check --backend cassandra
               - name: DEV notices must be current
                 # The dev image ships SOFTWARE-LICENSE-NOTICES-DEV.html for its own
                 # feature set (sqlite,dev-mode). Same incident class as #271/#272: a
        
        From a84012ca5e6320ed0b7af6985f51b55ab6c4482f Mon Sep 17 00:00:00 2001
        From: Joel Shepherd 
        Date: Mon, 28 Sep 2026 21:29:43 +0000
        Subject: [PATCH 46/48] ci: add Cassandra CI workflow and test runner
        
        ---
         .github/workflows/integration-cassandra.yml | 209 ++++++++++++++++++++
         devtools/run-cassandra-tests                | 209 ++++++++++++++++++++
         devtools/run-tests                          |   4 +-
         3 files changed, 420 insertions(+), 2 deletions(-)
         create mode 100644 .github/workflows/integration-cassandra.yml
         create mode 100755 devtools/run-cassandra-tests
        
        diff --git a/.github/workflows/integration-cassandra.yml b/.github/workflows/integration-cassandra.yml
        new file mode 100644
        index 00000000..49922b1b
        --- /dev/null
        +++ b/.github/workflows/integration-cassandra.yml
        @@ -0,0 +1,209 @@
        +# Copyright 2026 ExtendDB contributors
        +# SPDX-License-Identifier: Apache-2.0
        +name: Cassandra Integration Tests
        +
        +on:
        +  pull_request:
        +  merge_group:
        +  push:
        +    branches: [main, feat/storage-cassandra-in-tree]
        +
        +permissions:
        +  contents: read
        +
        +# The Cassandra backend's integration tests (crates/storage-cassandra/tests)
        +# talk to a live node at 127.0.0.1:9042 with cassandra/cassandra credentials
        +# and are not feature-gated — without this workflow they never run in CI and
        +# every green result is a local claim. The TTL claim protocol in particular
        +# fails silently if the stored item encoding drifts, so these tests are the
        +# only automated signal for a whole class of regressions.
        +#
        +# A single-node container is enough: the tests create their own keyspaces
        +# (RF=1) and run serially because the sweep and reconciliation passes are
        +# global.
        +#
        +# The cassandra:4.1 image does not honour CASSANDRA_AUTHENTICATOR, so each job
        +# patches cassandra.yaml inside the running container and restarts it before
        +# running any tests. The health-check on the service container uses the default
        +# AllowAllAuthenticator credentials (no auth challenge), so the container is
        +# healthy before the patch step runs; after the restart a second wait loop
        +# confirms the node is back up with PasswordAuthenticator active.
        +
        +jobs:
        +  cassandra-storage:
        +    runs-on: ubuntu-latest
        +    services:
        +      cassandra:
        +        image: cassandra:4.1
        +        ports:
        +          - 9042:9042
        +        env:
        +          CASSANDRA_CLUSTER_NAME: extenddb-ci
        +          HEAP_NEWSIZE: 128M
        +          MAX_HEAP_SIZE: 1024M
        +        options: >-
        +          --health-cmd "cqlsh -e 'SELECT release_version FROM system.local'"
        +          --health-interval 15s
        +          --health-timeout 10s
        +          --health-retries 20
        +          --health-start-period 60s
        +    steps:
        +      - uses: actions/checkout@v6
        +      - uses: dtolnay/rust-toolchain@stable
        +      - uses: Swatinem/rust-cache@v2
        +        with:
        +          cache-on-failure: true
        +      - name: Configure Cassandra authentication
        +        run: |
        +          docker exec ${{ job.services.cassandra.id }} sed -i \
        +            's/^authenticator: .*/authenticator: PasswordAuthenticator/' \
        +            /etc/cassandra/cassandra.yaml
        +          docker restart ${{ job.services.cassandra.id }}
        +          for i in $(seq 1 30); do
        +            if docker exec ${{ job.services.cassandra.id }} \
        +                cqlsh -u cassandra -p cassandra \
        +                -e 'SELECT release_version FROM system.local' \
        +                >/dev/null 2>&1; then
        +              echo "Cassandra ready with PasswordAuthenticator after ${i}s"
        +              exit 0
        +            fi
        +            sleep 2
        +          done
        +          echo "Cassandra did not become ready" >&2; exit 1
        +      - name: Unit tests
        +        run: cargo test -p extenddb-storage-cassandra --lib
        +      - name: TTL integration tests
        +        run: cargo test -p extenddb-storage-cassandra --test ttl_integration -- --test-threads=1
        +        env:
        +          EXTENDDB_TEST_CASSANDRA: required
        +      - name: Direct storage-trait integration tests
        +        # Ported from the plug-in repo's tests/rust suite. Parallel-safe:
        +        # each test provisions its own account and keyspace; the shared
        +        # control-plane setting test restores what it mutates.
        +        run: cargo test -p extenddb-storage-cassandra --test direct_integration
        +        env:
        +          EXTENDDB_TEST_CASSANDRA: required
        +
        +  cassandra-pytest:
        +    runs-on: ubuntu-latest
        +    services:
        +      cassandra:
        +        image: cassandra:4.1
        +        ports:
        +          - 9042:9042
        +        env:
        +          CASSANDRA_CLUSTER_NAME: extenddb-ci
        +          HEAP_NEWSIZE: 128M
        +          MAX_HEAP_SIZE: 1024M
        +        options: >-
        +          --health-cmd "cqlsh -e 'SELECT release_version FROM system.local'"
        +          --health-interval 15s
        +          --health-timeout 10s
        +          --health-retries 20
        +          --health-start-period 60s
        +    steps:
        +      - uses: actions/checkout@v6
        +      - uses: dtolnay/rust-toolchain@stable
        +      - uses: Swatinem/rust-cache@v2
        +        with:
        +          cache-on-failure: true
        +      - name: Configure Cassandra authentication
        +        run: |
        +          docker exec ${{ job.services.cassandra.id }} sed -i \
        +            's/^authenticator: .*/authenticator: PasswordAuthenticator/' \
        +            /etc/cassandra/cassandra.yaml
        +          docker restart ${{ job.services.cassandra.id }}
        +          for i in $(seq 1 30); do
        +            if docker exec ${{ job.services.cassandra.id }} \
        +                cqlsh -u cassandra -p cassandra \
        +                -e 'SELECT release_version FROM system.local' \
        +                >/dev/null 2>&1; then
        +              echo "Cassandra ready with PasswordAuthenticator after ${i}s"
        +              exit 0
        +            fi
        +            sleep 2
        +          done
        +          echo "Cassandra did not become ready" >&2; exit 1
        +      - uses: actions/setup-python@v5
        +        with:
        +          python-version: "3.11"
        +      - name: Install Python dependencies
        +        run: pip install -r requirements.txt
        +      - name: Build release (cassandra backend)
        +        run: cargo build --release --no-default-features --features cassandra
        +      - name: Run Cassandra pytest suite
        +        run: devtools/run-cassandra-tests -- --pytest --comprehensive --parallel
        +
        +  cassandra-rust:
        +    runs-on: ubuntu-latest
        +    services:
        +      cassandra:
        +        image: cassandra:4.1
        +        ports:
        +          - 9042:9042
        +        env:
        +          CASSANDRA_CLUSTER_NAME: extenddb-ci
        +          HEAP_NEWSIZE: 128M
        +          MAX_HEAP_SIZE: 1024M
        +        options: >-
        +          --health-cmd "cqlsh -e 'SELECT release_version FROM system.local'"
        +          --health-interval 15s
        +          --health-timeout 10s
        +          --health-retries 20
        +          --health-start-period 60s
        +    steps:
        +      - uses: actions/checkout@v6
        +      - uses: dtolnay/rust-toolchain@stable
        +      - uses: Swatinem/rust-cache@v2
        +        with:
        +          cache-on-failure: true
        +      - name: Configure Cassandra authentication
        +        run: |
        +          docker exec ${{ job.services.cassandra.id }} sed -i \
        +            's/^authenticator: .*/authenticator: PasswordAuthenticator/' \
        +            /etc/cassandra/cassandra.yaml
        +          docker restart ${{ job.services.cassandra.id }}
        +          for i in $(seq 1 30); do
        +            if docker exec ${{ job.services.cassandra.id }} \
        +                cqlsh -u cassandra -p cassandra \
        +                -e 'SELECT release_version FROM system.local' \
        +                >/dev/null 2>&1; then
        +              echo "Cassandra ready with PasswordAuthenticator after ${i}s"
        +              exit 0
        +            fi
        +            sleep 2
        +          done
        +          echo "Cassandra did not become ready" >&2; exit 1
        +      - uses: actions/setup-python@v5
        +        with:
        +          python-version: "3.11"
        +      - name: Install Python dependencies
        +        run: pip install -r requirements.txt
        +      - name: Build release (cassandra backend)
        +        run: cargo build --release --no-default-features --features cassandra
        +      - name: Run Cassandra rust integration suite
        +        run: devtools/run-cassandra-tests -- --rust --rust-integration
        +
        +  cassandra-production-build:
        +    runs-on: ubuntu-latest
        +    steps:
        +      - uses: actions/checkout@v6
        +      - uses: dtolnay/rust-toolchain@stable
        +      - uses: Swatinem/rust-cache@v2
        +        with:
        +          cache-on-failure: true
        +      - name: Check production Cassandra feature graph
        +        run: cargo check --release --no-default-features --features cassandra
        +
        +  cassandra-integration:
        +    runs-on: ubuntu-latest
        +    needs: [cassandra-storage, cassandra-pytest, cassandra-rust, cassandra-production-build]
        +    if: always()
        +    steps:
        +      - run: |
        +          if [ "${{ needs.cassandra-storage.result }}" != "success" ] || \
        +             [ "${{ needs.cassandra-pytest.result }}" != "success" ] || \
        +             [ "${{ needs.cassandra-rust.result }}" != "success" ] || \
        +             [ "${{ needs.cassandra-production-build.result }}" != "success" ]; then
        +            exit 1
        +          fi
        diff --git a/devtools/run-cassandra-tests b/devtools/run-cassandra-tests
        new file mode 100755
        index 00000000..26cbfd85
        --- /dev/null
        +++ b/devtools/run-cassandra-tests
        @@ -0,0 +1,209 @@
        +#!/usr/bin/env bash
        +# Copyright 2026 ExtendDB contributors
        +# SPDX-License-Identifier: Apache-2.0
        +#
        +# Orchestrated Cassandra integration-test runner.
        +#
        +# Initializes and serves extenddb against a running Cassandra node, then
        +# delegates the test workload to `devtools/run-tests --backend cassandra`.
        +# Tears everything down on exit.
        +#
        +# Usage:
        +#   devtools/run-cassandra-tests [OPTIONS] [-- RUN_TESTS_ARGS ...]
        +#
        +# Options:
        +#   --contact-points HOST:PORT  Cassandra contact point (default: 127.0.0.1:9042)
        +#   --port PORT             HTTPS port extenddb should bind (default: 18443)
        +#   --output DIR            Directory for logs and generated config
        +#                           (default: /tmp/run-cassandra-tests-)
        +#   --keep                  Do not tear down the extenddb server on exit
        +#   -h, --help              Show this help
        +#
        +# Arguments after `--` are passed to `devtools/run-tests` verbatim.
        +# Default suite is `--pytest --comprehensive --parallel`.
        +#
        +# Examples:
        +#   devtools/run-cassandra-tests
        +#   devtools/run-cassandra-tests -- --pytest --filter test_put_item
        +#   devtools/run-cassandra-tests --keep -- --rust --rust-integration
        +#
        +# Prerequisites:
        +#   - A Cassandra node reachable at the contact point (default 127.0.0.1:9042)
        +#     with PasswordAuthenticator enabled. The official cassandra:4.1 image does
        +#     not honour CASSANDRA_AUTHENTICATOR, so patch it after starting:
        +#       docker run -d --name cassandra -p 9042:9042 \
        +#         -e CASSANDRA_CLUSTER_NAME=extenddb-ci \
        +#         -e HEAP_NEWSIZE=128M -e MAX_HEAP_SIZE=1024M \
        +#         cassandra:4.1
        +#       sleep 30
        +#       docker exec cassandra sed -i \
        +#         's/^authenticator: .*/authenticator: PasswordAuthenticator/' \
        +#         /etc/cassandra/cassandra.yaml
        +#       docker restart cassandra && sleep 30
        +#   - `cargo build --release --no-default-features --features cassandra` done
        +#   - Python venv activated with pytest installed
        +#   - `~/.extenddb/tls/` populated (from a prior `extenddb init`)
        +
        +set -euo pipefail
        +
        +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
        +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
        +cd "$PROJECT_DIR"
        +
        +# ---------------------------------------------------------------------------
        +# Argument parsing
        +# ---------------------------------------------------------------------------
        +
        +CONTACT_POINTS="127.0.0.1:9042"
        +BIND_PORT=18443
        +OUTPUT_DIR=""
        +KEEP=false
        +RUN_TESTS_ARGS=()
        +
        +while [[ $# -gt 0 ]]; do
        +    case "$1" in
        +        --contact-points) CONTACT_POINTS="$2"; shift 2 ;;
        +        --port)           BIND_PORT="$2"; shift 2 ;;
        +        --output)         OUTPUT_DIR="$2"; shift 2 ;;
        +        --keep)           KEEP=true; shift ;;
        +        --)               shift; RUN_TESTS_ARGS=("$@"); break ;;
        +        -h|--help)        sed -n '5,35p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
        +        *)                echo "error: unknown option: $1" >&2; exit 1 ;;
        +    esac
        +done
        +
        +if [[ ${#RUN_TESTS_ARGS[@]} -eq 0 ]]; then
        +    RUN_TESTS_ARGS=(--pytest --comprehensive --parallel)
        +fi
        +
        +if [[ -z "$OUTPUT_DIR" ]]; then
        +    OUTPUT_DIR="/tmp/run-cassandra-tests-$(date +%Y%m%d-%H%M%S)-$$"
        +fi
        +mkdir -p "$OUTPUT_DIR"
        +
        +CONFIG="$OUTPUT_DIR/extenddb.toml"
        +SERVER_LOG="$OUTPUT_DIR/server.log"
        +BINARY="./target/release/extenddb"
        +
        +if [[ ! -x "$BINARY" ]]; then
        +    echo "error: $BINARY not found. Build first:" >&2
        +    echo "  cargo build --release --no-default-features --features cassandra" >&2
        +    exit 1
        +fi
        +
        +TMP_CANON=$(realpath /tmp)
        +
        +# ---------------------------------------------------------------------------
        +# Cleanup
        +# ---------------------------------------------------------------------------
        +
        +cleanup() {
        +    if $KEEP; then
        +        echo ""
        +        echo "=== --keep set; leaving extenddb running ==="
        +        echo "  server log: $SERVER_LOG"
        +        echo "  tear down:  $BINARY stop --config $CONFIG"
        +        return
        +    fi
        +    echo ""
        +    echo "=== Cleanup ==="
        +    if [[ -f "$CONFIG" ]] && "$BINARY" stop --config "$CONFIG" >/dev/null 2>&1; then
        +        echo "  stopped extenddb server"
        +    fi
        +}
        +trap cleanup EXIT
        +
        +# ---------------------------------------------------------------------------
        +# Initialize extenddb
        +# ---------------------------------------------------------------------------
        +
        +echo "=== Initializing extenddb (Cassandra backend) ==="
        +
        +ADMIN_PASSWORD=$(openssl rand -base64 16)
        +EXTENDDB_ADMIN_PASSWORD="$ADMIN_PASSWORD" "$BINARY" init \
        +    --backend cassandra \
        +    --config "$CONFIG" \
        +    --cassandra-contact-points "$CONTACT_POINTS" \
        +    --cassandra-user cassandra \
        +    --cassandra-pass cassandra \
        +    --overwrite 2>&1 | tail -3
        +
        +# Rewrite config with the port and paths the integration suite needs.
        +cat > "$CONFIG" <"$SERVER_LOG" 2>&1
        +
        +for i in $(seq 1 30); do
        +    if curl -sk "https://127.0.0.1:$BIND_PORT/health" >/dev/null 2>&1; then
        +        echo "  server healthy after ${i}s"
        +        break
        +    fi
        +    sleep 1
        +done
        +
        +if ! curl -sk "https://127.0.0.1:$BIND_PORT/health" >/dev/null 2>&1; then
        +    echo "error: extenddb server did not become healthy" >&2
        +    tail -30 "$SERVER_LOG" >&2
        +    exit 1
        +fi
        +
        +# ---------------------------------------------------------------------------
        +# Delegate to run-tests
        +# ---------------------------------------------------------------------------
        +
        +echo ""
        +echo "=== Running test suite via devtools/run-tests --backend cassandra ==="
        +echo "  passthrough args: ${RUN_TESTS_ARGS[*]}"
        +echo ""
        +
        +export EXTENDDB_TEST_ENDPOINT="https://127.0.0.1:$BIND_PORT"
        +export EXTENDDB_ADMIN_USER=admin
        +export EXTENDDB_ADMIN_PASSWORD="$ADMIN_PASSWORD"
        +export EXTENDDB_CONFIG="$CONFIG"
        +export TMPDIR="$TMP_CANON"
        +
        +RC=0
        +devtools/run-tests --extenddb --backend cassandra --config "$CONFIG" "${RUN_TESTS_ARGS[@]}" || RC=$?
        +
        +exit $RC
        diff --git a/devtools/run-tests b/devtools/run-tests
        index fd981c87..4e9091e0 100755
        --- a/devtools/run-tests
        +++ b/devtools/run-tests
        @@ -135,9 +135,9 @@ done
         
         # --- Validate: backend name ---
         case "$BACKEND" in
        -    postgres|sqlite|mongodb) ;;
        +    postgres|sqlite|mongodb|cassandra) ;;
             *)
        -        echo "error: --backend must be 'postgres', 'sqlite' or 'mongodb' (got: $BACKEND)"
        +        echo "error: --backend must be 'postgres', 'sqlite', 'mongodb' or 'cassandra' (got: $BACKEND)"
                 exit 1
                 ;;
         esac
        
        From 979ba4da9caef49a23630caefa1a8e3a4e77ee92 Mon Sep 17 00:00:00 2001
        From: Joel Shepherd 
        Date: Tue, 29 Sep 2026 18:04:26 +0000
        Subject: [PATCH 47/48] fix(storage-cassandra): Unconditional deletes do not
         pass through OCC, query scan follows Amazon DDB key sorting rules in both
         directions (mirroring https://github.com/ExtendDB/extenddb/pull/362 for
         PostgreSQL and SQLite).
        
        ---
         .../storage-cassandra/src/data/delete_item.rs |  32 ++---
         crates/storage-cassandra/src/data/query.rs    | 132 +++++++++---------
         2 files changed, 78 insertions(+), 86 deletions(-)
        
        diff --git a/crates/storage-cassandra/src/data/delete_item.rs b/crates/storage-cassandra/src/data/delete_item.rs
        index a6bf3026..08c1c297 100644
        --- a/crates/storage-cassandra/src/data/delete_item.rs
        +++ b/crates/storage-cassandra/src/data/delete_item.rs
        @@ -281,18 +281,10 @@ impl CassandraEngine {
                     } else if ttl_config.is_some() {
                         self.acquire_ttl_mutation_claim(key_info, key, old_item_opt.as_ref())
                             .await?
        -            } else {
        -                // No TTL claim and no transaction owner: use an OCC LWT to fence
        -                // concurrent writers. Without this, a concurrent UpdateItem that
        -                // read the same pre-image can race the plain DELETE.
        +            } else if condition.is_some() {
        +                // Conditional delete: use an OCC LWT to atomically delete only
        +                // if the row hasn't changed since we read and checked it.
                         if old_item_opt.is_some() {
        -                    // The transaction prepare path consults this high-water mark to
        -                    // order new-item PUTs against deletes in the same partition; the
        -                    // claimed and transactional delete paths write it before their
        -                    // delete, and this fast path must too. Written before the fence:
        -                    // if the fence then refuses, a spuriously advanced mark is
        -                    // harmless (it only widens a conservative check), while the
        -                    // reverse order can lose the mark on a crash between the two.
                             self.update_partition_max_delete_timestamp_at(
                                 &data_keyspace,
                                 &ddb_table,
        @@ -310,7 +302,6 @@ impl CassandraEngine {
                             if !fence_applied {
                                 return Err(StorageError::ConditionFailed(old_item_opt));
                             }
        -                    // LWT deleted the row; run secondary effects (indexes, stream) if needed.
                             if !indexes.is_empty() || stream.is_some() {
                                 self.delete_item_secondary_effects(
                                     &data_keyspace,
        @@ -325,6 +316,8 @@ impl CassandraEngine {
                             return Ok(if return_old { old_item_opt } else { None });
                         }
                         None
        +            } else {
        +                None
                     };
                     let mutation_timestamp = chrono::Utc::now().timestamp_micros();
         
        @@ -533,17 +526,10 @@ impl CassandraEngine {
                     } else if ttl_config.is_some() {
                         self.acquire_ttl_mutation_claim(key_info, key, old_item_opt.as_ref())
                             .await?
        -            } else {
        -                // No TTL claim and no transaction owner: use an OCC LWT to fence
        -                // concurrent writers.
        +            } else if condition.is_some() {
        +                // Conditional delete: use an OCC LWT to atomically delete only
        +                // if the row hasn't changed since we read and checked it.
                         if old_item_opt.is_some() {
        -                    // The transaction prepare path consults this high-water mark to
        -                    // order new-item PUTs against deletes in the same partition; the
        -                    // claimed and transactional delete paths write it before their
        -                    // delete, and this fast path must too. Written before the fence:
        -                    // if the fence then refuses, a spuriously advanced mark is
        -                    // harmless (it only widens a conservative check), while the
        -                    // reverse order can lose the mark on a crash between the two.
                             self.update_partition_max_delete_timestamp_at(
                                 &data_keyspace,
                                 &ddb_table,
        @@ -575,6 +561,8 @@ impl CassandraEngine {
                             return Ok(if return_old { old_item_opt } else { None });
                         }
                         None
        +            } else {
        +                None
                     };
                     // Pinned here, immediately after the claim and before any further
                     // await, so it is strictly newer than the image this request owns
        diff --git a/crates/storage-cassandra/src/data/query.rs b/crates/storage-cassandra/src/data/query.rs
        index b3e59587..35f66c4f 100644
        --- a/crates/storage-cassandra/src/data/query.rs
        +++ b/crates/storage-cassandra/src/data/query.rs
        @@ -250,8 +250,8 @@ impl CassandraEngine {
                         let base_pk_attr = &key_info.base_key_schema[0].attribute_name;
                         if start_key.get(base_pk_attr).is_some() {
                             if !needs_two_query_pagination {
        -                        // Will be handled in Query 2 section for two-query path
        -                        query.push_str(" AND base_pk > ?");
        +                        let cmp = if forward { ">" } else { "<" };
        +                        query.push_str(&format!(" AND base_pk {cmp} ?"));
                             }
                             None // Returning None, will use base_pk from start_key in execution
                         } else {
        @@ -272,6 +272,23 @@ impl CassandraEngine {
                         let sk_col = sk_column(sk_type);
                         let direction = if forward { "ASC" } else { "DESC" };
                         query.push_str(&format!(" ORDER BY {sk_col} {direction}"));
        +                if index_name.is_some() {
        +                    // base_pk is a clustering key after the index SK; include it
        +                    // so ties are broken consistently and the cursor is stable.
        +                    query.push_str(&format!(", base_pk {direction}"));
        +                    if let Some((_, base_sk_type)) = &base_sk_info_val {
        +                        let base_sk_col = format!("base_{}", sk_column(*base_sk_type));
        +                        query.push_str(&format!(", {base_sk_col} {direction}"));
        +                    }
        +                }
        +            } else if is_hash_only_index {
        +                // No index SK — clustering order is (base_pk, base_sk_*).
        +                let direction = if forward { "ASC" } else { "DESC" };
        +                query.push_str(&format!(" ORDER BY base_pk {direction}"));
        +                if let Some((_, base_sk_type)) = &base_sk_info_val {
        +                    let base_sk_col = format!("base_{}", sk_column(*base_sk_type));
        +                    query.push_str(&format!(", {base_sk_col} {direction}"));
        +                }
                     }
         
                     // Step 6: Add LIMIT (fetch limit + 1 to detect pagination)
        @@ -495,13 +512,17 @@ impl CassandraEngine {
                                 &pagination_binds
                             {
                                 let base_sk_col = format!("base_{}", sk_column(base_sk.scalar_type()));
        +                        let base_sk_cmp = if forward { ">" } else { "<" };
        +                        let dir = if forward { "ASC" } else { "DESC" };
        +                        // base_pk is equality-constrained so omit it from ORDER BY;
        +                        // only base_sk_* needs direction.
                                 format!(
        -                            "{} AND base_pk = ? AND {} > ? ORDER BY base_pk {}, {} {} LIMIT {}",
        +                            "{} AND base_pk = ? AND {} {} ? ORDER BY {} {} LIMIT {}",
                                     query,
                                     base_sk_col,
        -                            if forward { "ASC" } else { "DESC" },
        +                            base_sk_cmp,
                                     base_sk_col,
        -                            if forward { "ASC" } else { "DESC" },
        +                            dir,
                                     fetch_limit
                                 )
                             } else {
        @@ -539,14 +560,14 @@ impl CassandraEngine {
                         _ => {}
                     }
         
        -            // Query 2: next SK values (only if we haven't reached limit yet)
        -            if all_rows.len() < fetch_limit
        -                && sk_info_opt.is_some()
        -                && let Some(start_sk) = start_sk
        -            {
        +            // Query 2: advance past the cursor's base_pk (hash-only index with base SK)
        +            // or advance past the cursor's index SK (index with SK).
        +            if all_rows.len() < fetch_limit {
                         let remaining = fetch_limit - all_rows.len();
        -                let query2 = if let Some((_, sk_type)) = sk_info_opt {
        -                    let sk_col = sk_column(sk_type);
        +
        +                if let Some(start_sk) = start_sk {
        +                    // Index has a sort key — advance past start_sk.
        +                    let sk_col = sk_column(sk_info_opt.unwrap().1);
                             let cmp = if forward { ">" } else { "<" };
                             let order_clause = if let Some((_, base_sk_type)) = &base_sk_info_val {
                                 let base_sk_col = format!("base_{}", sk_column(*base_sk_type));
        @@ -556,69 +577,52 @@ impl CassandraEngine {
                                 )
                             } else {
                                 format!(
        -                            " ORDER BY {} {}, base_pk {} LIMIT {}",
        -                            sk_col,
        +                            " ORDER BY {sk_col} {}, base_pk {} LIMIT {remaining}",
                                     if forward { "ASC" } else { "DESC" },
                                     if forward { "ASC" } else { "DESC" },
        -                            remaining
                                 )
                             };
        -                    format!("{query} AND {sk_col} {cmp} ?{order_clause}")
        -                } else {
        -                    // Hash-only index
        -                    let order_clause = if let Some((_, base_sk_type)) = &base_sk_info_val {
        +                    let query2 = format!("{query} AND {sk_col} {cmp} ?{order_clause}");
        +                    let rows2 = query_with_pk_sk(
        +                        &self.session_arc(),
        +                        &query2,
        +                        &pk_text,
        +                        start_sk,
        +                        "next_sk",
        +                    )
        +                    .await?;
        +                    all_rows.extend(rows2);
        +                } else if let PaginationBinds::BasePkOnly {
        +                    pk_text: base_pk_text,
        +                }
        +                | PaginationBinds::BasePkAndSk {
        +                    pk_text: base_pk_text,
        +                    ..
        +                } = &pagination_binds
        +                {
        +                    // Hash-only index — advance past cursor's base_pk.
        +                    let dir = if forward { "ASC" } else { "DESC" };
        +                    let base_pk_cmp = if forward { ">" } else { "<" };
        +                    let query2 = if let Some((_, base_sk_type)) = &base_sk_info_val {
                                 let base_sk_col = format!("base_{}", sk_column(*base_sk_type));
                                 format!(
        -                            " ORDER BY base_pk {}, {} {} LIMIT {}",
        -                            if forward { "ASC" } else { "DESC" },
        -                            base_sk_col,
        -                            if forward { "ASC" } else { "DESC" },
        -                            remaining
        +                            "{query} AND base_pk {base_pk_cmp} ? ORDER BY base_pk {dir}, {base_sk_col} {dir} LIMIT {remaining}"
                                 )
                             } else {
                                 format!(
        -                            " ORDER BY base_pk {} LIMIT {}",
        -                            if forward { "ASC" } else { "DESC" },
        -                            remaining
        +                            "{query} AND base_pk {base_pk_cmp} ? ORDER BY base_pk {dir} LIMIT {remaining}"
                                 )
                             };
        -                    format!("{query} AND base_pk > ?{order_clause}")
        -                };
        -
        -                let rows2 = match &pagination_binds {
        -                    PaginationBinds::BaseSkOnly { .. }
        -                    | PaginationBinds::BasePkOnly { .. }
        -                    | PaginationBinds::BasePkAndSk { .. }
        -                        if sk_info_opt.is_some() =>
        -                    {
        -                        query_with_pk_sk(
        -                            &self.session_arc(),
        -                            &query2,
        -                            &pk_text,
        -                            start_sk,
        -                            "next_sk",
        -                        )
        -                        .await?
        -                    }
        -                    PaginationBinds::BasePkOnly {
        -                        pk_text: base_pk_text,
        -                    }
        -                    | PaginationBinds::BasePkAndSk {
        -                        pk_text: base_pk_text,
        -                        ..
        -                    } => {
        -                        cassandra_util::query_rows(
        -                            &self.session_arc(),
        -                            &query2,
        -                            cdrs_tokio::query_values!(pk_text.as_str(), base_pk_text.as_str()),
        -                            "next_sk",
        -                        )
        -                        .await?
        -                    }
        -                    _ => Vec::new(),
        -                };
        -                all_rows.extend(rows2);
        -            } // end if let Some(start_sk)
        +                    let rows2 = cassandra_util::query_rows(
        +                        &self.session_arc(),
        +                        &query2,
        +                        cdrs_tokio::query_values!(pk_text.as_str(), base_pk_text.as_str()),
        +                        "next_base_pk",
        +                    )
        +                    .await?;
        +                    all_rows.extend(rows2);
        +                }
        +            }
         
                     all_rows
                 } else {
        
        From 71ae9b4001a2c92db1cea2f9443a01317f8be539 Mon Sep 17 00:00:00 2001
        From: Joel Shepherd 
        Date: Tue, 29 Sep 2026 19:38:54 +0000
        Subject: [PATCH 48/48] chore: cargo fmt
        
        ---
         crates/storage-cassandra/src/data/query.rs | 7 +------
         1 file changed, 1 insertion(+), 6 deletions(-)
        
        diff --git a/crates/storage-cassandra/src/data/query.rs b/crates/storage-cassandra/src/data/query.rs
        index 35f66c4f..65b0c242 100644
        --- a/crates/storage-cassandra/src/data/query.rs
        +++ b/crates/storage-cassandra/src/data/query.rs
        @@ -518,12 +518,7 @@ impl CassandraEngine {
                                 // only base_sk_* needs direction.
                                 format!(
                                     "{} AND base_pk = ? AND {} {} ? ORDER BY {} {} LIMIT {}",
        -                            query,
        -                            base_sk_col,
        -                            base_sk_cmp,
        -                            base_sk_col,
        -                            dir,
        -                            fetch_limit
        +                            query, base_sk_col, base_sk_cmp, base_sk_col, dir, fetch_limit
                                 )
                             } else {
                                 format!(