Skip to main content

aster_forge_db/
runtime_lease.rs

1//! Database-backed runtime lease store.
2//!
3//! Runtime leases coordinate process-level singleton components across service
4//! instances. The table is deliberately small and infrastructure-owned: it
5//! records the current owner of a lease key and the timestamp at which another
6//! process may take over. Product crates still decide which worker groups are
7//! singleton and run their own migrations; this module provides the shared
8//! entity, store implementation, and stable table/column names.
9
10use aster_forge_runtime::{
11    RuntimeLeaseAcquire, RuntimeLeaseClaim, RuntimeLeaseOwner, RuntimeLeaseStore,
12};
13use sea_orm::entity::prelude::*;
14use sea_orm::sea_query::{Alias, ColumnDef, Table, TableCreateStatement, TableDropStatement};
15use sea_orm::{
16    ActiveModelTrait, ColumnTrait, Condition, DatabaseBackend, DatabaseConnection, EntityTrait,
17    QueryFilter, Set, sea_query::Expr,
18};
19
20use crate::DbError;
21
22/// Runtime lease table name.
23pub const RUNTIME_LEASES_TABLE: &str = "runtime_leases";
24/// Runtime lease identifier column name.
25pub const RUNTIME_LEASE_ID_COLUMN: &str = "lease_id";
26/// Runtime lease owner column name.
27pub const RUNTIME_LEASE_OWNER_ID_COLUMN: &str = "owner_id";
28/// Runtime lease expiry column name.
29pub const RUNTIME_LEASE_EXPIRES_AT_COLUMN: &str = "expires_at";
30/// Runtime lease last-renewed column name.
31pub const RUNTIME_LEASE_LAST_RENEWED_AT_COLUMN: &str = "last_renewed_at";
32/// Runtime lease created-at column name.
33pub const RUNTIME_LEASE_CREATED_AT_COLUMN: &str = "created_at";
34/// Runtime lease updated-at column name.
35pub const RUNTIME_LEASE_UPDATED_AT_COLUMN: &str = "updated_at";
36
37/// Builds the shared `runtime_leases` table creation statement.
38///
39/// Product migration crates should call this helper instead of duplicating the
40/// table shape. Forge owns this table contract because [`RuntimeLeaseDbStore`]
41/// owns its row semantics and update rules.
42pub fn create_runtime_leases_table(backend: DatabaseBackend) -> TableCreateStatement {
43    Table::create()
44        .table(runtime_leases_table())
45        .if_not_exists()
46        .col(
47            ColumnDef::new(runtime_lease_id())
48                .string_len(191)
49                .not_null()
50                .primary_key(),
51        )
52        .col(
53            ColumnDef::new(runtime_lease_owner_id())
54                .string_len(191)
55                .not_null(),
56        )
57        .col(utc_datetime_column(backend, runtime_lease_expires_at()).not_null())
58        .col(utc_datetime_column(backend, runtime_lease_last_renewed_at()).not_null())
59        .col(utc_datetime_column(backend, runtime_lease_created_at()).not_null())
60        .col(utc_datetime_column(backend, runtime_lease_updated_at()).not_null())
61        .to_owned()
62}
63
64/// Builds the shared `runtime_leases` table drop statement.
65pub fn drop_runtime_leases_table() -> TableDropStatement {
66    Table::drop()
67        .table(runtime_leases_table())
68        .if_exists()
69        .to_owned()
70}
71
72fn runtime_leases_table() -> Alias {
73    Alias::new(RUNTIME_LEASES_TABLE)
74}
75
76fn runtime_lease_id() -> Alias {
77    Alias::new(RUNTIME_LEASE_ID_COLUMN)
78}
79
80fn runtime_lease_owner_id() -> Alias {
81    Alias::new(RUNTIME_LEASE_OWNER_ID_COLUMN)
82}
83
84fn runtime_lease_expires_at() -> Alias {
85    Alias::new(RUNTIME_LEASE_EXPIRES_AT_COLUMN)
86}
87
88fn runtime_lease_last_renewed_at() -> Alias {
89    Alias::new(RUNTIME_LEASE_LAST_RENEWED_AT_COLUMN)
90}
91
92fn runtime_lease_created_at() -> Alias {
93    Alias::new(RUNTIME_LEASE_CREATED_AT_COLUMN)
94}
95
96fn runtime_lease_updated_at() -> Alias {
97    Alias::new(RUNTIME_LEASE_UPDATED_AT_COLUMN)
98}
99
100fn utc_datetime_column(backend: DatabaseBackend, column: Alias) -> ColumnDef {
101    let mut definition = ColumnDef::new(column);
102    match backend {
103        DatabaseBackend::MySql => {
104            definition.custom(Alias::new("datetime(6)"));
105        }
106        _ => {
107            definition.timestamp_with_time_zone();
108        }
109    }
110    definition
111}
112
113/// SeaORM model for `runtime_leases`.
114#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
115#[sea_orm(table_name = "runtime_leases")]
116pub struct Model {
117    /// Stable lease key shared by all service instances.
118    #[sea_orm(primary_key, auto_increment = false)]
119    pub lease_id: String,
120    /// Owner identifier stored by the active process.
121    pub owner_id: String,
122    /// Timestamp after which another owner may take over.
123    pub expires_at: DateTimeUtc,
124    /// Timestamp of the last successful acquisition or renewal.
125    pub last_renewed_at: DateTimeUtc,
126    /// Row creation timestamp.
127    pub created_at: DateTimeUtc,
128    /// Row update timestamp.
129    pub updated_at: DateTimeUtc,
130}
131
132#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
133pub enum Relation {}
134
135impl ActiveModelBehavior for ActiveModel {}
136
137/// SeaORM-backed implementation of [`RuntimeLeaseStore`].
138#[derive(Clone)]
139pub struct RuntimeLeaseDbStore {
140    db: DatabaseConnection,
141}
142
143impl RuntimeLeaseDbStore {
144    /// Creates a runtime lease store from a SeaORM database connection.
145    pub const fn new(db: DatabaseConnection) -> Self {
146        Self { db }
147    }
148
149    /// Returns the underlying database connection.
150    pub const fn db(&self) -> &DatabaseConnection {
151        &self.db
152    }
153}
154
155#[async_trait::async_trait]
156impl RuntimeLeaseStore for RuntimeLeaseDbStore {
157    type Error = DbError;
158
159    async fn try_acquire(
160        &self,
161        claim: RuntimeLeaseClaim<'_>,
162    ) -> std::result::Result<RuntimeLeaseAcquire, Self::Error> {
163        try_insert_lease(&self.db, claim).await
164    }
165
166    async fn renew(
167        &self,
168        lease_id: &str,
169        owner_id: &str,
170        now: chrono::DateTime<chrono::Utc>,
171        expires_at: chrono::DateTime<chrono::Utc>,
172    ) -> std::result::Result<bool, Self::Error> {
173        renew_lease(&self.db, lease_id, owner_id, now, expires_at).await
174    }
175
176    async fn release(
177        &self,
178        lease_id: &str,
179        owner_id: &str,
180    ) -> std::result::Result<(), Self::Error> {
181        release_lease(&self.db, lease_id, owner_id).await
182    }
183}
184
185async fn try_insert_lease(
186    db: &DatabaseConnection,
187    claim: RuntimeLeaseClaim<'_>,
188) -> crate::Result<RuntimeLeaseAcquire> {
189    let insert_result = ActiveModel {
190        lease_id: Set(claim.lease_id.to_string()),
191        owner_id: Set(claim.owner_id.to_string()),
192        expires_at: Set(claim.expires_at),
193        last_renewed_at: Set(claim.now),
194        created_at: Set(claim.now),
195        updated_at: Set(claim.now),
196    }
197    .insert(db)
198    .await;
199
200    match insert_result {
201        Ok(_) => Ok(RuntimeLeaseAcquire::Acquired),
202        Err(insert_error) => acquire_existing_lease(db, claim, insert_error).await,
203    }
204}
205
206async fn acquire_existing_lease(
207    db: &DatabaseConnection,
208    claim: RuntimeLeaseClaim<'_>,
209    insert_error: sea_orm::DbErr,
210) -> crate::Result<RuntimeLeaseAcquire> {
211    let existing = Entity::find_by_id(claim.lease_id.to_string())
212        .one(db)
213        .await
214        .map_err(DbError::from)?;
215    let Some(existing) = existing else {
216        return Err(DbError::from(insert_error));
217    };
218
219    if existing.owner_id != claim.owner_id && existing.expires_at > claim.now {
220        return Ok(standby_from_model(existing));
221    }
222
223    let owner_or_expired = Condition::any()
224        .add(Column::OwnerId.eq(claim.owner_id))
225        .add(Column::ExpiresAt.lte(claim.now));
226    let update = Entity::update_many()
227        .col_expr(Column::OwnerId, Expr::value(claim.owner_id.to_string()))
228        .col_expr(Column::ExpiresAt, Expr::value(claim.expires_at))
229        .col_expr(Column::LastRenewedAt, Expr::value(claim.now))
230        .col_expr(Column::UpdatedAt, Expr::value(claim.now))
231        .filter(Column::LeaseId.eq(claim.lease_id))
232        .filter(owner_or_expired)
233        .exec(db)
234        .await
235        .map_err(DbError::from)?;
236
237    if update.rows_affected == 1 {
238        return Ok(RuntimeLeaseAcquire::Acquired);
239    }
240
241    Entity::find_by_id(claim.lease_id.to_string())
242        .one(db)
243        .await
244        .map_err(DbError::from)?
245        .map_or(Ok(RuntimeLeaseAcquire::Standby { owner: None }), |model| {
246            Ok(standby_from_model(model))
247        })
248}
249
250async fn renew_lease(
251    db: &DatabaseConnection,
252    lease_id: &str,
253    owner_id: &str,
254    now: chrono::DateTime<chrono::Utc>,
255    expires_at: chrono::DateTime<chrono::Utc>,
256) -> crate::Result<bool> {
257    let update = Entity::update_many()
258        .col_expr(Column::ExpiresAt, Expr::value(expires_at))
259        .col_expr(Column::LastRenewedAt, Expr::value(now))
260        .col_expr(Column::UpdatedAt, Expr::value(now))
261        .filter(Column::LeaseId.eq(lease_id))
262        .filter(Column::OwnerId.eq(owner_id))
263        .exec(db)
264        .await
265        .map_err(DbError::from)?;
266
267    Ok(update.rows_affected == 1)
268}
269
270async fn release_lease(
271    db: &DatabaseConnection,
272    lease_id: &str,
273    owner_id: &str,
274) -> crate::Result<()> {
275    Entity::delete_many()
276        .filter(Column::LeaseId.eq(lease_id))
277        .filter(Column::OwnerId.eq(owner_id))
278        .exec(db)
279        .await
280        .map_err(DbError::from)?;
281
282    Ok(())
283}
284
285fn standby_from_model(model: Model) -> RuntimeLeaseAcquire {
286    RuntimeLeaseAcquire::Standby {
287        owner: Some(RuntimeLeaseOwner {
288            owner_id: model.owner_id,
289            expires_at: model.expires_at,
290        }),
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use aster_forge_runtime::{RuntimeLeaseAcquire, RuntimeLeaseClaim, RuntimeLeaseStore};
297    use chrono::Utc;
298    use sea_orm::sea_query::{MysqlQueryBuilder, PostgresQueryBuilder, SqliteQueryBuilder};
299    use sea_orm::{ConnectionTrait, Database, DatabaseBackend, Schema};
300
301    use super::{Entity, RuntimeLeaseDbStore, create_runtime_leases_table};
302
303    async fn sqlite_store() -> RuntimeLeaseDbStore {
304        let db = Database::connect("sqlite::memory:")
305            .await
306            .expect("sqlite memory database should connect");
307        let schema = Schema::new(db.get_database_backend());
308        let statement = schema.create_table_from_entity(Entity);
309        db.execute(&statement)
310            .await
311            .expect("runtime leases table should be created");
312        RuntimeLeaseDbStore::new(db)
313    }
314
315    fn claim<'a>(
316        lease_id: &'a str,
317        owner_id: &'a str,
318        now: chrono::DateTime<Utc>,
319        ttl_secs: i64,
320    ) -> RuntimeLeaseClaim<'a> {
321        RuntimeLeaseClaim {
322            lease_id,
323            owner_id,
324            now,
325            expires_at: now + chrono::Duration::seconds(ttl_secs),
326        }
327    }
328
329    fn create_table_sql(backend: DatabaseBackend) -> String {
330        let table = create_runtime_leases_table(backend);
331        match backend {
332            DatabaseBackend::MySql => table.to_string(MysqlQueryBuilder),
333            DatabaseBackend::Postgres => table.to_string(PostgresQueryBuilder),
334            DatabaseBackend::Sqlite => table.to_string(SqliteQueryBuilder),
335            _ => unreachable!("unsupported backend in runtime lease table test"),
336        }
337    }
338
339    #[test]
340    fn create_runtime_leases_table_uses_stable_shape() {
341        let sqlite_sql = create_table_sql(DatabaseBackend::Sqlite);
342        assert!(sqlite_sql.contains("CREATE TABLE IF NOT EXISTS \"runtime_leases\""));
343        assert!(sqlite_sql.contains("\"lease_id\" varchar(191) NOT NULL PRIMARY KEY"));
344        assert!(sqlite_sql.contains("\"owner_id\" varchar(191) NOT NULL"));
345        assert!(sqlite_sql.contains("\"expires_at\" timestamp_with_timezone_text NOT NULL"));
346
347        let mysql_sql = create_table_sql(DatabaseBackend::MySql);
348        assert!(mysql_sql.contains("`expires_at` datetime(6) NOT NULL"));
349
350        let postgres_sql = create_table_sql(DatabaseBackend::Postgres);
351        assert!(postgres_sql.contains("\"expires_at\" timestamp with time zone NOT NULL"));
352    }
353
354    #[tokio::test]
355    async fn acquiring_new_lease_inserts_owner() {
356        let store = sqlite_store().await;
357        let now = Utc::now();
358
359        let result = store
360            .try_acquire(claim("aster.test", "node-a", now, 30))
361            .await
362            .expect("acquire should succeed");
363
364        assert_eq!(result, RuntimeLeaseAcquire::Acquired);
365    }
366
367    #[tokio::test]
368    async fn held_unexpired_lease_returns_standby_owner() {
369        let store = sqlite_store().await;
370        let now = Utc::now();
371        store
372            .try_acquire(claim("aster.test", "node-a", now, 30))
373            .await
374            .expect("initial acquire should succeed");
375
376        let result = store
377            .try_acquire(claim("aster.test", "node-b", now, 30))
378            .await
379            .expect("standby acquire should succeed");
380
381        match result {
382            RuntimeLeaseAcquire::Standby { owner: Some(owner) } => {
383                assert_eq!(owner.owner_id, "node-a");
384            }
385            other => panic!("expected standby owner, got {other:?}"),
386        }
387    }
388
389    #[tokio::test]
390    async fn expired_lease_can_be_acquired_by_new_owner() {
391        let store = sqlite_store().await;
392        let now = Utc::now();
393        store
394            .try_acquire(claim("aster.test", "node-a", now, 1))
395            .await
396            .expect("initial acquire should succeed");
397
398        let result = store
399            .try_acquire(claim(
400                "aster.test",
401                "node-b",
402                now + chrono::Duration::seconds(2),
403                30,
404            ))
405            .await
406            .expect("expired acquire should succeed");
407
408        assert_eq!(result, RuntimeLeaseAcquire::Acquired);
409    }
410
411    #[tokio::test]
412    async fn same_owner_reacquire_renews_lease() {
413        let store = sqlite_store().await;
414        let now = Utc::now();
415        store
416            .try_acquire(claim("aster.test", "node-a", now, 1))
417            .await
418            .expect("initial acquire should succeed");
419
420        let result = store
421            .try_acquire(claim(
422                "aster.test",
423                "node-a",
424                now + chrono::Duration::seconds(1),
425                30,
426            ))
427            .await
428            .expect("same owner acquire should succeed");
429
430        assert_eq!(result, RuntimeLeaseAcquire::Acquired);
431    }
432
433    #[tokio::test]
434    async fn renew_requires_matching_owner() {
435        let store = sqlite_store().await;
436        let now = Utc::now();
437        store
438            .try_acquire(claim("aster.test", "node-a", now, 30))
439            .await
440            .expect("initial acquire should succeed");
441
442        let renewed = store
443            .renew(
444                "aster.test",
445                "node-b",
446                now + chrono::Duration::seconds(1),
447                now + chrono::Duration::seconds(31),
448            )
449            .await
450            .expect("renew should query");
451
452        assert!(!renewed);
453    }
454
455    #[tokio::test]
456    async fn release_requires_matching_owner() {
457        let store = sqlite_store().await;
458        let now = Utc::now();
459        store
460            .try_acquire(claim("aster.test", "node-a", now, 30))
461            .await
462            .expect("initial acquire should succeed");
463        store
464            .release("aster.test", "node-b")
465            .await
466            .expect("wrong owner release should be ignored");
467
468        let result = store
469            .try_acquire(claim("aster.test", "node-c", now, 30))
470            .await
471            .expect("standby acquire should succeed");
472
473        assert!(matches!(result, RuntimeLeaseAcquire::Standby { .. }));
474    }
475}