1use 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
22pub const RUNTIME_LEASES_TABLE: &str = "runtime_leases";
24pub const RUNTIME_LEASE_ID_COLUMN: &str = "lease_id";
26pub const RUNTIME_LEASE_OWNER_ID_COLUMN: &str = "owner_id";
28pub const RUNTIME_LEASE_EXPIRES_AT_COLUMN: &str = "expires_at";
30pub const RUNTIME_LEASE_LAST_RENEWED_AT_COLUMN: &str = "last_renewed_at";
32pub const RUNTIME_LEASE_CREATED_AT_COLUMN: &str = "created_at";
34pub const RUNTIME_LEASE_UPDATED_AT_COLUMN: &str = "updated_at";
36
37#[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#[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#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
117#[sea_orm(table_name = "runtime_leases")]
118pub struct Model {
119 #[sea_orm(primary_key, auto_increment = false)]
121 pub lease_id: String,
122 pub owner_id: String,
124 pub expires_at: DateTimeUtc,
126 pub last_renewed_at: DateTimeUtc,
128 pub created_at: DateTimeUtc,
130 pub updated_at: DateTimeUtc,
132}
133
134#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
135pub enum Relation {}
136
137impl ActiveModelBehavior for ActiveModel {}
138
139#[derive(Clone)]
141pub struct RuntimeLeaseDbStore {
142 db: DatabaseConnection,
143}
144
145impl RuntimeLeaseDbStore {
146 #[must_use]
148 pub const fn new(db: DatabaseConnection) -> Self {
149 Self { db }
150 }
151
152 #[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}