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
37pub 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
64pub 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#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
115#[sea_orm(table_name = "runtime_leases")]
116pub struct Model {
117 #[sea_orm(primary_key, auto_increment = false)]
119 pub lease_id: String,
120 pub owner_id: String,
122 pub expires_at: DateTimeUtc,
124 pub last_renewed_at: DateTimeUtc,
126 pub created_at: DateTimeUtc,
128 pub updated_at: DateTimeUtc,
130}
131
132#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
133pub enum Relation {}
134
135impl ActiveModelBehavior for ActiveModel {}
136
137#[derive(Clone)]
139pub struct RuntimeLeaseDbStore {
140 db: DatabaseConnection,
141}
142
143impl RuntimeLeaseDbStore {
144 pub const fn new(db: DatabaseConnection) -> Self {
146 Self { db }
147 }
148
149 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}