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