honker 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,6 +20,7 @@
20
20
 
21
21
  use rusqlite::Connection;
22
22
  use rusqlite::functions::FunctionFlags;
23
+ use serde_json::{Value, json};
23
24
 
24
25
  /// Wrap a Displayable error for SQLite scalar-function returns.
25
26
  fn to_sql_err<E: std::fmt::Display>(e: E) -> rusqlite::Error {
@@ -102,6 +103,18 @@ pub fn attach_honker_functions(conn: &Connection) -> rusqlite::Result<()> {
102
103
  },
103
104
  )?;
104
105
 
106
+ // honker_lock_renew(name, owner, ttl_s) -> 1 if this owner still
107
+ // holds the lock and expires_at was extended, 0 otherwise.
108
+ // Distinct from honker_lock_acquire: INSERT OR IGNORE does not
109
+ // refresh expires_at for an existing (name, owner) row.
110
+ conn.create_scalar_function("honker_lock_renew", 3, FunctionFlags::SQLITE_UTF8, |ctx| {
111
+ let name: String = ctx.get(0)?;
112
+ let owner: String = ctx.get(1)?;
113
+ let ttl: i64 = ctx.get(2)?;
114
+ let db = unsafe { ctx.get_connection() }?;
115
+ lock_renew(&db, &name, &owner, ttl).map_err(to_sql_err)
116
+ })?;
117
+
105
118
  conn.create_scalar_function(
106
119
  "honker_rate_limit_try",
107
120
  3,
@@ -128,6 +141,8 @@ pub fn attach_honker_functions(conn: &Connection) -> rusqlite::Result<()> {
128
141
 
129
142
  // honker_scheduler_register(name, queue, cron_expr, payload_json,
130
143
  // priority, expires_s_or_null) -> 1.
144
+ // Optional 7th arg max_attempts (default 3) pins the attempt budget
145
+ // on every job the scheduler enqueues for this task.
131
146
  // Upserts the task row. `next_fire_at` is recomputed as the next
132
147
  // cron boundary strictly after `unixepoch()`. Calling twice with
133
148
  // the same name replaces the first registration entirely.
@@ -144,7 +159,33 @@ pub fn attach_honker_functions(conn: &Connection) -> rusqlite::Result<()> {
144
159
  let expires_s: Option<i64> = ctx.get(5)?;
145
160
  let db = unsafe { ctx.get_connection() }?;
146
161
  scheduler_register(
147
- &db, &name, &queue, &cron_expr, &payload, priority, expires_s,
162
+ &db, &name, &queue, &cron_expr, &payload, priority, expires_s, 3,
163
+ )
164
+ .map_err(to_sql_err)
165
+ },
166
+ )?;
167
+ conn.create_scalar_function(
168
+ "honker_scheduler_register",
169
+ 7,
170
+ FunctionFlags::SQLITE_UTF8,
171
+ |ctx| {
172
+ let name: String = ctx.get(0)?;
173
+ let queue: String = ctx.get(1)?;
174
+ let cron_expr: String = ctx.get(2)?;
175
+ let payload: String = ctx.get(3)?;
176
+ let priority: i64 = ctx.get(4)?;
177
+ let expires_s: Option<i64> = ctx.get(5)?;
178
+ let max_attempts: i64 = ctx.get(6)?;
179
+ let db = unsafe { ctx.get_connection() }?;
180
+ scheduler_register(
181
+ &db,
182
+ &name,
183
+ &queue,
184
+ &cron_expr,
185
+ &payload,
186
+ priority,
187
+ expires_s,
188
+ max_attempts,
148
189
  )
149
190
  .map_err(to_sql_err)
150
191
  },
@@ -229,10 +270,13 @@ pub fn attach_honker_functions(conn: &Connection) -> rusqlite::Result<()> {
229
270
  // honker_scheduler_update(name, cron_expr_or_null, payload_or_null,
230
271
  // priority_or_null, expires_s_or_null,
231
272
  // touch_expires) -> 1 if updated, 0 if missing.
273
+ // Optional 8-arg form adds max_attempts_or_null, touch_max_attempts.
232
274
  // `touch_expires` is a 0/1 flag: when 1 we treat the expires_s arg
233
275
  // as the desired value (which may be NULL = "clear"); when 0 we
234
276
  // leave expires_s untouched. SQL has no good way to distinguish
235
- // "user passed NULL" from "user did not specify" otherwise.
277
+ // "user passed NULL" from "user did not specify" otherwise. Same
278
+ // pattern for max_attempts so old 6-arg raw callers stay compatible;
279
+ // explicit NULL resets max_attempts to the scheduler default (3).
236
280
  conn.create_scalar_function(
237
281
  "honker_scheduler_update",
238
282
  6,
@@ -257,6 +301,43 @@ pub fn attach_honker_functions(conn: &Connection) -> rusqlite::Result<()> {
257
301
  payload.as_deref(),
258
302
  priority,
259
303
  expires_s,
304
+ None,
305
+ )
306
+ .map_err(to_sql_err)
307
+ },
308
+ )?;
309
+ conn.create_scalar_function(
310
+ "honker_scheduler_update",
311
+ 8,
312
+ FunctionFlags::SQLITE_UTF8,
313
+ |ctx| {
314
+ let name: String = ctx.get(0)?;
315
+ let cron_expr: Option<String> = ctx.get(1)?;
316
+ let payload: Option<String> = ctx.get(2)?;
317
+ let priority: Option<i64> = ctx.get(3)?;
318
+ let expires_s_arg: Option<i64> = ctx.get(4)?;
319
+ let touch_expires: i64 = ctx.get(5)?;
320
+ let max_attempts_arg: Option<i64> = ctx.get(6)?;
321
+ let touch_max_attempts: i64 = ctx.get(7)?;
322
+ let db = unsafe { ctx.get_connection() }?;
323
+ let expires_s = if touch_expires != 0 {
324
+ Some(expires_s_arg)
325
+ } else {
326
+ None
327
+ };
328
+ let max_attempts = if touch_max_attempts != 0 {
329
+ Some(max_attempts_arg)
330
+ } else {
331
+ None
332
+ };
333
+ scheduler_update(
334
+ &db,
335
+ &name,
336
+ cron_expr.as_deref(),
337
+ payload.as_deref(),
338
+ priority,
339
+ expires_s,
340
+ max_attempts,
260
341
  )
261
342
  .map_err(to_sql_err)
262
343
  },
@@ -457,6 +538,60 @@ pub fn attach_honker_functions(conn: &Connection) -> rusqlite::Result<()> {
457
538
  // Claim / ack
458
539
  // ---------------------------------------------------------------------
459
540
 
541
+ /// Move claimable rows that have already exhausted `max_attempts` into
542
+ /// `_honker_dead`. Without this, a worker that dies after the last
543
+ /// allowed claim leaves the row reclaimable forever — every reclaim
544
+ /// would bump `attempts` past `max_attempts` with no dead-letter path
545
+ /// (dead-letter previously only ran inside `retry()`).
546
+ ///
547
+ /// "Claimable" here matches the reclaim predicate: pending+due or
548
+ /// processing with an expired visibility timeout. In-flight claims
549
+ /// that still hold a valid timeout are left alone so the holder can
550
+ /// still ack / retry / fail.
551
+ fn dead_letter_exhausted_claimable(conn: &Connection, queue: &str) -> rusqlite::Result<i64> {
552
+ let mut select = conn.prepare_cached(
553
+ "DELETE FROM _honker_live
554
+ WHERE queue = ?1
555
+ AND attempts >= max_attempts
556
+ AND (expires_at IS NULL OR expires_at > unixepoch())
557
+ AND (
558
+ (state = 'pending' AND run_at <= unixepoch())
559
+ OR (state = 'processing' AND claim_expires_at < unixepoch())
560
+ )
561
+ RETURNING id, queue, payload, priority, run_at, max_attempts,
562
+ attempts, created_at",
563
+ )?;
564
+ #[allow(clippy::type_complexity)]
565
+ let rows: Vec<(i64, String, String, i64, i64, i64, i64, i64)> = select
566
+ .query_map(rusqlite::params![queue], |r| {
567
+ Ok((
568
+ r.get(0)?,
569
+ r.get(1)?,
570
+ r.get(2)?,
571
+ r.get(3)?,
572
+ r.get(4)?,
573
+ r.get(5)?,
574
+ r.get(6)?,
575
+ r.get(7)?,
576
+ ))
577
+ })?
578
+ .collect::<Result<Vec<_>, _>>()?;
579
+ if rows.is_empty() {
580
+ return Ok(0);
581
+ }
582
+ let mut insert = conn.prepare_cached(
583
+ "INSERT INTO _honker_dead
584
+ (id, queue, payload, priority, run_at, max_attempts,
585
+ attempts, last_error, created_at)
586
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'max attempts exceeded', ?8)",
587
+ )?;
588
+ let count = rows.len() as i64;
589
+ for r in rows {
590
+ insert.execute(rusqlite::params![r.0, r.1, r.2, r.3, r.4, r.5, r.6, r.7])?;
591
+ }
592
+ Ok(count)
593
+ }
594
+
460
595
  /// Returns JSON text: `[{"id":1,"queue":"...","payload":"...","worker_id":"...","attempts":N,"claim_expires_at":T}, ...]`
461
596
  pub fn claim_batch(
462
597
  conn: &Connection,
@@ -465,6 +600,12 @@ pub fn claim_batch(
465
600
  n: i64,
466
601
  timeout_s: i64,
467
602
  ) -> rusqlite::Result<String> {
603
+ // Drop reclaimable rows that already used their attempt budget so
604
+ // they cannot be claimed again (and so they don't clog the claim
605
+ // index forever). Same outer SQL statement / connection, so this
606
+ // shares the caller's transaction with the claim UPDATE below.
607
+ dead_letter_exhausted_claimable(conn, queue)?;
608
+
468
609
  let mut stmt = conn.prepare_cached(
469
610
  "UPDATE _honker_live
470
611
  SET state = 'processing',
@@ -475,6 +616,7 @@ pub fn claim_batch(
475
616
  SELECT id FROM _honker_live
476
617
  WHERE queue = ?2
477
618
  AND state IN ('pending', 'processing')
619
+ AND attempts < max_attempts
478
620
  AND (expires_at IS NULL OR expires_at > unixepoch())
479
621
  AND ((state = 'pending' AND run_at <= unixepoch())
480
622
  OR (state = 'processing' AND claim_expires_at < unixepoch()))
@@ -493,22 +635,21 @@ pub fn claim_batch(
493
635
  row.get::<_, i64>(5)?,
494
636
  ))
495
637
  })?;
496
- let mut out = String::from("[");
497
- let mut first = true;
638
+ let mut out = Vec::new();
498
639
  for row in rows {
499
640
  let (id, q, payload, w, attempts, claim_expires_at) = row?;
500
- if !first {
501
- out.push(',');
502
- }
503
- first = false;
504
- out.push_str(&format!(
505
- "{{\"id\":{},\"queue\":{},\"payload\":{},\"worker_id\":{},\"attempts\":{},\"claim_expires_at\":{}}}",
506
- id, json_str(&q), json_str(&payload), json_str(&w),
507
- attempts, claim_expires_at,
508
- ));
641
+ // payload stays a JSON string (double-encoded on the wire) so
642
+ // every binding's existing parse path keeps working.
643
+ out.push(json!({
644
+ "id": id,
645
+ "queue": q,
646
+ "payload": payload,
647
+ "worker_id": w,
648
+ "attempts": attempts,
649
+ "claim_expires_at": claim_expires_at,
650
+ }));
509
651
  }
510
- out.push(']');
511
- Ok(out)
652
+ Ok(Value::Array(out).to_string())
512
653
  }
513
654
 
514
655
  pub fn ack_batch(conn: &Connection, ids_json: &str, worker_id: &str) -> rusqlite::Result<i64> {
@@ -532,6 +673,9 @@ pub fn ack_batch(conn: &Connection, ids_json: &str, worker_id: &str) -> rusqlite
532
673
  /// * a pending row's `run_at`
533
674
  /// * one second after a processing row's `claim_expires_at`
534
675
  ///
676
+ /// Rows that have already exhausted `max_attempts` are ignored — they
677
+ /// are dead-lettered on the next claim path, not reclaimable.
678
+ ///
535
679
  /// Returns 0 if no such future deadline exists.
536
680
  pub fn queue_next_claim_at(conn: &Connection, queue: &str) -> rusqlite::Result<i64> {
537
681
  Ok(conn
@@ -542,6 +686,7 @@ pub fn queue_next_claim_at(conn: &Connection, queue: &str) -> rusqlite::Result<i
542
686
  FROM _honker_live
543
687
  WHERE queue = ?1
544
688
  AND state = 'pending'
689
+ AND attempts < max_attempts
545
690
  AND (expires_at IS NULL OR expires_at > unixepoch())
546
691
  AND run_at > unixepoch()
547
692
  UNION ALL
@@ -549,6 +694,7 @@ pub fn queue_next_claim_at(conn: &Connection, queue: &str) -> rusqlite::Result<i
549
694
  FROM _honker_live
550
695
  WHERE queue = ?1
551
696
  AND state = 'processing'
697
+ AND attempts < max_attempts
552
698
  AND (expires_at IS NULL OR expires_at > unixepoch())
553
699
  AND claim_expires_at >= unixepoch()
554
700
  )",
@@ -587,8 +733,12 @@ pub fn enqueue(
587
733
  (None, None) => now,
588
734
  };
589
735
  let expires_at: Option<i64> = expires.map(|e| now + e);
590
- let channel = format!("honker:{}", queue);
591
736
 
737
+ // No synthetic `_honker_notifications` row. The live-table INSERT
738
+ // already advances PRAGMA data_version on commit, which is what
739
+ // SharedUpdateWatcher / every binding's update_events path observes.
740
+ // Writing a wake row per enqueue used to grow the notifications
741
+ // table without bound on high-throughput queues.
592
742
  let id: i64 = conn.query_row(
593
743
  "INSERT INTO _honker_live
594
744
  (queue, payload, run_at, priority, max_attempts, expires_at)
@@ -604,12 +754,6 @@ pub fn enqueue(
604
754
  ],
605
755
  |r| r.get(0),
606
756
  )?;
607
- // Fire a wake so workers parked on this queue's channel re-poll.
608
- conn.execute(
609
- "INSERT INTO _honker_notifications (channel, payload)
610
- VALUES (?1, 'new')",
611
- rusqlite::params![channel],
612
- )?;
613
757
  Ok(id)
614
758
  }
615
759
 
@@ -700,14 +844,8 @@ pub fn retry(
700
844
  WHERE id = ?1",
701
845
  rusqlite::params![id, delay_s],
702
846
  )?;
703
- // Fire a wake the row is now claimable again (after the
704
- // delay), and waiting workers should re-poll.
705
- let channel = format!("honker:{}", queue);
706
- conn.execute(
707
- "INSERT INTO _honker_notifications (channel, payload)
708
- VALUES (?1, 'new')",
709
- rusqlite::params![channel],
710
- )?;
847
+ // Wake comes from the live-table UPDATE + commit (data_version).
848
+ // No synthetic notification row see enqueue() for rationale.
711
849
  }
712
850
  Ok(1)
713
851
  }
@@ -837,32 +975,21 @@ pub fn get_job(conn: &Connection, job_id: i64) -> rusqlite::Result<String> {
837
975
  else {
838
976
  return Ok(String::new());
839
977
  };
840
- let opt_str = |v: &Option<String>| match v {
841
- Some(s) => json_str(s),
842
- None => "null".into(),
843
- };
844
- let opt_i = |v: Option<i64>| match v {
845
- Some(n) => n.to_string(),
846
- None => "null".into(),
847
- };
848
- Ok(format!(
849
- "{{\"id\":{},\"queue\":{},\"payload\":{},\"state\":{},\
850
- \"priority\":{},\"run_at\":{},\"worker_id\":{},\
851
- \"claim_expires_at\":{},\"attempts\":{},\"max_attempts\":{},\
852
- \"created_at\":{},\"expires_at\":{}}}",
853
- id,
854
- json_str(&queue),
855
- json_str(&payload),
856
- json_str(&state),
857
- priority,
858
- run_at,
859
- opt_str(&worker_id),
860
- opt_i(claim_expires_at),
861
- attempts,
862
- max_attempts,
863
- created_at,
864
- opt_i(expires_at),
865
- ))
978
+ Ok(json!({
979
+ "id": id,
980
+ "queue": queue,
981
+ "payload": payload,
982
+ "state": state,
983
+ "priority": priority,
984
+ "run_at": run_at,
985
+ "worker_id": worker_id,
986
+ "claim_expires_at": claim_expires_at,
987
+ "attempts": attempts,
988
+ "max_attempts": max_attempts,
989
+ "created_at": created_at,
990
+ "expires_at": expires_at,
991
+ })
992
+ .to_string())
866
993
  }
867
994
 
868
995
  /// Extend the current claim by `extend_s` seconds. Returns 1 if the
@@ -874,10 +1001,14 @@ pub fn heartbeat(
874
1001
  worker_id: &str,
875
1002
  extend_s: i64,
876
1003
  ) -> rusqlite::Result<i64> {
1004
+ // Require a still-valid claim. Without `claim_expires_at >= now`,
1005
+ // a late heartbeat after visibility timeout can steal the job
1006
+ // back from a reclaimer (dual execution).
877
1007
  let updated = conn.execute(
878
1008
  "UPDATE _honker_live
879
1009
  SET claim_expires_at = unixepoch() + ?3
880
- WHERE id = ?1 AND worker_id = ?2 AND state = 'processing'",
1010
+ WHERE id = ?1 AND worker_id = ?2 AND state = 'processing'
1011
+ AND claim_expires_at >= unixepoch()",
881
1012
  rusqlite::params![job_id, worker_id, extend_s],
882
1013
  )?;
883
1014
  Ok(updated as i64)
@@ -972,6 +1103,25 @@ pub fn lock_release(conn: &Connection, name: &str, owner: &str) -> rusqlite::Res
972
1103
  Ok(deleted as i64)
973
1104
  }
974
1105
 
1106
+ /// Extend `expires_at` for a lock held by `owner`. Returns 1 if the
1107
+ /// row was updated, 0 if the lock is missing or held by someone else.
1108
+ ///
1109
+ /// `honker_lock_acquire` uses `INSERT OR IGNORE` and does **not**
1110
+ /// refresh TTL on same-owner re-acquire — callers that need renewal
1111
+ /// (scheduler leaders, long critical sections) must use this.
1112
+ pub fn lock_renew(conn: &Connection, name: &str, owner: &str, ttl_s: i64) -> rusqlite::Result<i64> {
1113
+ if ttl_s <= 0 {
1114
+ return Err(to_sql_err("ttl_s must be positive"));
1115
+ }
1116
+ let updated = conn.execute(
1117
+ "UPDATE _honker_locks
1118
+ SET expires_at = unixepoch() + ?3
1119
+ WHERE name = ?1 AND owner = ?2",
1120
+ rusqlite::params![name, owner, ttl_s],
1121
+ )?;
1122
+ Ok(if updated > 0 { 1 } else { 0 })
1123
+ }
1124
+
975
1125
  // ---------------------------------------------------------------------
976
1126
  // Rate limiting
977
1127
  // ---------------------------------------------------------------------
@@ -990,24 +1140,14 @@ pub fn rate_limit_try(
990
1140
  rusqlite::params![per],
991
1141
  |r| r.get(0),
992
1142
  )?;
993
- let current: i64 = conn
994
- .query_row(
995
- "SELECT COALESCE(MAX(count), 0) FROM _honker_rate_limits
996
- WHERE name = ?1 AND window_start = ?2",
997
- rusqlite::params![name, window_start],
998
- |r| r.get(0),
999
- )
1000
- .unwrap_or(0);
1001
- if current >= limit {
1002
- return Ok(0);
1003
- }
1004
- conn.execute(
1143
+ let changed = conn.execute(
1005
1144
  "INSERT INTO _honker_rate_limits (name, window_start, count)
1006
1145
  VALUES (?1, ?2, 1)
1007
- ON CONFLICT(name, window_start) DO UPDATE SET count = count + 1",
1008
- rusqlite::params![name, window_start],
1146
+ ON CONFLICT(name, window_start) DO UPDATE SET count = count + 1
1147
+ WHERE count < ?3",
1148
+ rusqlite::params![name, window_start, limit],
1009
1149
  )?;
1010
- Ok(1)
1150
+ Ok(if changed > 0 { 1 } else { 0 })
1011
1151
  }
1012
1152
 
1013
1153
  pub fn rate_limit_sweep(conn: &Connection, older_than_s: i64) -> rusqlite::Result<i64> {
@@ -1026,7 +1166,8 @@ pub fn rate_limit_sweep(conn: &Connection, older_than_s: i64) -> rusqlite::Resul
1026
1166
  /// Register (or re-register) a periodic task. `next_fire_at` is
1027
1167
  /// computed as the next cron boundary strictly after
1028
1168
  /// `unixepoch()`. Calling twice with the same name replaces the
1029
- /// first registration entirely.
1169
+ /// first registration entirely. `max_attempts` is stored on the task
1170
+ /// row and applied to every job `scheduler_tick` enqueues for it.
1030
1171
  pub fn scheduler_register(
1031
1172
  conn: &Connection,
1032
1173
  name: &str,
@@ -1035,20 +1176,23 @@ pub fn scheduler_register(
1035
1176
  payload: &str,
1036
1177
  priority: i64,
1037
1178
  expires_s: Option<i64>,
1179
+ max_attempts: i64,
1038
1180
  ) -> rusqlite::Result<i64> {
1181
+ let max_attempts = if max_attempts < 1 { 1 } else { max_attempts };
1039
1182
  let now = now_unix(conn)?;
1040
1183
  let next_fire_at = super::cron::next_after_unix(cron_expr, now).map_err(to_sql_err)?;
1041
1184
  conn.execute(
1042
1185
  "INSERT INTO _honker_scheduler_tasks
1043
- (name, queue, cron_expr, payload, priority, expires_s, next_fire_at)
1044
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
1186
+ (name, queue, cron_expr, payload, priority, expires_s, next_fire_at, max_attempts)
1187
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
1045
1188
  ON CONFLICT(name) DO UPDATE SET
1046
1189
  queue = excluded.queue,
1047
1190
  cron_expr = excluded.cron_expr,
1048
1191
  payload = excluded.payload,
1049
1192
  priority = excluded.priority,
1050
1193
  expires_s = excluded.expires_s,
1051
- next_fire_at = excluded.next_fire_at",
1194
+ next_fire_at = excluded.next_fire_at,
1195
+ max_attempts = excluded.max_attempts",
1052
1196
  rusqlite::params![
1053
1197
  name,
1054
1198
  queue,
@@ -1056,7 +1200,8 @@ pub fn scheduler_register(
1056
1200
  payload,
1057
1201
  priority,
1058
1202
  expires_s,
1059
- next_fire_at
1203
+ next_fire_at,
1204
+ max_attempts
1060
1205
  ],
1061
1206
  )?;
1062
1207
  // Wake any sleeping scheduler leader so it re-computes
@@ -1064,6 +1209,9 @@ pub fn scheduler_register(
1064
1209
  // this, a leader that went to sleep for an hour before a newly-
1065
1210
  // registered 1-minute-from-now task existed would oversleep past
1066
1211
  // its first fire.
1212
+ //
1213
+ // Wake is the register/update write itself advancing data_version
1214
+ // on commit — see scheduler_wake.
1067
1215
  scheduler_wake(conn)?;
1068
1216
  Ok(1)
1069
1217
  }
@@ -1083,29 +1231,42 @@ pub fn scheduler_unregister(conn: &Connection, name: &str) -> rusqlite::Result<i
1083
1231
  Ok(n as i64)
1084
1232
  }
1085
1233
 
1086
- /// INSERT a row on channel `honker:scheduler` so a sleeping scheduler
1087
- /// leader sitting on `update_events()` wakes and re-evaluates. Payload
1088
- /// is opaque — the leader doesn't read it, only the update tick matters.
1089
- fn scheduler_wake(conn: &Connection) -> rusqlite::Result<()> {
1090
- conn.execute(
1091
- "INSERT INTO _honker_notifications (channel, payload)
1092
- VALUES ('honker:scheduler', 'wake')",
1093
- [],
1094
- )?;
1234
+ /// Ensure a sleeping scheduler leader sitting on `update_events()`
1235
+ /// re-evaluates after a register/unregister/pause/resume/update.
1236
+ ///
1237
+ /// The register/unregister/pause/resume/update statements already
1238
+ /// mutate `_honker_scheduler_tasks`, which advances data_version on
1239
+ /// commit. A synthetic notification row used to be written here and
1240
+ /// grew without bound under frequent schedule edits — no longer needed.
1241
+ fn scheduler_wake(_conn: &Connection) -> rusqlite::Result<()> {
1095
1242
  Ok(())
1096
1243
  }
1097
1244
 
1245
+ /// Max fires enqueued for a single schedule row in one
1246
+ /// `scheduler_tick` call. After a long outage an `@every 1s` task
1247
+ /// would otherwise enqueue tens of thousands of jobs in one writer
1248
+ /// transaction.
1249
+ ///
1250
+ /// **Semantics (intentional):** once the cap is hit, remaining missed
1251
+ /// boundaries for that task are **skipped** — `next_fire_at` jumps to
1252
+ /// the next boundary strictly after `now_unix`. Those intermediate
1253
+ /// fires are never enqueued. Run the scheduler continuously, use
1254
+ /// coarser schedules, or raise this constant if every missed fire
1255
+ /// must be delivered.
1256
+ pub const SCHEDULER_MAX_CATCHUP_FIRES: i64 = 64;
1257
+
1098
1258
  /// For each registered task whose `next_fire_at <= now_unix`,
1099
1259
  /// enqueue the payload into its queue and advance `next_fire_at`
1100
1260
  /// to the next boundary. Keeps advancing within one tick while
1101
1261
  /// boundaries remain in the past (catches up after a scheduler
1102
- /// outage) same semantics as the previous Python `_fire_due`.
1262
+ /// outage), up to [`SCHEDULER_MAX_CATCHUP_FIRES`] per task.
1103
1263
  /// Returns a JSON array of `{name, queue, fire_at, job_id}` fires.
1104
1264
  pub fn scheduler_tick(conn: &Connection, now_unix: i64) -> rusqlite::Result<String> {
1105
1265
  #[allow(clippy::type_complexity)]
1106
- let tasks: Vec<(String, String, String, String, i64, Option<i64>, i64)> = {
1266
+ let tasks: Vec<(String, String, String, String, i64, Option<i64>, i64, i64)> = {
1107
1267
  let mut stmt = conn.prepare_cached(
1108
- "SELECT name, queue, cron_expr, payload, priority, expires_s, next_fire_at
1268
+ "SELECT name, queue, cron_expr, payload, priority, expires_s,
1269
+ next_fire_at, COALESCE(max_attempts, 3)
1109
1270
  FROM _honker_scheduler_tasks
1110
1271
  WHERE next_fire_at <= ?1 AND enabled = 1",
1111
1272
  )?;
@@ -1118,31 +1279,47 @@ pub fn scheduler_tick(conn: &Connection, now_unix: i64) -> rusqlite::Result<Stri
1118
1279
  r.get::<_, i64>(4)?,
1119
1280
  r.get::<_, Option<i64>>(5)?,
1120
1281
  r.get::<_, i64>(6)?,
1282
+ r.get::<_, i64>(7)?,
1121
1283
  ))
1122
1284
  })?
1123
1285
  .collect::<Result<Vec<_>, _>>()?
1124
1286
  };
1125
- let mut out = String::from("[");
1126
- let mut first = true;
1127
- for (name, queue, cron_expr, payload, priority, expires_s, mut next_fire_at) in tasks {
1287
+ let mut out = Vec::new();
1288
+ for (name, queue, cron_expr, payload, priority, expires_s, mut next_fire_at, max_attempts) in
1289
+ tasks
1290
+ {
1291
+ let mut fires_this_task: i64 = 0;
1128
1292
  while next_fire_at <= now_unix {
1293
+ if fires_this_task >= SCHEDULER_MAX_CATCHUP_FIRES {
1294
+ // Skip the remaining backlog. Resume from the next
1295
+ // boundary strictly after now so we don't immediately
1296
+ // re-enter the catch-up loop on the next tick.
1297
+ // Intermediate boundaries are intentionally never
1298
+ // enqueued (see SCHEDULER_MAX_CATCHUP_FIRES docs).
1299
+ next_fire_at =
1300
+ super::cron::next_after_unix(&cron_expr, now_unix).map_err(to_sql_err)?;
1301
+ break;
1302
+ }
1129
1303
  // Enqueue at this boundary. `run_at` is NULL (claimable
1130
1304
  // immediately); `expires` is the task's expires_s if set.
1305
+ // max_attempts comes from the schedule row, not a constant.
1131
1306
  let job_id = enqueue(
1132
- conn, &queue, &payload, None, None, priority, 3, /* max_attempts default */
1307
+ conn,
1308
+ &queue,
1309
+ &payload,
1310
+ None,
1311
+ None,
1312
+ priority,
1313
+ max_attempts,
1133
1314
  expires_s,
1134
1315
  )?;
1135
- if !first {
1136
- out.push(',');
1137
- }
1138
- first = false;
1139
- out.push_str(&format!(
1140
- "{{\"name\":{},\"queue\":{},\"fire_at\":{},\"job_id\":{}}}",
1141
- json_str(&name),
1142
- json_str(&queue),
1143
- next_fire_at,
1144
- job_id,
1145
- ));
1316
+ out.push(json!({
1317
+ "name": name,
1318
+ "queue": queue,
1319
+ "fire_at": next_fire_at,
1320
+ "job_id": job_id,
1321
+ }));
1322
+ fires_this_task += 1;
1146
1323
  // Advance to the next boundary strictly after this one.
1147
1324
  next_fire_at =
1148
1325
  super::cron::next_after_unix(&cron_expr, next_fire_at).map_err(to_sql_err)?;
@@ -1154,8 +1331,7 @@ pub fn scheduler_tick(conn: &Connection, now_unix: i64) -> rusqlite::Result<Stri
1154
1331
  rusqlite::params![name, next_fire_at],
1155
1332
  )?;
1156
1333
  }
1157
- out.push(']');
1158
- Ok(out)
1334
+ Ok(Value::Array(out).to_string())
1159
1335
  }
1160
1336
 
1161
1337
  pub fn scheduler_soonest(conn: &Connection) -> rusqlite::Result<i64> {
@@ -1195,15 +1371,25 @@ pub fn scheduler_resume(conn: &Connection, name: &str) -> rusqlite::Result<i64>
1195
1371
 
1196
1372
  /// Return all registered schedules as a JSON array. Each row:
1197
1373
  /// `{name, queue, cron_expr, payload, priority, expires_s,
1198
- /// next_fire_at, enabled}`.
1374
+ /// next_fire_at, enabled, max_attempts}`.
1199
1375
  pub fn scheduler_list(conn: &Connection) -> rusqlite::Result<String> {
1200
1376
  let mut stmt = conn.prepare(
1201
1377
  "SELECT name, queue, cron_expr, payload, priority, expires_s,
1202
- next_fire_at, enabled
1378
+ next_fire_at, enabled, COALESCE(max_attempts, 3)
1203
1379
  FROM _honker_scheduler_tasks
1204
1380
  ORDER BY name",
1205
1381
  )?;
1206
- let rows: Vec<(String, String, String, String, i64, Option<i64>, i64, i64)> = stmt
1382
+ let rows: Vec<(
1383
+ String,
1384
+ String,
1385
+ String,
1386
+ String,
1387
+ i64,
1388
+ Option<i64>,
1389
+ i64,
1390
+ i64,
1391
+ i64,
1392
+ )> = stmt
1207
1393
  .query_map([], |r| {
1208
1394
  Ok((
1209
1395
  r.get(0)?,
@@ -1214,35 +1400,36 @@ pub fn scheduler_list(conn: &Connection) -> rusqlite::Result<String> {
1214
1400
  r.get(5)?,
1215
1401
  r.get(6)?,
1216
1402
  r.get(7)?,
1403
+ r.get(8)?,
1217
1404
  ))
1218
1405
  })?
1219
1406
  .collect::<Result<Vec<_>, _>>()?;
1220
- let mut out = String::from("[");
1221
- for (i, (name, queue, cron_expr, payload, priority, expires_s, next_fire_at, enabled)) in
1222
- rows.iter().enumerate()
1407
+ let mut out = Vec::new();
1408
+ for (
1409
+ name,
1410
+ queue,
1411
+ cron_expr,
1412
+ payload,
1413
+ priority,
1414
+ expires_s,
1415
+ next_fire_at,
1416
+ enabled,
1417
+ max_attempts,
1418
+ ) in rows
1223
1419
  {
1224
- if i > 0 {
1225
- out.push(',');
1226
- }
1227
- let expires_repr = match expires_s {
1228
- Some(v) => v.to_string(),
1229
- None => "null".into(),
1230
- };
1231
- out.push_str(&format!(
1232
- "{{\"name\":{},\"queue\":{},\"cron_expr\":{},\"payload\":{},\
1233
- \"priority\":{},\"expires_s\":{},\"next_fire_at\":{},\"enabled\":{}}}",
1234
- json_str(name),
1235
- json_str(queue),
1236
- json_str(cron_expr),
1237
- json_str(payload),
1238
- priority,
1239
- expires_repr,
1240
- next_fire_at,
1241
- if *enabled != 0 { "true" } else { "false" },
1242
- ));
1420
+ out.push(json!({
1421
+ "name": name,
1422
+ "queue": queue,
1423
+ "cron_expr": cron_expr,
1424
+ "payload": payload,
1425
+ "priority": priority,
1426
+ "expires_s": expires_s,
1427
+ "next_fire_at": next_fire_at,
1428
+ "enabled": enabled != 0,
1429
+ "max_attempts": max_attempts,
1430
+ }));
1243
1431
  }
1244
- out.push(']');
1245
- Ok(out)
1432
+ Ok(Value::Array(out).to_string())
1246
1433
  }
1247
1434
 
1248
1435
  /// Mutate one or more fields of a registered schedule. Pass `None` for
@@ -1257,6 +1444,7 @@ pub fn scheduler_update(
1257
1444
  payload: Option<&str>,
1258
1445
  priority: Option<i64>,
1259
1446
  expires_s: Option<Option<i64>>,
1447
+ max_attempts: Option<Option<i64>>,
1260
1448
  ) -> rusqlite::Result<i64> {
1261
1449
  // Verify exists first so we can return 0 cleanly without dynamic SQL gymnastics.
1262
1450
  let exists: bool = conn
@@ -1272,7 +1460,8 @@ pub fn scheduler_update(
1272
1460
  let any_field = cron_expr.is_some()
1273
1461
  || payload.is_some()
1274
1462
  || priority.is_some()
1275
- || expires_s.is_some();
1463
+ || expires_s.is_some()
1464
+ || max_attempts.is_some();
1276
1465
  if !any_field {
1277
1466
  // No fields to change. Don't wake the leader for a no-op.
1278
1467
  return Ok(0);
@@ -1306,6 +1495,14 @@ pub fn scheduler_update(
1306
1495
  rusqlite::params![name, e],
1307
1496
  )?;
1308
1497
  }
1498
+ if let Some(m) = max_attempts {
1499
+ let m = m.unwrap_or(3);
1500
+ let m = if m < 1 { 1 } else { m };
1501
+ conn.execute(
1502
+ "UPDATE _honker_scheduler_tasks SET max_attempts = ?2 WHERE name = ?1",
1503
+ rusqlite::params![name, m],
1504
+ )?;
1505
+ }
1309
1506
  if let Some(expr) = cron_expr {
1310
1507
  conn.execute(
1311
1508
  "UPDATE _honker_scheduler_tasks
@@ -1316,8 +1513,10 @@ pub fn scheduler_update(
1316
1513
  Ok(())
1317
1514
  })();
1318
1515
  if result.is_err() {
1319
- let _ = conn.execute_batch("ROLLBACK TO SAVEPOINT honker_sched_update; \
1320
- RELEASE SAVEPOINT honker_sched_update");
1516
+ let _ = conn.execute_batch(
1517
+ "ROLLBACK TO SAVEPOINT honker_sched_update; \
1518
+ RELEASE SAVEPOINT honker_sched_update",
1519
+ );
1321
1520
  result?;
1322
1521
  }
1323
1522
  conn.execute_batch("RELEASE SAVEPOINT honker_sched_update")?;
@@ -1391,6 +1590,8 @@ pub fn stream_publish(
1391
1590
  key: Option<&str>,
1392
1591
  payload: &str,
1393
1592
  ) -> rusqlite::Result<i64> {
1593
+ // Stream row INSERT advances data_version on commit — same wake
1594
+ // path as enqueue. No synthetic notification row (see enqueue).
1394
1595
  let offset: i64 = conn.query_row(
1395
1596
  "INSERT INTO _honker_stream (topic, key, payload)
1396
1597
  VALUES (?1, ?2, ?3)
@@ -1398,12 +1599,6 @@ pub fn stream_publish(
1398
1599
  rusqlite::params![topic, key, payload],
1399
1600
  |r| r.get(0),
1400
1601
  )?;
1401
- let channel = format!("honker:stream:{}", topic);
1402
- conn.execute(
1403
- "INSERT INTO _honker_notifications (channel, payload)
1404
- VALUES (?1, 'new')",
1405
- rusqlite::params![channel],
1406
- )?;
1407
1602
  Ok(offset)
1408
1603
  }
1409
1604
 
@@ -1432,29 +1627,18 @@ pub fn stream_read_since(
1432
1627
  r.get::<_, i64>(4)?,
1433
1628
  ))
1434
1629
  })?;
1435
- let mut out = String::from("[");
1436
- let mut first = true;
1630
+ let mut out = Vec::new();
1437
1631
  for row in rows {
1438
1632
  let (off, top, key, payload, created_at) = row?;
1439
- if !first {
1440
- out.push(',');
1441
- }
1442
- first = false;
1443
- let key_tok = match key {
1444
- Some(s) => json_str(&s),
1445
- None => "null".to_string(),
1446
- };
1447
- out.push_str(&format!(
1448
- "{{\"offset\":{},\"topic\":{},\"key\":{},\"payload\":{},\"created_at\":{}}}",
1449
- off,
1450
- json_str(&top),
1451
- key_tok,
1452
- json_str(&payload),
1453
- created_at,
1454
- ));
1633
+ out.push(json!({
1634
+ "offset": off,
1635
+ "topic": top,
1636
+ "key": key,
1637
+ "payload": payload,
1638
+ "created_at": created_at,
1639
+ }));
1455
1640
  }
1456
- out.push(']');
1457
- Ok(out)
1641
+ Ok(Value::Array(out).to_string())
1458
1642
  }
1459
1643
 
1460
1644
  pub fn stream_save_offset(
@@ -1493,26 +1677,3 @@ pub fn stream_get_offset(conn: &Connection, consumer: &str, topic: &str) -> rusq
1493
1677
  fn now_unix(conn: &Connection) -> rusqlite::Result<i64> {
1494
1678
  conn.query_row("SELECT unixepoch()", [], |r| r.get(0))
1495
1679
  }
1496
-
1497
- /// Escape a string for inclusion as a JSON string literal. Used by
1498
- /// `claim_batch` to build its JSON array return value without
1499
- /// pulling in serde_json just for one site.
1500
- fn json_str(s: &str) -> String {
1501
- let mut out = String::with_capacity(s.len() + 2);
1502
- out.push('"');
1503
- for c in s.chars() {
1504
- match c {
1505
- '"' => out.push_str("\\\""),
1506
- '\\' => out.push_str("\\\\"),
1507
- '\n' => out.push_str("\\n"),
1508
- '\r' => out.push_str("\\r"),
1509
- '\t' => out.push_str("\\t"),
1510
- c if (c as u32) < 0x20 => {
1511
- out.push_str(&format!("\\u{:04x}", c as u32));
1512
- }
1513
- c => out.push(c),
1514
- }
1515
- }
1516
- out.push('"');
1517
- out
1518
- }