1use std::sync::Arc;
2
3use chrono::{Duration, Utc};
4
5use eventuary_core::io::OwnerId;
6use eventuary_core::io::reader::{
7 CheckpointScope, Generation, PartitionCoordinator, PartitionLease,
8};
9use eventuary_core::{Error, Partition, Result};
10
11use crate::database::SqliteConn;
12use crate::reader::SqliteCursor;
13use crate::relation::SqliteRelationName;
14use crate::schema::{Migration, RelationReplacement};
15
16const PARTITION_COORDINATOR_0001_INIT_SQL: &str = r#"
17CREATE TABLE IF NOT EXISTS {consumers} (
18 consumer_group_id TEXT NOT NULL,
19 stream_id TEXT NOT NULL,
20 owner_id TEXT NOT NULL,
21 lease_until TEXT NOT NULL,
22 PRIMARY KEY (consumer_group_id, stream_id, owner_id)
23);
24
25CREATE INDEX IF NOT EXISTS idx_event_stream_consumers_group_stream_lease
26ON {consumers} (consumer_group_id, stream_id, lease_until);
27
28CREATE TABLE IF NOT EXISTS {partitions} (
29 consumer_group_id TEXT NOT NULL,
30 stream_id TEXT NOT NULL,
31 partition_id INTEGER NOT NULL,
32 partition_count INTEGER NULL,
33 owner_id TEXT NULL,
34 lease_until TEXT NULL,
35 checkpoint_sequence INTEGER NOT NULL DEFAULT 0,
36 generation INTEGER NOT NULL DEFAULT 0,
37 PRIMARY KEY (consumer_group_id, stream_id, partition_id)
38);
39
40CREATE INDEX IF NOT EXISTS idx_event_stream_partitions_group_stream_owner
41ON {partitions} (consumer_group_id, stream_id, owner_id);
42
43CREATE INDEX IF NOT EXISTS idx_event_stream_partitions_group_stream_count
44ON {partitions} (consumer_group_id, stream_id, partition_count, partition_id);
45"#;
46
47const PARTITION_COORDINATOR_MIGRATIONS: &[Migration] = &[Migration {
48 name: "0001_init",
49 sql: PARTITION_COORDINATOR_0001_INIT_SQL,
50}];
51
52#[derive(Debug, Clone)]
53pub struct SqlitePartitionCoordinatorConfig {
54 pub consumers_relation: SqliteRelationName,
55 pub partitions_relation: SqliteRelationName,
56}
57
58impl Default for SqlitePartitionCoordinatorConfig {
59 fn default() -> Self {
60 Self {
61 consumers_relation: SqliteRelationName::new("event_stream_consumers")
62 .expect("default consumers relation"),
63 partitions_relation: SqliteRelationName::new("event_stream_partitions")
64 .expect("default partitions relation"),
65 }
66 }
67}
68
69pub struct SqlitePartitionCoordinator {
70 conn: SqliteConn,
71 consumers_relation: Arc<String>,
72 partitions_relation: Arc<String>,
73}
74
75impl Clone for SqlitePartitionCoordinator {
76 fn clone(&self) -> Self {
77 Self {
78 conn: Arc::clone(&self.conn),
79 consumers_relation: Arc::clone(&self.consumers_relation),
80 partitions_relation: Arc::clone(&self.partitions_relation),
81 }
82 }
83}
84
85impl SqlitePartitionCoordinator {
86 pub fn new(conn: SqliteConn, config: SqlitePartitionCoordinatorConfig) -> Self {
87 Self {
88 conn,
89 consumers_relation: Arc::new(config.consumers_relation.render()),
90 partitions_relation: Arc::new(config.partitions_relation.render()),
91 }
92 }
93
94 pub fn connect(conn: SqliteConn, config: SqlitePartitionCoordinatorConfig) -> Result<Self> {
95 Self::prepare_schema(&conn, &config)?;
96 Ok(Self::new(conn, config))
97 }
98
99 pub fn prepare_schema(
100 conn: &SqliteConn,
101 config: &SqlitePartitionCoordinatorConfig,
102 ) -> Result<()> {
103 let guard = conn.lock().map_err(|e| Error::Store(e.to_string()))?;
104 crate::schema::apply_schema(
105 &guard,
106 PARTITION_COORDINATOR_MIGRATIONS,
107 &[
108 RelationReplacement {
109 token: "{consumers}",
110 relation: &config.consumers_relation,
111 },
112 RelationReplacement {
113 token: "{partitions}",
114 relation: &config.partitions_relation,
115 },
116 ],
117 )
118 }
119
120 pub fn schema_sql(config: &SqlitePartitionCoordinatorConfig) -> String {
121 crate::schema::render_schema_sql(
122 PARTITION_COORDINATOR_MIGRATIONS,
123 &[
124 RelationReplacement {
125 token: "{consumers}",
126 relation: &config.consumers_relation,
127 },
128 RelationReplacement {
129 token: "{partitions}",
130 relation: &config.partitions_relation,
131 },
132 ],
133 )
134 }
135}
136
137fn compute_lease_until(lease_duration: std::time::Duration) -> Result<String> {
138 let dt = Utc::now()
139 + Duration::from_std(lease_duration)
140 .map_err(|_| Error::Config("lease duration out of range".to_owned()))?;
141 Ok(dt.to_rfc3339())
142}
143
144impl PartitionCoordinator<SqliteCursor> for SqlitePartitionCoordinator {
145 async fn heartbeat<'a>(
146 &'a self,
147 scope: &'a CheckpointScope,
148 owner_id: &'a OwnerId,
149 lease_duration: std::time::Duration,
150 ) -> Result<()> {
151 let lease_until = compute_lease_until(lease_duration)?;
152 let conn = Arc::clone(&self.conn);
153 let consumers = Arc::clone(&self.consumers_relation);
154 let group = scope.consumer_group_id.as_str().to_owned();
155 let stream = scope.stream_id.as_str().to_owned();
156 let owner = owner_id.as_str().to_owned();
157 tokio::task::spawn_blocking(move || {
158 let guard = conn.lock().map_err(|e| Error::Store(e.to_string()))?;
159 let sql = format!(
160 "INSERT INTO {consumers} (consumer_group_id, stream_id, owner_id, lease_until) \
161 VALUES (?1, ?2, ?3, ?4) \
162 ON CONFLICT (consumer_group_id, stream_id, owner_id) \
163 DO UPDATE SET lease_until = excluded.lease_until"
164 );
165 guard
166 .execute(&sql, rusqlite::params![group, stream, owner, lease_until])
167 .map_err(|e| Error::Store(e.to_string()))?;
168 Ok(())
169 })
170 .await
171 .map_err(|e| Error::Store(format!("blocking task panicked: {e}")))?
172 }
173
174 async fn live_consumers<'a>(&'a self, scope: &'a CheckpointScope) -> Result<usize> {
175 let conn = Arc::clone(&self.conn);
176 let consumers = Arc::clone(&self.consumers_relation);
177 let group = scope.consumer_group_id.as_str().to_owned();
178 let stream = scope.stream_id.as_str().to_owned();
179 let now = Utc::now().to_rfc3339();
180 tokio::task::spawn_blocking(move || {
181 let guard = conn.lock().map_err(|e| Error::Store(e.to_string()))?;
182 let sql = format!(
183 "SELECT COUNT(*) FROM {consumers} \
184 WHERE consumer_group_id = ?1 \
185 AND stream_id = ?2 \
186 AND lease_until > ?3"
187 );
188 let count: i64 = guard
189 .query_row(&sql, rusqlite::params![group, stream, now], |r| r.get(0))
190 .map_err(|e| Error::Store(e.to_string()))?;
191 Ok(count as usize)
192 })
193 .await
194 .map_err(|e| Error::Store(format!("blocking task panicked: {e}")))?
195 }
196
197 async fn release_consumer<'a>(
198 &'a self,
199 scope: &'a CheckpointScope,
200 owner_id: &'a OwnerId,
201 ) -> Result<()> {
202 let conn = Arc::clone(&self.conn);
203 let consumers = Arc::clone(&self.consumers_relation);
204 let group = scope.consumer_group_id.as_str().to_owned();
205 let stream = scope.stream_id.as_str().to_owned();
206 let owner = owner_id.as_str().to_owned();
207 tokio::task::spawn_blocking(move || {
208 let guard = conn.lock().map_err(|e| Error::Store(e.to_string()))?;
209 let sql = format!(
210 "DELETE FROM {consumers} \
211 WHERE consumer_group_id = ?1 AND stream_id = ?2 AND owner_id = ?3"
212 );
213 guard
214 .execute(&sql, rusqlite::params![group, stream, owner])
215 .map_err(|e| Error::Store(e.to_string()))?;
216 Ok(())
217 })
218 .await
219 .map_err(|e| Error::Store(format!("blocking task panicked: {e}")))?
220 }
221
222 async fn claim<'a>(
223 &'a self,
224 scope: &'a CheckpointScope,
225 owner_id: &'a OwnerId,
226 partition: Partition,
227 lease_duration: std::time::Duration,
228 ) -> Result<Option<PartitionLease<SqliteCursor>>> {
229 let lease_until = compute_lease_until(lease_duration)?;
230 let conn = Arc::clone(&self.conn);
231 let partitions = Arc::clone(&self.partitions_relation);
232 let group = scope.consumer_group_id.as_str().to_owned();
233 let stream = scope.stream_id.as_str().to_owned();
234 let owner = owner_id.as_str().to_owned();
235 let partition_id_i64 = partition.id() as i64;
236 let partition_count_i64 = partition.count() as i64;
237 let now = Utc::now().to_rfc3339();
238 let scope = scope.clone();
239 let owner_id = owner_id.clone();
240 tokio::task::spawn_blocking(move || {
241 let guard = conn.lock().map_err(|e| Error::Store(e.to_string()))?;
242 let sql = format!(
243 "INSERT INTO {partitions} \
244 (consumer_group_id, stream_id, partition_id, partition_count, owner_id, lease_until, generation, checkpoint_sequence) \
245 VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, 0) \
246 ON CONFLICT (consumer_group_id, stream_id, partition_id) DO UPDATE \
247 SET owner_id = excluded.owner_id, \
248 lease_until = excluded.lease_until, \
249 partition_count = COALESCE({partitions}.partition_count, excluded.partition_count), \
250 generation = {partitions}.generation + 1 \
251 WHERE ({partitions}.partition_count IS NULL OR {partitions}.partition_count = excluded.partition_count) \
252 AND ({partitions}.owner_id IS NULL \
253 OR {partitions}.lease_until IS NULL \
254 OR {partitions}.lease_until < ?7 \
255 OR {partitions}.owner_id = excluded.owner_id) \
256 RETURNING owner_id, lease_until, generation, checkpoint_sequence"
257 );
258 let row = guard
259 .query_row(
260 &sql,
261 rusqlite::params![
262 group,
263 stream,
264 partition_id_i64,
265 partition_count_i64,
266 owner,
267 lease_until,
268 now
269 ],
270 |r| {
271 Ok((
272 r.get::<_, String>(0)?,
273 r.get::<_, String>(1)?,
274 r.get::<_, i64>(2)?,
275 r.get::<_, i64>(3)?,
276 ))
277 },
278 )
279 .map(Some)
280 .or_else(|e| match e {
281 rusqlite::Error::QueryReturnedNoRows => Ok(None),
282 other => Err(other),
283 })
284 .map_err(|e| Error::Store(e.to_string()))?;
285 match row {
286 None => {
287 let check_sql = format!(
288 "SELECT partition_count FROM {partitions} \
289 WHERE consumer_group_id = ?1 AND stream_id = ?2 AND partition_id = ?3"
290 );
291 let stored: Option<i64> = guard
292 .query_row(
293 &check_sql,
294 rusqlite::params![group, stream, partition_id_i64],
295 |r| r.get::<_, Option<i64>>(0),
296 )
297 .map(Some)
298 .or_else(|e| match e {
299 rusqlite::Error::QueryReturnedNoRows => Ok(None),
300 other => Err(other),
301 })
302 .map_err(|e| Error::Store(e.to_string()))?
303 .flatten();
304 if let Some(stored) = stored
305 && stored != partition_count_i64
306 {
307 return Err(Error::Config(format!(
308 "partition count mismatch for scope {} stream {} partition {}: stored {}, requested {}",
309 group, stream, partition_id_i64, stored, partition_count_i64,
310 )));
311 }
312 Ok(None)
313 }
314 Some((_returned_owner, lease_until_text, generation, checkpoint_sequence)) => {
315 let lease_until = chrono::DateTime::parse_from_rfc3339(&lease_until_text)
316 .map(|dt| dt.with_timezone(&Utc))
317 .map_err(|e| {
318 Error::Serialization(format!("lease_until decode: {e}"))
319 })?;
320 Ok(Some(PartitionLease {
321 scope,
322 owner_id,
323 partition,
324 generation: Generation::from_i64(generation),
325 checkpoint_cursor: (checkpoint_sequence > 0)
326 .then_some(SqliteCursor::new(checkpoint_sequence)),
327 lease_until,
328 }))
329 }
330 }
331 })
332 .await
333 .map_err(|e| Error::Store(format!("blocking task panicked: {e}")))?
334 }
335
336 async fn renew<'a>(
337 &'a self,
338 lease: &'a PartitionLease<SqliteCursor>,
339 lease_duration: std::time::Duration,
340 ) -> Result<()> {
341 let lease_until = compute_lease_until(lease_duration)?;
342 let conn = Arc::clone(&self.conn);
343 let partitions = Arc::clone(&self.partitions_relation);
344 let group = lease.scope.consumer_group_id.as_str().to_owned();
345 let stream = lease.scope.stream_id.as_str().to_owned();
346 let owner = lease.owner_id.as_str().to_owned();
347 let partition_id_i64 = lease.partition.id() as i64;
348 let partition_count_i64 = lease.partition.count() as i64;
349 let generation = lease.generation.get();
350 tokio::task::spawn_blocking(move || {
351 let guard = conn.lock().map_err(|e| Error::Store(e.to_string()))?;
352 let sql = format!(
353 "UPDATE {partitions} \
354 SET lease_until = ?5 \
355 WHERE consumer_group_id = ?1 \
356 AND stream_id = ?2 \
357 AND partition_id = ?3 \
358 AND owner_id = ?4 \
359 AND generation = ?6 \
360 AND partition_count = ?7"
361 );
362 let affected = guard
363 .execute(
364 &sql,
365 rusqlite::params![
366 group,
367 stream,
368 partition_id_i64,
369 owner,
370 lease_until,
371 generation,
372 partition_count_i64
373 ],
374 )
375 .map_err(|e| Error::Store(e.to_string()))?;
376 if affected == 0 {
377 check_partition_count_mismatch(
378 &guard,
379 &partitions,
380 &group,
381 &stream,
382 partition_id_i64,
383 partition_count_i64,
384 )?;
385 return Err(Error::OwnershipLost(format!(
386 "partition {partition_id_i64} generation {generation}"
387 )));
388 }
389 Ok(())
390 })
391 .await
392 .map_err(|e| Error::Store(format!("blocking task panicked: {e}")))?
393 }
394
395 async fn release<'a>(&'a self, lease: &'a PartitionLease<SqliteCursor>) -> Result<()> {
396 let conn = Arc::clone(&self.conn);
397 let partitions = Arc::clone(&self.partitions_relation);
398 let group = lease.scope.consumer_group_id.as_str().to_owned();
399 let stream = lease.scope.stream_id.as_str().to_owned();
400 let owner = lease.owner_id.as_str().to_owned();
401 let partition_id_i64 = lease.partition.id() as i64;
402 let partition_count_i64 = lease.partition.count() as i64;
403 let generation = lease.generation.get();
404 tokio::task::spawn_blocking(move || {
405 let guard = conn.lock().map_err(|e| Error::Store(e.to_string()))?;
406 let sql = format!(
407 "UPDATE {partitions} \
408 SET owner_id = NULL, \
409 lease_until = NULL, \
410 generation = generation + 1 \
411 WHERE consumer_group_id = ?1 \
412 AND stream_id = ?2 \
413 AND partition_id = ?3 \
414 AND owner_id = ?4 \
415 AND generation = ?5 \
416 AND partition_count = ?6"
417 );
418 let affected = guard
419 .execute(
420 &sql,
421 rusqlite::params![
422 group,
423 stream,
424 partition_id_i64,
425 owner,
426 generation,
427 partition_count_i64
428 ],
429 )
430 .map_err(|e| Error::Store(e.to_string()))?;
431 if affected == 0 {
432 check_partition_count_mismatch(
433 &guard,
434 &partitions,
435 &group,
436 &stream,
437 partition_id_i64,
438 partition_count_i64,
439 )?;
440 return Err(Error::OwnershipLost(format!(
441 "partition {partition_id_i64} generation {generation}"
442 )));
443 }
444 Ok(())
445 })
446 .await
447 .map_err(|e| Error::Store(format!("blocking task panicked: {e}")))?
448 }
449
450 async fn checkpoint<'a>(
451 &'a self,
452 lease: &'a PartitionLease<SqliteCursor>,
453 cursor: SqliteCursor,
454 ) -> Result<()> {
455 let conn = Arc::clone(&self.conn);
456 let partitions = Arc::clone(&self.partitions_relation);
457 let group = lease.scope.consumer_group_id.as_str().to_owned();
458 let stream = lease.scope.stream_id.as_str().to_owned();
459 let owner = lease.owner_id.as_str().to_owned();
460 let partition_id_i64 = lease.partition.id() as i64;
461 let partition_count_i64 = lease.partition.count() as i64;
462 let generation = lease.generation.get();
463 let sequence = cursor.sequence;
464 tokio::task::spawn_blocking(move || {
465 let guard = conn.lock().map_err(|e| Error::Store(e.to_string()))?;
466 let sql = format!(
467 "UPDATE {partitions} \
468 SET checkpoint_sequence = ?6 \
469 WHERE consumer_group_id = ?1 \
470 AND stream_id = ?2 \
471 AND partition_id = ?3 \
472 AND owner_id = ?4 \
473 AND generation = ?5 \
474 AND partition_count = ?7 \
475 AND ?6 > {partitions}.checkpoint_sequence"
476 );
477 let affected = guard
478 .execute(
479 &sql,
480 rusqlite::params![
481 group,
482 stream,
483 partition_id_i64,
484 owner,
485 generation,
486 sequence,
487 partition_count_i64
488 ],
489 )
490 .map_err(|e| Error::Store(e.to_string()))?;
491 if affected == 0 {
492 let check_sql = format!(
493 "SELECT owner_id, generation, partition_count FROM {partitions} \
494 WHERE consumer_group_id = ?1 AND stream_id = ?2 AND partition_id = ?3"
495 );
496 let check_row = guard
497 .query_row(
498 &check_sql,
499 rusqlite::params![group, stream, partition_id_i64],
500 |r| {
501 Ok((
502 r.get::<_, Option<String>>(0)?,
503 r.get::<_, i64>(1)?,
504 r.get::<_, Option<i64>>(2)?,
505 ))
506 },
507 )
508 .map(Some)
509 .or_else(|e| match e {
510 rusqlite::Error::QueryReturnedNoRows => Ok(None),
511 other => Err(other),
512 })
513 .map_err(|e| Error::Store(e.to_string()))?;
514 match check_row {
515 Some((current_owner, current_generation, current_count)) => {
516 if let Some(stored) = current_count
517 && stored != partition_count_i64
518 {
519 return Err(Error::Config(format!(
520 "partition count mismatch for scope {group} stream {stream} partition {partition_id_i64}: stored {stored}, requested {partition_count_i64}"
521 )));
522 }
523 if current_generation == generation
524 && current_owner.as_deref() == Some(owner.as_str())
525 {
526 return Ok(());
527 }
528 Err(Error::OwnershipLost(format!(
529 "checkpoint rejected for partition {partition_id_i64}: stale owner/generation"
530 )))
531 }
532 None => Err(Error::OwnershipLost(format!(
533 "partition {partition_id_i64} generation {generation}"
534 ))),
535 }
536 } else {
537 Ok(())
538 }
539 })
540 .await
541 .map_err(|e| Error::Store(format!("blocking task panicked: {e}")))?
542 }
543}
544
545fn check_partition_count_mismatch(
546 guard: &rusqlite::Connection,
547 partitions: &str,
548 group: &str,
549 stream: &str,
550 partition_id: i64,
551 requested_count: i64,
552) -> Result<()> {
553 let sql = format!(
554 "SELECT partition_count FROM {partitions} \
555 WHERE consumer_group_id = ?1 AND stream_id = ?2 AND partition_id = ?3"
556 );
557 let stored: Option<i64> = guard
558 .query_row(&sql, rusqlite::params![group, stream, partition_id], |r| {
559 r.get::<_, Option<i64>>(0)
560 })
561 .map(Some)
562 .or_else(|e| match e {
563 rusqlite::Error::QueryReturnedNoRows => Ok(None),
564 other => Err(other),
565 })
566 .map_err(|e| Error::Store(e.to_string()))?
567 .flatten();
568 if let Some(stored) = stored
569 && stored != requested_count
570 {
571 return Err(Error::Config(format!(
572 "partition count mismatch for scope {group} stream {stream} partition {partition_id}: stored {stored}, requested {requested_count}"
573 )));
574 }
575 Ok(())
576}
577
578#[cfg(test)]
579mod tests {
580 use std::num::NonZeroU16;
581
582 use super::*;
583 use eventuary_core::io::reader::PartitionCoordinator;
584 use eventuary_core::io::{ConsumerGroupId, OwnerId, StreamId};
585
586 use crate::database::SqliteDatabase;
587
588 #[test]
589 fn schema_sql_contains_expected_tables() {
590 let sql =
591 SqlitePartitionCoordinator::schema_sql(&SqlitePartitionCoordinatorConfig::default());
592 assert!(sql.contains("CREATE TABLE IF NOT EXISTS \"event_stream_consumers\""));
593 assert!(sql.contains("CREATE TABLE IF NOT EXISTS \"event_stream_partitions\""));
594 }
595
596 fn make_coordinator() -> SqlitePartitionCoordinator {
597 let db = SqliteDatabase::open_in_memory().unwrap();
598 let conn = db.conn();
599 SqlitePartitionCoordinator::prepare_schema(
600 &conn,
601 &SqlitePartitionCoordinatorConfig::default(),
602 )
603 .unwrap();
604 SqlitePartitionCoordinator::new(conn, SqlitePartitionCoordinatorConfig::default())
605 }
606
607 fn scope() -> CheckpointScope {
608 CheckpointScope::new(
609 ConsumerGroupId::new("group-1").unwrap(),
610 StreamId::new("orders").unwrap(),
611 )
612 }
613
614 fn partition(id: u16) -> Partition {
615 Partition::new(id, NonZeroU16::new(64).unwrap()).unwrap()
616 }
617
618 #[tokio::test]
619 async fn heartbeat_and_live_count() {
620 let coord = make_coordinator();
621 let s = scope();
622 let owner_a = OwnerId::new("worker-a").unwrap();
623 let owner_b = OwnerId::new("worker-b").unwrap();
624 let long_lease = std::time::Duration::from_secs(60);
625
626 coord.heartbeat(&s, &owner_a, long_lease).await.unwrap();
627 coord.heartbeat(&s, &owner_b, long_lease).await.unwrap();
628
629 let live = coord.live_consumers(&s).await.unwrap();
630 assert_eq!(live, 2);
631 }
632
633 #[tokio::test]
634 async fn live_consumers_excludes_expired() {
635 let coord = make_coordinator();
636 let s = scope();
637 let owner_a = OwnerId::new("worker-a").unwrap();
638 let owner_b = OwnerId::new("worker-b").unwrap();
639 let long_lease = std::time::Duration::from_secs(60);
640 let short_lease = std::time::Duration::from_millis(50);
641
642 coord.heartbeat(&s, &owner_a, long_lease).await.unwrap();
643 coord.heartbeat(&s, &owner_b, short_lease).await.unwrap();
644
645 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
646
647 let live = coord.live_consumers(&s).await.unwrap();
648 assert_eq!(live, 1);
649 }
650
651 #[tokio::test]
652 async fn claim_free_partition_succeeds() {
653 let coord = make_coordinator();
654 let s = scope();
655 let owner = OwnerId::new("worker-a").unwrap();
656 let lease_dur = std::time::Duration::from_secs(60);
657
658 let lease = coord
659 .claim(&s, &owner, partition(0), lease_dur)
660 .await
661 .unwrap()
662 .expect("should get lease");
663
664 assert_eq!(lease.generation.get(), 1);
665 assert!(lease.checkpoint_cursor.is_none());
666 }
667
668 #[tokio::test]
669 async fn claim_contested_returns_none() {
670 let coord = make_coordinator();
671 let s = scope();
672 let owner_a = OwnerId::new("worker-a").unwrap();
673 let owner_b = OwnerId::new("worker-b").unwrap();
674 let lease_dur = std::time::Duration::from_secs(60);
675
676 let first = coord
677 .claim(&s, &owner_a, partition(0), lease_dur)
678 .await
679 .unwrap();
680 assert!(first.is_some());
681
682 let second = coord
683 .claim(&s, &owner_b, partition(0), lease_dur)
684 .await
685 .unwrap();
686 assert!(second.is_none());
687 }
688
689 #[tokio::test]
690 async fn claim_after_expiry_succeeds() {
691 let coord = make_coordinator();
692 let s = scope();
693 let owner_a = OwnerId::new("worker-a").unwrap();
694 let owner_b = OwnerId::new("worker-b").unwrap();
695 let short_lease = std::time::Duration::from_millis(50);
696 let long_lease = std::time::Duration::from_secs(60);
697
698 let first = coord
699 .claim(&s, &owner_a, partition(0), short_lease)
700 .await
701 .unwrap();
702 assert_eq!(first.unwrap().generation.get(), 1);
703
704 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
705
706 let second = coord
707 .claim(&s, &owner_b, partition(0), long_lease)
708 .await
709 .unwrap()
710 .expect("should succeed after expiry");
711 assert_eq!(second.generation.get(), 2);
712 }
713
714 #[tokio::test]
715 async fn renew_with_matching_generation() {
716 let coord = make_coordinator();
717 let s = scope();
718 let owner = OwnerId::new("worker-a").unwrap();
719 let lease_dur = std::time::Duration::from_secs(60);
720
721 let lease = coord
722 .claim(&s, &owner, partition(0), lease_dur)
723 .await
724 .unwrap()
725 .unwrap();
726
727 coord.renew(&lease, lease_dur).await.unwrap();
728 }
729
730 #[tokio::test]
731 async fn renew_with_stale_generation_returns_ownership_lost() {
732 let coord = make_coordinator();
733 let s = scope();
734 let owner_a = OwnerId::new("worker-a").unwrap();
735 let owner_b = OwnerId::new("worker-b").unwrap();
736 let short_lease = std::time::Duration::from_millis(50);
737 let long_lease = std::time::Duration::from_secs(60);
738
739 let lease_a = coord
740 .claim(&s, &owner_a, partition(0), short_lease)
741 .await
742 .unwrap()
743 .unwrap();
744 assert_eq!(lease_a.generation.get(), 1);
745
746 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
747
748 coord
749 .claim(&s, &owner_b, partition(0), long_lease)
750 .await
751 .unwrap()
752 .unwrap();
753
754 let err = coord.renew(&lease_a, long_lease).await.unwrap_err();
755 assert!(matches!(err, eventuary_core::Error::OwnershipLost(_)));
756 }
757
758 #[tokio::test]
759 async fn release_with_matching_generation() {
760 let coord = make_coordinator();
761 let s = scope();
762 let owner_a = OwnerId::new("worker-a").unwrap();
763 let owner_b = OwnerId::new("worker-b").unwrap();
764 let lease_dur = std::time::Duration::from_secs(60);
765
766 let lease = coord
767 .claim(&s, &owner_a, partition(0), lease_dur)
768 .await
769 .unwrap()
770 .unwrap();
771 assert_eq!(lease.generation.get(), 1);
772
773 coord.release(&lease).await.unwrap();
774
775 let new_lease = coord
776 .claim(&s, &owner_b, partition(0), lease_dur)
777 .await
778 .unwrap()
779 .expect("should claim after release");
780 assert_eq!(new_lease.generation.get(), 3);
781 }
782
783 #[tokio::test]
784 async fn release_with_stale_generation_returns_ownership_lost() {
785 let coord = make_coordinator();
786 let s = scope();
787 let owner_a = OwnerId::new("worker-a").unwrap();
788 let owner_b = OwnerId::new("worker-b").unwrap();
789 let short_lease = std::time::Duration::from_millis(50);
790 let long_lease = std::time::Duration::from_secs(60);
791
792 let lease_a = coord
793 .claim(&s, &owner_a, partition(0), short_lease)
794 .await
795 .unwrap()
796 .unwrap();
797
798 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
799
800 coord
801 .claim(&s, &owner_b, partition(0), long_lease)
802 .await
803 .unwrap()
804 .unwrap();
805
806 let err = coord.release(&lease_a).await.unwrap_err();
807 assert!(matches!(err, eventuary_core::Error::OwnershipLost(_)));
808 }
809
810 #[tokio::test]
811 async fn checkpoint_with_matching_generation_advances() {
812 let coord = make_coordinator();
813 let s = scope();
814 let owner_a = OwnerId::new("worker-a").unwrap();
815 let owner_b = OwnerId::new("worker-b").unwrap();
816 let long_lease = std::time::Duration::from_secs(60);
817
818 let lease = coord
819 .claim(&s, &owner_a, partition(0), long_lease)
820 .await
821 .unwrap()
822 .unwrap();
823
824 coord
825 .checkpoint(&lease, SqliteCursor::new(100))
826 .await
827 .unwrap();
828
829 coord.release(&lease).await.unwrap();
830
831 let new_lease = coord
832 .claim(&s, &owner_b, partition(0), long_lease)
833 .await
834 .unwrap()
835 .expect("should claim after release");
836 assert_eq!(new_lease.checkpoint_cursor.unwrap().sequence, 100);
837 }
838
839 #[tokio::test]
840 async fn checkpoint_is_monotonic() {
841 let coord = make_coordinator();
842 let s = scope();
843 let owner_a = OwnerId::new("worker-a").unwrap();
844 let owner_b = OwnerId::new("worker-b").unwrap();
845 let long_lease = std::time::Duration::from_secs(60);
846
847 let lease = coord
848 .claim(&s, &owner_a, partition(0), long_lease)
849 .await
850 .unwrap()
851 .unwrap();
852
853 coord
854 .checkpoint(&lease, SqliteCursor::new(100))
855 .await
856 .unwrap();
857 coord
858 .checkpoint(&lease, SqliteCursor::new(50))
859 .await
860 .unwrap();
861
862 coord.release(&lease).await.unwrap();
863
864 let new_lease = coord
865 .claim(&s, &owner_b, partition(0), long_lease)
866 .await
867 .unwrap()
868 .expect("should claim after release");
869 assert_eq!(new_lease.checkpoint_cursor.unwrap().sequence, 100);
870 }
871
872 #[tokio::test]
873 async fn checkpoint_with_stale_generation_returns_ownership_lost() {
874 let coord = make_coordinator();
875 let s = scope();
876 let owner_a = OwnerId::new("worker-a").unwrap();
877 let owner_b = OwnerId::new("worker-b").unwrap();
878 let short_lease = std::time::Duration::from_millis(50);
879 let long_lease = std::time::Duration::from_secs(60);
880
881 let lease_a = coord
882 .claim(&s, &owner_a, partition(0), short_lease)
883 .await
884 .unwrap()
885 .unwrap();
886
887 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
888
889 coord
890 .claim(&s, &owner_b, partition(0), long_lease)
891 .await
892 .unwrap()
893 .unwrap();
894
895 let err = coord
896 .checkpoint(&lease_a, SqliteCursor::new(100))
897 .await
898 .unwrap_err();
899 assert!(matches!(err, eventuary_core::Error::OwnershipLost(_)));
900 }
901
902 #[tokio::test]
903 async fn claim_rejects_partition_count_mismatch() {
904 let coord = make_coordinator();
905 let s = scope();
906 let owner_a = OwnerId::new("worker-a").unwrap();
907 let owner_b = OwnerId::new("worker-b").unwrap();
908 let p_four = Partition::new(0, NonZeroU16::new(4).unwrap()).unwrap();
909 let p_eight = Partition::new(0, NonZeroU16::new(8).unwrap()).unwrap();
910
911 coord
912 .claim(&s, &owner_a, p_four, std::time::Duration::from_millis(1))
913 .await
914 .unwrap()
915 .unwrap();
916 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
917
918 let err = coord
919 .claim(&s, &owner_b, p_eight, std::time::Duration::from_secs(10))
920 .await
921 .unwrap_err();
922 assert!(
923 matches!(err, eventuary_core::Error::Config(ref message) if message.contains("partition count mismatch")),
924 "expected partition count mismatch error, got {err:?}"
925 );
926 }
927
928 #[tokio::test]
929 async fn checkpoint_after_release_returns_ownership_lost() {
930 let coord = make_coordinator();
931 let s = scope();
932 let owner_a = OwnerId::new("worker-a").unwrap();
933 let lease_dur = std::time::Duration::from_secs(60);
934
935 let lease = coord
936 .claim(&s, &owner_a, partition(0), lease_dur)
937 .await
938 .unwrap()
939 .unwrap();
940 assert_eq!(lease.generation.get(), 1);
941
942 coord.release(&lease).await.unwrap();
943
944 let err = coord
945 .checkpoint(&lease, SqliteCursor::new(100))
946 .await
947 .unwrap_err();
948 assert!(matches!(err, eventuary_core::Error::OwnershipLost(_)));
949 }
950}