Problem Statement: Introducing any form of statefulness to an application introduces a new layer of complexity during deployment and operations. A cache such as Redis is stateful and has a relatively low cost in maintenance; the cache can simply be thrown away and restarted upon deployment of the application. A database, however, can lead to many problems at deployment as the database schema and the data may require a migration upon release of a new version of the application. The database needs to be versioned and needs to support rollbacks in the application without rolling back the database. In a cloud native environment, it may seem trivial to rollback the database, however, the rollbacks cannot easily capture data-lossy transitions. Also, testing rollback scenarios is extremely difficult as it requires a combinatorial explosion of test cases. Developers, therefore, rarely test rollback behavior.
Solution: Build a database repository (dbrepo) in Rust to expose different versions of gRPC endpoints to a single target database. This allows migrating the database more broadly against the application versions.
Architecture:
- SQLx: Use the SQLx (https://github.com/launchbadge/sqlx) crate to compile SQL queries for PostgreSQL in source. Write migrations as standard SQL DDL commands (as SQLx recommends). Avoid ORMs because they do not discourage developers from issuing queries from within loops. Also, ORMs provide a whole new DSL to learn and maintain (https://github.com/launchbadge/sqlx?tab=readme-ov-file#sqlx-is-not-an-orm).
- Tonic: Use the tonic crate (https://github.com/hyperium/tonic) to provide the gRPC endpoints for other services in the namespaces. gRPC (https://grpc.io/) is a highly performant protocol and provides well-typed interfaces via protobuf files. Tonic provides the code generation of the interface traits and types via prost (https://github.com/tokio-rs/prost).
When a new version of a consuming client is under development, introduce a newly versioned protobuf file. The dbrepo then migrates the database to accommodate both versions at the same time and maps each version into a single target db module. The gRPC client developers are then free to build their application with their language of choice. You now have the freedom to rollback the client without risk to the database release and transitively to other services that depend on the dbrepo.
The code in the dbrepo is organized as follows. [Note the **/mod.rs have been omitted for brevity.] The terms “app” and “manager” relate to example schemas in PostgreSQL for organizing tables.
$ tree
.
├── Cargo.toml
├── Dockerfile
├── Migrations.Dockerfile
├── build.rs
├── crates
│ └── migrations
│ ├── Cargo.toml
│ └── src
│ └── main.rs
├── migrations
│ ├── 0001_init.down.sql
│ └── 0001_init.up.sql
├── proto
│ ├── app
│ │ └── v1.proto
│ └── manager
│ ├── v1.proto
│ └── v2.proto
├── src
│ ├── api
│ │ ├── app
│ │ │ └── v1.rs
│ │ └── manager
│ │ ├── v1.rs
│ │ └── v2.rs
│ ├── core
│ │ ├── error.rs
│ │ └── sql
│ │ ├── app.rs
│ │ └── manager.rs
│ ├── io
│ │ └── db
│ │ ├── appdb.rs
│ │ └── managerdb.rs
│ ├── lib.rs
│ └── main.rs
├── target
└── tests
├── integration.rs
└── real
├── appdb.rs
└── managerdb.rs
Notes
- The migrations crate is a secondary crate with full DDL permissions against the PostgreSQL database when deployed in its own docker container with the supplied
migrations/*.sql files. - The
migrations/*are written in standard SQL for PostgreSQL. - The dbrepo crate is built as a docker image and only given DML permissions at runtime in the PostgreSQL db.
- The
build.rsscript provides the pre-built gRPC endpoints via prost for tonic. - The
proto/**.proto(protobuf) files provide the versioned interfaces for the consuming clients. - The
src/api/**.rsmodules provide the mapping of the gRPC endpoints from tonic to thecore/sql/**.rsin-memory types. - The
core/sql/**.rsmodules provide Rust structs that bind to the db queries at compile time via sqlx and traits of the IO for dependency injection. - The
io/db/*.rsmodules provide the runtime IO implementations (impls) of the traits defined in thecore/sql/*.rsto the database via sqlx. Theio/db/*.rsmodules provide the queries and the command execution against the live database. - The
lib.rsrolls up the modules to provide a public interface for bothmain.rsand the integration tests. - The
main.rsmodule provides the entrypoint and construction of the runtime implementations (impl) with the IO. - The
tests/integration.rsprovides tests with a fake, in-memory implementation (impl) of thecore/sql/*.rsmodule traits. - The
tests/real/*.rsprovide real tests against a running db. The database instances are managed by the Testcontainers for Rust (https://rust.testcontainers.org/) crate. This crate spins up PostgreSQL containers for each test.