honker 0.1.2 → 0.3.0

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.
@@ -0,0 +1,1518 @@
1
+ //! Rust implementations of the `honker_*` SQL scalar functions, plus a
2
+ //! single `attach_honker_functions` helper that registers them on a
3
+ //! [`rusqlite::Connection`].
4
+ //!
5
+ //! Consumers:
6
+ //! * `honker-extension` — the loadable SQLite extension. Calls
7
+ //! `attach_honker_functions` so `.load ./libhonker_ext` in any
8
+ //! SQLite client exposes the full function set.
9
+ //! * `packages/honker` — the PyO3 binding. Calls
10
+ //! `attach_honker_functions` on its writer connection so Python
11
+ //! can invoke `SELECT honker_*(...)` inside its own transactions
12
+ //! without loading the `.dylib` at runtime.
13
+ //! * Future bindings (Go, Ruby, napi-rs) — load the extension via
14
+ //! SQLite's `sqlite3_load_extension` and get the same functions
15
+ //! for free.
16
+ //!
17
+ //! Rationale: each per-language binding would otherwise re-implement
18
+ //! this SQL. Moving it here gives us one source of truth that's
19
+ //! tested once and inherited by every consumer.
20
+
21
+ use rusqlite::Connection;
22
+ use rusqlite::functions::FunctionFlags;
23
+
24
+ /// Wrap a Displayable error for SQLite scalar-function returns.
25
+ fn to_sql_err<E: std::fmt::Display>(e: E) -> rusqlite::Error {
26
+ rusqlite::Error::UserFunctionError(Box::new(std::io::Error::new(
27
+ std::io::ErrorKind::Other,
28
+ e.to_string(),
29
+ )))
30
+ }
31
+
32
+ /// Register all `honker_*` honker scalar functions on `conn`. Idempotent
33
+ /// per-connection: creating the same function twice is a rusqlite
34
+ /// error, so call exactly once per connection.
35
+ pub fn attach_honker_functions(conn: &Connection) -> rusqlite::Result<()> {
36
+ conn.create_scalar_function("honker_bootstrap", 0, FunctionFlags::SQLITE_UTF8, |ctx| {
37
+ let db = unsafe { ctx.get_connection() }?;
38
+ super::bootstrap_honker_schema(&db).map_err(to_sql_err)?;
39
+ Ok(1i64)
40
+ })?;
41
+
42
+ conn.create_scalar_function("honker_claim_batch", 4, FunctionFlags::SQLITE_UTF8, |ctx| {
43
+ let queue: String = ctx.get(0)?;
44
+ let worker_id: String = ctx.get(1)?;
45
+ let n: i64 = ctx.get(2)?;
46
+ let timeout_s: i64 = ctx.get(3)?;
47
+ let db = unsafe { ctx.get_connection() }?;
48
+ claim_batch(&db, &queue, &worker_id, n, timeout_s).map_err(to_sql_err)
49
+ })?;
50
+
51
+ conn.create_scalar_function("honker_ack_batch", 2, FunctionFlags::SQLITE_UTF8, |ctx| {
52
+ let ids_json: String = ctx.get(0)?;
53
+ let worker_id: String = ctx.get(1)?;
54
+ let db = unsafe { ctx.get_connection() }?;
55
+ ack_batch(&db, &ids_json, &worker_id).map_err(to_sql_err)
56
+ })?;
57
+
58
+ conn.create_scalar_function(
59
+ "honker_queue_next_claim_at",
60
+ 1,
61
+ FunctionFlags::SQLITE_UTF8,
62
+ |ctx| {
63
+ let queue: String = ctx.get(0)?;
64
+ let db = unsafe { ctx.get_connection() }?;
65
+ queue_next_claim_at(&db, &queue).map_err(to_sql_err)
66
+ },
67
+ )?;
68
+
69
+ conn.create_scalar_function(
70
+ "honker_sweep_expired",
71
+ 1,
72
+ FunctionFlags::SQLITE_UTF8,
73
+ |ctx| {
74
+ let queue: String = ctx.get(0)?;
75
+ let db = unsafe { ctx.get_connection() }?;
76
+ sweep_expired(&db, &queue).map_err(to_sql_err)
77
+ },
78
+ )?;
79
+
80
+ conn.create_scalar_function(
81
+ "honker_lock_acquire",
82
+ 3,
83
+ FunctionFlags::SQLITE_UTF8,
84
+ |ctx| {
85
+ let name: String = ctx.get(0)?;
86
+ let owner: String = ctx.get(1)?;
87
+ let ttl: i64 = ctx.get(2)?;
88
+ let db = unsafe { ctx.get_connection() }?;
89
+ lock_acquire(&db, &name, &owner, ttl).map_err(to_sql_err)
90
+ },
91
+ )?;
92
+
93
+ conn.create_scalar_function(
94
+ "honker_lock_release",
95
+ 2,
96
+ FunctionFlags::SQLITE_UTF8,
97
+ |ctx| {
98
+ let name: String = ctx.get(0)?;
99
+ let owner: String = ctx.get(1)?;
100
+ let db = unsafe { ctx.get_connection() }?;
101
+ lock_release(&db, &name, &owner).map_err(to_sql_err)
102
+ },
103
+ )?;
104
+
105
+ conn.create_scalar_function(
106
+ "honker_rate_limit_try",
107
+ 3,
108
+ FunctionFlags::SQLITE_UTF8,
109
+ |ctx| {
110
+ let name: String = ctx.get(0)?;
111
+ let limit: i64 = ctx.get(1)?;
112
+ let per: i64 = ctx.get(2)?;
113
+ let db = unsafe { ctx.get_connection() }?;
114
+ rate_limit_try(&db, &name, limit, per).map_err(to_sql_err)
115
+ },
116
+ )?;
117
+
118
+ conn.create_scalar_function(
119
+ "honker_rate_limit_sweep",
120
+ 1,
121
+ FunctionFlags::SQLITE_UTF8,
122
+ |ctx| {
123
+ let older_than_s: i64 = ctx.get(0)?;
124
+ let db = unsafe { ctx.get_connection() }?;
125
+ rate_limit_sweep(&db, older_than_s).map_err(to_sql_err)
126
+ },
127
+ )?;
128
+
129
+ // honker_scheduler_register(name, queue, cron_expr, payload_json,
130
+ // priority, expires_s_or_null) -> 1.
131
+ // Upserts the task row. `next_fire_at` is recomputed as the next
132
+ // cron boundary strictly after `unixepoch()`. Calling twice with
133
+ // the same name replaces the first registration entirely.
134
+ conn.create_scalar_function(
135
+ "honker_scheduler_register",
136
+ 6,
137
+ FunctionFlags::SQLITE_UTF8,
138
+ |ctx| {
139
+ let name: String = ctx.get(0)?;
140
+ let queue: String = ctx.get(1)?;
141
+ let cron_expr: String = ctx.get(2)?;
142
+ let payload: String = ctx.get(3)?;
143
+ let priority: i64 = ctx.get(4)?;
144
+ let expires_s: Option<i64> = ctx.get(5)?;
145
+ let db = unsafe { ctx.get_connection() }?;
146
+ scheduler_register(
147
+ &db, &name, &queue, &cron_expr, &payload, priority, expires_s,
148
+ )
149
+ .map_err(to_sql_err)
150
+ },
151
+ )?;
152
+
153
+ // honker_scheduler_unregister(name) -> rows deleted (0 or 1).
154
+ conn.create_scalar_function(
155
+ "honker_scheduler_unregister",
156
+ 1,
157
+ FunctionFlags::SQLITE_UTF8,
158
+ |ctx| {
159
+ let name: String = ctx.get(0)?;
160
+ let db = unsafe { ctx.get_connection() }?;
161
+ scheduler_unregister(&db, &name).map_err(to_sql_err)
162
+ },
163
+ )?;
164
+
165
+ // honker_scheduler_tick(now_unix) -> JSON array of fires. For each
166
+ // registered task whose `next_fire_at <= now`, enqueues the
167
+ // payload into the task's queue, advances `next_fire_at` to the
168
+ // next cron boundary, and appends `{name, queue, fire_at,
169
+ // job_id}` to the output array. Caller typically holds
170
+ // `_honker_locks` entry 'honker-scheduler' for mutual
171
+ // exclusion across scheduler processes.
172
+ conn.create_scalar_function(
173
+ "honker_scheduler_tick",
174
+ 1,
175
+ FunctionFlags::SQLITE_UTF8,
176
+ |ctx| {
177
+ let now_unix: i64 = ctx.get(0)?;
178
+ let db = unsafe { ctx.get_connection() }?;
179
+ scheduler_tick(&db, now_unix).map_err(to_sql_err)
180
+ },
181
+ )?;
182
+
183
+ // honker_scheduler_soonest() -> unix ts of the earliest next_fire_at
184
+ // across all registered tasks, or 0 if no tasks. Scheduler main
185
+ // loop uses this to compute its sleep duration.
186
+ conn.create_scalar_function(
187
+ "honker_scheduler_soonest",
188
+ 0,
189
+ FunctionFlags::SQLITE_UTF8,
190
+ |ctx| {
191
+ let db = unsafe { ctx.get_connection() }?;
192
+ scheduler_soonest(&db).map_err(to_sql_err)
193
+ },
194
+ )?;
195
+
196
+ // honker_scheduler_pause(name) / _resume(name) -> 1 if toggled, 0 otherwise.
197
+ conn.create_scalar_function(
198
+ "honker_scheduler_pause",
199
+ 1,
200
+ FunctionFlags::SQLITE_UTF8,
201
+ |ctx| {
202
+ let name: String = ctx.get(0)?;
203
+ let db = unsafe { ctx.get_connection() }?;
204
+ scheduler_pause(&db, &name).map_err(to_sql_err)
205
+ },
206
+ )?;
207
+ conn.create_scalar_function(
208
+ "honker_scheduler_resume",
209
+ 1,
210
+ FunctionFlags::SQLITE_UTF8,
211
+ |ctx| {
212
+ let name: String = ctx.get(0)?;
213
+ let db = unsafe { ctx.get_connection() }?;
214
+ scheduler_resume(&db, &name).map_err(to_sql_err)
215
+ },
216
+ )?;
217
+
218
+ // honker_scheduler_list() -> JSON array of all schedules with state.
219
+ conn.create_scalar_function(
220
+ "honker_scheduler_list",
221
+ 0,
222
+ FunctionFlags::SQLITE_UTF8,
223
+ |ctx| {
224
+ let db = unsafe { ctx.get_connection() }?;
225
+ scheduler_list(&db).map_err(to_sql_err)
226
+ },
227
+ )?;
228
+
229
+ // honker_scheduler_update(name, cron_expr_or_null, payload_or_null,
230
+ // priority_or_null, expires_s_or_null,
231
+ // touch_expires) -> 1 if updated, 0 if missing.
232
+ // `touch_expires` is a 0/1 flag: when 1 we treat the expires_s arg
233
+ // as the desired value (which may be NULL = "clear"); when 0 we
234
+ // leave expires_s untouched. SQL has no good way to distinguish
235
+ // "user passed NULL" from "user did not specify" otherwise.
236
+ conn.create_scalar_function(
237
+ "honker_scheduler_update",
238
+ 6,
239
+ FunctionFlags::SQLITE_UTF8,
240
+ |ctx| {
241
+ let name: String = ctx.get(0)?;
242
+ let cron_expr: Option<String> = ctx.get(1)?;
243
+ let payload: Option<String> = ctx.get(2)?;
244
+ let priority: Option<i64> = ctx.get(3)?;
245
+ let expires_s_arg: Option<i64> = ctx.get(4)?;
246
+ let touch_expires: i64 = ctx.get(5)?;
247
+ let db = unsafe { ctx.get_connection() }?;
248
+ let expires_s = if touch_expires != 0 {
249
+ Some(expires_s_arg)
250
+ } else {
251
+ None
252
+ };
253
+ scheduler_update(
254
+ &db,
255
+ &name,
256
+ cron_expr.as_deref(),
257
+ payload.as_deref(),
258
+ priority,
259
+ expires_s,
260
+ )
261
+ .map_err(to_sql_err)
262
+ },
263
+ )?;
264
+
265
+ conn.create_scalar_function("honker_result_save", 3, FunctionFlags::SQLITE_UTF8, |ctx| {
266
+ let job_id: i64 = ctx.get(0)?;
267
+ let value: String = ctx.get(1)?;
268
+ let ttl_s: i64 = ctx.get(2)?;
269
+ let db = unsafe { ctx.get_connection() }?;
270
+ result_save(&db, job_id, &value, ttl_s).map_err(to_sql_err)
271
+ })?;
272
+
273
+ conn.create_scalar_function("honker_result_get", 1, FunctionFlags::SQLITE_UTF8, |ctx| {
274
+ let job_id: i64 = ctx.get(0)?;
275
+ let db = unsafe { ctx.get_connection() }?;
276
+ result_get(&db, job_id).map_err(to_sql_err)
277
+ })?;
278
+
279
+ conn.create_scalar_function(
280
+ "honker_result_sweep",
281
+ 0,
282
+ FunctionFlags::SQLITE_UTF8,
283
+ |ctx| {
284
+ let db = unsafe { ctx.get_connection() }?;
285
+ result_sweep(&db).map_err(to_sql_err)
286
+ },
287
+ )?;
288
+
289
+ // honker_enqueue(queue, payload, run_at_or_null, delay_or_null,
290
+ // priority, max_attempts, expires_or_null) -> inserted id.
291
+ // Precedence: if `delay` is not NULL, use `unixepoch() + delay`;
292
+ // else if `run_at` is not NULL, use that literal; else use
293
+ // `unixepoch()`. `expires` is `unixepoch() + expires` if non-NULL,
294
+ // else NULL (never expires).
295
+ conn.create_scalar_function("honker_enqueue", 7, FunctionFlags::SQLITE_UTF8, |ctx| {
296
+ let queue: String = ctx.get(0)?;
297
+ let payload: String = ctx.get(1)?;
298
+ let run_at: Option<i64> = ctx.get(2)?;
299
+ let delay: Option<i64> = ctx.get(3)?;
300
+ let priority: i64 = ctx.get(4)?;
301
+ let max_attempts: i64 = ctx.get(5)?;
302
+ let expires: Option<i64> = ctx.get(6)?;
303
+ let db = unsafe { ctx.get_connection() }?;
304
+ enqueue(
305
+ &db,
306
+ &queue,
307
+ &payload,
308
+ run_at,
309
+ delay,
310
+ priority,
311
+ max_attempts,
312
+ expires,
313
+ )
314
+ .map_err(to_sql_err)
315
+ })?;
316
+
317
+ // honker_ack(job_id, worker_id) -> 1 if ack'd, 0 if claim expired /
318
+ // not ours.
319
+ conn.create_scalar_function("honker_ack", 2, FunctionFlags::SQLITE_UTF8, |ctx| {
320
+ let job_id: i64 = ctx.get(0)?;
321
+ let worker_id: String = ctx.get(1)?;
322
+ let db = unsafe { ctx.get_connection() }?;
323
+ ack(&db, job_id, &worker_id).map_err(to_sql_err)
324
+ })?;
325
+
326
+ // honker_retry(job_id, worker_id, delay_s, error) -> 1 if retried /
327
+ // moved to dead, 0 if not our claim. If attempts >= max_attempts,
328
+ // moves the row to `_honker_dead` instead of flipping it back
329
+ // to pending. Fires a notify on the queue's channel on successful
330
+ // pending-flip (so waiting workers wake).
331
+ conn.create_scalar_function("honker_retry", 4, FunctionFlags::SQLITE_UTF8, |ctx| {
332
+ let job_id: i64 = ctx.get(0)?;
333
+ let worker_id: String = ctx.get(1)?;
334
+ let delay_s: i64 = ctx.get(2)?;
335
+ let error: String = ctx.get(3)?;
336
+ let db = unsafe { ctx.get_connection() }?;
337
+ retry(&db, job_id, &worker_id, delay_s, &error).map_err(to_sql_err)
338
+ })?;
339
+
340
+ // honker_fail(job_id, worker_id, error) -> 1 if failed-to-dead, 0 if
341
+ // not our claim.
342
+ conn.create_scalar_function("honker_fail", 3, FunctionFlags::SQLITE_UTF8, |ctx| {
343
+ let job_id: i64 = ctx.get(0)?;
344
+ let worker_id: String = ctx.get(1)?;
345
+ let error: String = ctx.get(2)?;
346
+ let db = unsafe { ctx.get_connection() }?;
347
+ fail(&db, job_id, &worker_id, &error).map_err(to_sql_err)
348
+ })?;
349
+
350
+ // honker_heartbeat(job_id, worker_id, extend_s) -> 1 if extended, 0
351
+ // if not our claim.
352
+ conn.create_scalar_function("honker_heartbeat", 3, FunctionFlags::SQLITE_UTF8, |ctx| {
353
+ let job_id: i64 = ctx.get(0)?;
354
+ let worker_id: String = ctx.get(1)?;
355
+ let extend_s: i64 = ctx.get(2)?;
356
+ let db = unsafe { ctx.get_connection() }?;
357
+ heartbeat(&db, job_id, &worker_id, extend_s).map_err(to_sql_err)
358
+ })?;
359
+
360
+ // honker_cancel(job_id) -> 1 if a pending/processing row was removed,
361
+ // 0 otherwise. Idempotent on missing.
362
+ conn.create_scalar_function("honker_cancel", 1, FunctionFlags::SQLITE_UTF8, |ctx| {
363
+ let job_id: i64 = ctx.get(0)?;
364
+ let db = unsafe { ctx.get_connection() }?;
365
+ cancel(&db, job_id).map_err(to_sql_err)
366
+ })?;
367
+
368
+ // honker_get_job(job_id) -> JSON object on hit, empty string on miss.
369
+ conn.create_scalar_function("honker_get_job", 1, FunctionFlags::SQLITE_UTF8, |ctx| {
370
+ let job_id: i64 = ctx.get(0)?;
371
+ let db = unsafe { ctx.get_connection() }?;
372
+ get_job(&db, job_id).map_err(to_sql_err)
373
+ })?;
374
+
375
+ // honker_cron_next_after(expr, from_unix) -> unix_ts of next boundary
376
+ // strictly after `from_unix`, minute precision, system local time.
377
+ // Same 5-field grammar as standard Unix cron. Deterministic +
378
+ // pure; marked DETERMINISTIC to let SQLite optimize inside joins.
379
+ conn.create_scalar_function(
380
+ "honker_cron_next_after",
381
+ 2,
382
+ FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC,
383
+ |ctx| {
384
+ let expr: String = ctx.get(0)?;
385
+ let from_unix: i64 = ctx.get(1)?;
386
+ super::cron::next_after_unix(&expr, from_unix).map_err(to_sql_err)
387
+ },
388
+ )?;
389
+
390
+ // Stream functions. One impl for every binding; _honker_stream +
391
+ // _honker_stream_consumers are the shared on-disk layout.
392
+
393
+ // honker_stream_publish(topic, key_or_null, payload_json) -> offset.
394
+ // INSERTs one event and fires a wake on honker:stream:<topic>.
395
+ conn.create_scalar_function(
396
+ "honker_stream_publish",
397
+ 3,
398
+ FunctionFlags::SQLITE_UTF8,
399
+ |ctx| {
400
+ let topic: String = ctx.get(0)?;
401
+ let key: Option<String> = ctx.get(1)?;
402
+ let payload: String = ctx.get(2)?;
403
+ let db = unsafe { ctx.get_connection() }?;
404
+ stream_publish(&db, &topic, key.as_deref(), &payload).map_err(to_sql_err)
405
+ },
406
+ )?;
407
+
408
+ // honker_stream_read_since(topic, offset, limit) -> JSON array of
409
+ // {offset, topic, key, payload, created_at}.
410
+ conn.create_scalar_function(
411
+ "honker_stream_read_since",
412
+ 3,
413
+ FunctionFlags::SQLITE_UTF8,
414
+ |ctx| {
415
+ let topic: String = ctx.get(0)?;
416
+ let offset: i64 = ctx.get(1)?;
417
+ let limit: i64 = ctx.get(2)?;
418
+ let db = unsafe { ctx.get_connection() }?;
419
+ stream_read_since(&db, &topic, offset, limit).map_err(to_sql_err)
420
+ },
421
+ )?;
422
+
423
+ // honker_stream_save_offset(consumer, topic, offset) -> 1 if row
424
+ // advanced (new row or higher offset), 0 if the saved offset is
425
+ // already >= `offset`. Monotonic: never rewinds on duplicate
426
+ // deliveries.
427
+ conn.create_scalar_function(
428
+ "honker_stream_save_offset",
429
+ 3,
430
+ FunctionFlags::SQLITE_UTF8,
431
+ |ctx| {
432
+ let consumer: String = ctx.get(0)?;
433
+ let topic: String = ctx.get(1)?;
434
+ let offset: i64 = ctx.get(2)?;
435
+ let db = unsafe { ctx.get_connection() }?;
436
+ stream_save_offset(&db, &consumer, &topic, offset).map_err(to_sql_err)
437
+ },
438
+ )?;
439
+
440
+ // honker_stream_get_offset(consumer, topic) -> offset or 0.
441
+ conn.create_scalar_function(
442
+ "honker_stream_get_offset",
443
+ 2,
444
+ FunctionFlags::SQLITE_UTF8,
445
+ |ctx| {
446
+ let consumer: String = ctx.get(0)?;
447
+ let topic: String = ctx.get(1)?;
448
+ let db = unsafe { ctx.get_connection() }?;
449
+ stream_get_offset(&db, &consumer, &topic).map_err(to_sql_err)
450
+ },
451
+ )?;
452
+
453
+ Ok(())
454
+ }
455
+
456
+ // ---------------------------------------------------------------------
457
+ // Claim / ack
458
+ // ---------------------------------------------------------------------
459
+
460
+ /// Returns JSON text: `[{"id":1,"queue":"...","payload":"...","worker_id":"...","attempts":N,"claim_expires_at":T}, ...]`
461
+ pub fn claim_batch(
462
+ conn: &Connection,
463
+ queue: &str,
464
+ worker_id: &str,
465
+ n: i64,
466
+ timeout_s: i64,
467
+ ) -> rusqlite::Result<String> {
468
+ let mut stmt = conn.prepare_cached(
469
+ "UPDATE _honker_live
470
+ SET state = 'processing',
471
+ worker_id = ?1,
472
+ claim_expires_at = unixepoch() + ?4,
473
+ attempts = attempts + 1
474
+ WHERE id IN (
475
+ SELECT id FROM _honker_live
476
+ WHERE queue = ?2
477
+ AND state IN ('pending', 'processing')
478
+ AND (expires_at IS NULL OR expires_at > unixepoch())
479
+ AND ((state = 'pending' AND run_at <= unixepoch())
480
+ OR (state = 'processing' AND claim_expires_at < unixepoch()))
481
+ ORDER BY priority DESC, run_at ASC, id ASC
482
+ LIMIT ?3
483
+ )
484
+ RETURNING id, queue, payload, worker_id, attempts, claim_expires_at",
485
+ )?;
486
+ let rows = stmt.query_map(rusqlite::params![worker_id, queue, n, timeout_s], |row| {
487
+ Ok((
488
+ row.get::<_, i64>(0)?,
489
+ row.get::<_, String>(1)?,
490
+ row.get::<_, String>(2)?,
491
+ row.get::<_, String>(3)?,
492
+ row.get::<_, i64>(4)?,
493
+ row.get::<_, i64>(5)?,
494
+ ))
495
+ })?;
496
+ let mut out = String::from("[");
497
+ let mut first = true;
498
+ for row in rows {
499
+ 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
+ ));
509
+ }
510
+ out.push(']');
511
+ Ok(out)
512
+ }
513
+
514
+ pub fn ack_batch(conn: &Connection, ids_json: &str, worker_id: &str) -> rusqlite::Result<i64> {
515
+ let mut stmt = conn.prepare_cached(
516
+ "DELETE FROM _honker_live
517
+ WHERE id IN (SELECT value FROM json_each(?1))
518
+ AND worker_id = ?2
519
+ AND claim_expires_at >= unixepoch()
520
+ RETURNING id",
521
+ )?;
522
+ let mut rows = stmt.query(rusqlite::params![ids_json, worker_id])?;
523
+ let mut count = 0;
524
+ while rows.next()?.is_some() {
525
+ count += 1;
526
+ }
527
+ Ok(count)
528
+ }
529
+
530
+ /// Return the earliest future deadline that could make `claim_batch()`
531
+ /// return non-empty for this queue:
532
+ /// * a pending row's `run_at`
533
+ /// * one second after a processing row's `claim_expires_at`
534
+ ///
535
+ /// Returns 0 if no such future deadline exists.
536
+ pub fn queue_next_claim_at(conn: &Connection, queue: &str) -> rusqlite::Result<i64> {
537
+ Ok(conn
538
+ .query_row(
539
+ "SELECT COALESCE(MIN(deadline), 0)
540
+ FROM (
541
+ SELECT MIN(run_at) AS deadline
542
+ FROM _honker_live
543
+ WHERE queue = ?1
544
+ AND state = 'pending'
545
+ AND (expires_at IS NULL OR expires_at > unixepoch())
546
+ AND run_at > unixepoch()
547
+ UNION ALL
548
+ SELECT MIN(claim_expires_at + 1) AS deadline
549
+ FROM _honker_live
550
+ WHERE queue = ?1
551
+ AND state = 'processing'
552
+ AND (expires_at IS NULL OR expires_at > unixepoch())
553
+ AND claim_expires_at >= unixepoch()
554
+ )",
555
+ rusqlite::params![queue],
556
+ |r| r.get(0),
557
+ )
558
+ .unwrap_or(0))
559
+ }
560
+
561
+ // ---------------------------------------------------------------------
562
+ // Enqueue / single-job ack / retry / fail / heartbeat
563
+ // ---------------------------------------------------------------------
564
+
565
+ /// INSERT a job. Returns the new row's id.
566
+ ///
567
+ /// Scheduling (lowest-to-highest precedence):
568
+ /// - no run_at, no delay → `unixepoch()` (claimable immediately)
569
+ /// - run_at set → that literal unix timestamp
570
+ /// - delay set → `unixepoch() + delay` (wins over run_at)
571
+ ///
572
+ /// Expiration: NULL = never; `Some(s)` = `unixepoch() + s`.
573
+ pub fn enqueue(
574
+ conn: &Connection,
575
+ queue: &str,
576
+ payload: &str,
577
+ run_at: Option<i64>,
578
+ delay: Option<i64>,
579
+ priority: i64,
580
+ max_attempts: i64,
581
+ expires: Option<i64>,
582
+ ) -> rusqlite::Result<i64> {
583
+ let now: i64 = conn.query_row("SELECT unixepoch()", [], |r| r.get(0))?;
584
+ let run_at_val: i64 = match (delay, run_at) {
585
+ (Some(d), _) => now + d,
586
+ (None, Some(r)) => r,
587
+ (None, None) => now,
588
+ };
589
+ let expires_at: Option<i64> = expires.map(|e| now + e);
590
+ let channel = format!("honker:{}", queue);
591
+
592
+ let id: i64 = conn.query_row(
593
+ "INSERT INTO _honker_live
594
+ (queue, payload, run_at, priority, max_attempts, expires_at)
595
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6)
596
+ RETURNING id",
597
+ rusqlite::params![
598
+ queue,
599
+ payload,
600
+ run_at_val,
601
+ priority,
602
+ max_attempts,
603
+ expires_at
604
+ ],
605
+ |r| r.get(0),
606
+ )?;
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
+ Ok(id)
614
+ }
615
+
616
+ /// Single-job ack. DELETEs the row if the caller's claim is still
617
+ /// valid. Returns 1 on success, 0 if the claim expired or the row
618
+ /// isn't ours.
619
+ pub fn ack(conn: &Connection, job_id: i64, worker_id: &str) -> rusqlite::Result<i64> {
620
+ let deleted = conn.execute(
621
+ "DELETE FROM _honker_live
622
+ WHERE id = ?1 AND worker_id = ?2 AND claim_expires_at >= unixepoch()",
623
+ rusqlite::params![job_id, worker_id],
624
+ )?;
625
+ Ok(deleted as i64)
626
+ }
627
+
628
+ /// Retry or fail based on `attempts` vs `max_attempts`. If another
629
+ /// attempt is allowed, flips the row back to `'pending'` with
630
+ /// `run_at = unixepoch() + delay_s` and fires a wake. Otherwise
631
+ /// DELETEs from `_honker_live` and INSERTs into `_honker_dead`
632
+ /// with `last_error=error`.
633
+ ///
634
+ /// Returns 1 if either branch ran, 0 if the claim is no longer valid
635
+ /// (expired / not our worker / row moved on).
636
+ pub fn retry(
637
+ conn: &Connection,
638
+ job_id: i64,
639
+ worker_id: &str,
640
+ delay_s: i64,
641
+ error: &str,
642
+ ) -> rusqlite::Result<i64> {
643
+ #[allow(clippy::type_complexity)]
644
+ let row: Option<(i64, String, String, i64, i64, i64, i64, i64)> = conn
645
+ .query_row(
646
+ "SELECT id, queue, payload, priority, run_at, max_attempts,
647
+ attempts, created_at
648
+ FROM _honker_live
649
+ WHERE id = ?1 AND worker_id = ?2
650
+ AND claim_expires_at >= unixepoch()
651
+ AND state = 'processing'",
652
+ rusqlite::params![job_id, worker_id],
653
+ |r| {
654
+ Ok((
655
+ r.get(0)?,
656
+ r.get(1)?,
657
+ r.get(2)?,
658
+ r.get(3)?,
659
+ r.get(4)?,
660
+ r.get(5)?,
661
+ r.get(6)?,
662
+ r.get(7)?,
663
+ ))
664
+ },
665
+ )
666
+ .ok();
667
+ let Some((id, queue, payload, priority, run_at, max_attempts, attempts, created_at)) = row
668
+ else {
669
+ return Ok(0);
670
+ };
671
+ if attempts >= max_attempts {
672
+ conn.execute(
673
+ "DELETE FROM _honker_live WHERE id = ?1",
674
+ rusqlite::params![id],
675
+ )?;
676
+ conn.execute(
677
+ "INSERT INTO _honker_dead
678
+ (id, queue, payload, priority, run_at, max_attempts,
679
+ attempts, last_error, created_at)
680
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
681
+ rusqlite::params![
682
+ id,
683
+ queue,
684
+ payload,
685
+ priority,
686
+ run_at,
687
+ max_attempts,
688
+ attempts,
689
+ error,
690
+ created_at
691
+ ],
692
+ )?;
693
+ } else {
694
+ conn.execute(
695
+ "UPDATE _honker_live
696
+ SET state = 'pending',
697
+ run_at = unixepoch() + ?2,
698
+ worker_id = NULL,
699
+ claim_expires_at = NULL
700
+ WHERE id = ?1",
701
+ rusqlite::params![id, delay_s],
702
+ )?;
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
+ )?;
711
+ }
712
+ Ok(1)
713
+ }
714
+
715
+ /// Unconditionally move the claim to `_honker_dead` with the given
716
+ /// error. Returns 1 if moved, 0 if not our claim.
717
+ pub fn fail(conn: &Connection, job_id: i64, worker_id: &str, error: &str) -> rusqlite::Result<i64> {
718
+ #[allow(clippy::type_complexity)]
719
+ let row: Option<(i64, String, String, i64, i64, i64, i64, i64)> = conn
720
+ .query_row(
721
+ "DELETE FROM _honker_live
722
+ WHERE id = ?1 AND worker_id = ?2
723
+ AND claim_expires_at >= unixepoch()
724
+ RETURNING id, queue, payload, priority, run_at, max_attempts,
725
+ attempts, created_at",
726
+ rusqlite::params![job_id, worker_id],
727
+ |r| {
728
+ Ok((
729
+ r.get(0)?,
730
+ r.get(1)?,
731
+ r.get(2)?,
732
+ r.get(3)?,
733
+ r.get(4)?,
734
+ r.get(5)?,
735
+ r.get(6)?,
736
+ r.get(7)?,
737
+ ))
738
+ },
739
+ )
740
+ .ok();
741
+ let Some((id, queue, payload, priority, run_at, max_attempts, attempts, created_at)) = row
742
+ else {
743
+ return Ok(0);
744
+ };
745
+ conn.execute(
746
+ "INSERT INTO _honker_dead
747
+ (id, queue, payload, priority, run_at, max_attempts,
748
+ attempts, last_error, created_at)
749
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
750
+ rusqlite::params![
751
+ id,
752
+ queue,
753
+ payload,
754
+ priority,
755
+ run_at,
756
+ max_attempts,
757
+ attempts,
758
+ error,
759
+ created_at
760
+ ],
761
+ )?;
762
+ Ok(1)
763
+ }
764
+
765
+ /// Cancel a job by id. Removes pending or processing rows from
766
+ /// `_honker_live` regardless of which worker (if any) holds it.
767
+ /// Returns 1 if a row was removed, 0 otherwise. Idempotent.
768
+ ///
769
+ /// Use case: an operator decides a queued or in-flight job is no
770
+ /// longer needed (the upstream request was cancelled, the user
771
+ /// changed their mind). Note that for a `state='processing'` row,
772
+ /// the worker holding the claim will see `ack()` return 0 on its
773
+ /// next call — same shape as a claim that simply expired.
774
+ pub fn cancel(conn: &Connection, job_id: i64) -> rusqlite::Result<i64> {
775
+ let n = conn.execute(
776
+ "DELETE FROM _honker_live WHERE id = ?1 AND state IN ('pending', 'processing')",
777
+ rusqlite::params![job_id],
778
+ )?;
779
+ Ok(n as i64)
780
+ }
781
+
782
+ /// Read a single job row by id. Returns a JSON object on success or
783
+ /// the empty string on miss (job ack'd, dead'd, or never existed).
784
+ /// Pure read — does not change state.
785
+ pub fn get_job(conn: &Connection, job_id: i64) -> rusqlite::Result<String> {
786
+ let row: Option<(
787
+ i64,
788
+ String,
789
+ String,
790
+ String,
791
+ i64,
792
+ i64,
793
+ Option<String>,
794
+ Option<i64>,
795
+ i64,
796
+ i64,
797
+ i64,
798
+ Option<i64>,
799
+ )> = conn
800
+ .query_row(
801
+ "SELECT id, queue, payload, state, priority, run_at, worker_id,
802
+ claim_expires_at, attempts, max_attempts, created_at, expires_at
803
+ FROM _honker_live WHERE id = ?1",
804
+ rusqlite::params![job_id],
805
+ |r| {
806
+ Ok((
807
+ r.get(0)?,
808
+ r.get(1)?,
809
+ r.get(2)?,
810
+ r.get(3)?,
811
+ r.get(4)?,
812
+ r.get(5)?,
813
+ r.get(6)?,
814
+ r.get(7)?,
815
+ r.get(8)?,
816
+ r.get(9)?,
817
+ r.get(10)?,
818
+ r.get(11)?,
819
+ ))
820
+ },
821
+ )
822
+ .ok();
823
+ let Some((
824
+ id,
825
+ queue,
826
+ payload,
827
+ state,
828
+ priority,
829
+ run_at,
830
+ worker_id,
831
+ claim_expires_at,
832
+ attempts,
833
+ max_attempts,
834
+ created_at,
835
+ expires_at,
836
+ )) = row
837
+ else {
838
+ return Ok(String::new());
839
+ };
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
+ ))
866
+ }
867
+
868
+ /// Extend the current claim by `extend_s` seconds. Returns 1 if the
869
+ /// heartbeat landed, 0 if we're not the holder (either the row is
870
+ /// in a different state or worker_id doesn't match).
871
+ pub fn heartbeat(
872
+ conn: &Connection,
873
+ job_id: i64,
874
+ worker_id: &str,
875
+ extend_s: i64,
876
+ ) -> rusqlite::Result<i64> {
877
+ let updated = conn.execute(
878
+ "UPDATE _honker_live
879
+ SET claim_expires_at = unixepoch() + ?3
880
+ WHERE id = ?1 AND worker_id = ?2 AND state = 'processing'",
881
+ rusqlite::params![job_id, worker_id, extend_s],
882
+ )?;
883
+ Ok(updated as i64)
884
+ }
885
+
886
+ // ---------------------------------------------------------------------
887
+ // Task expiration
888
+ // ---------------------------------------------------------------------
889
+
890
+ /// Move expired-pending rows from `_honker_live` to `_honker_dead`
891
+ /// with `last_error='expired'`. Returns count moved.
892
+ pub fn sweep_expired(conn: &Connection, queue: &str) -> rusqlite::Result<i64> {
893
+ let mut select = conn.prepare_cached(
894
+ "DELETE FROM _honker_live
895
+ WHERE queue = ?1
896
+ AND state = 'pending'
897
+ AND expires_at IS NOT NULL
898
+ AND expires_at <= unixepoch()
899
+ RETURNING id, queue, payload, priority, run_at, max_attempts,
900
+ attempts, created_at",
901
+ )?;
902
+ #[allow(clippy::type_complexity)]
903
+ let rows: Vec<(i64, String, String, i64, i64, i64, i64, i64)> = select
904
+ .query_map(rusqlite::params![queue], |r| {
905
+ Ok((
906
+ r.get(0)?,
907
+ r.get(1)?,
908
+ r.get(2)?,
909
+ r.get(3)?,
910
+ r.get(4)?,
911
+ r.get(5)?,
912
+ r.get(6)?,
913
+ r.get(7)?,
914
+ ))
915
+ })?
916
+ .collect::<Result<Vec<_>, _>>()?;
917
+ if rows.is_empty() {
918
+ return Ok(0);
919
+ }
920
+ let mut insert = conn.prepare_cached(
921
+ "INSERT INTO _honker_dead
922
+ (id, queue, payload, priority, run_at, max_attempts,
923
+ attempts, last_error, created_at)
924
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'expired', ?8)",
925
+ )?;
926
+ let count = rows.len() as i64;
927
+ for r in rows {
928
+ insert.execute(rusqlite::params![r.0, r.1, r.2, r.3, r.4, r.5, r.6, r.7])?;
929
+ }
930
+ Ok(count)
931
+ }
932
+
933
+ // ---------------------------------------------------------------------
934
+ // Named locks
935
+ // ---------------------------------------------------------------------
936
+
937
+ pub fn lock_acquire(
938
+ conn: &Connection,
939
+ name: &str,
940
+ owner: &str,
941
+ ttl_s: i64,
942
+ ) -> rusqlite::Result<i64> {
943
+ conn.execute(
944
+ "DELETE FROM _honker_locks
945
+ WHERE name = ?1 AND expires_at <= unixepoch()",
946
+ rusqlite::params![name],
947
+ )?;
948
+ conn.execute(
949
+ "INSERT OR IGNORE INTO _honker_locks (name, owner, expires_at)
950
+ VALUES (?1, ?2, unixepoch() + ?3)",
951
+ rusqlite::params![name, owner, ttl_s],
952
+ )?;
953
+ let current: Option<String> = conn
954
+ .query_row(
955
+ "SELECT owner FROM _honker_locks WHERE name = ?1",
956
+ rusqlite::params![name],
957
+ |r| r.get(0),
958
+ )
959
+ .ok();
960
+ Ok(if current.as_deref() == Some(owner) {
961
+ 1
962
+ } else {
963
+ 0
964
+ })
965
+ }
966
+
967
+ pub fn lock_release(conn: &Connection, name: &str, owner: &str) -> rusqlite::Result<i64> {
968
+ let deleted = conn.execute(
969
+ "DELETE FROM _honker_locks WHERE name = ?1 AND owner = ?2",
970
+ rusqlite::params![name, owner],
971
+ )?;
972
+ Ok(deleted as i64)
973
+ }
974
+
975
+ // ---------------------------------------------------------------------
976
+ // Rate limiting
977
+ // ---------------------------------------------------------------------
978
+
979
+ pub fn rate_limit_try(
980
+ conn: &Connection,
981
+ name: &str,
982
+ limit: i64,
983
+ per: i64,
984
+ ) -> rusqlite::Result<i64> {
985
+ if limit <= 0 || per <= 0 {
986
+ return Err(to_sql_err("limit and per must be positive"));
987
+ }
988
+ let window_start: i64 = conn.query_row(
989
+ "SELECT (unixepoch() / ?1) * ?1",
990
+ rusqlite::params![per],
991
+ |r| r.get(0),
992
+ )?;
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(
1005
+ "INSERT INTO _honker_rate_limits (name, window_start, count)
1006
+ VALUES (?1, ?2, 1)
1007
+ ON CONFLICT(name, window_start) DO UPDATE SET count = count + 1",
1008
+ rusqlite::params![name, window_start],
1009
+ )?;
1010
+ Ok(1)
1011
+ }
1012
+
1013
+ pub fn rate_limit_sweep(conn: &Connection, older_than_s: i64) -> rusqlite::Result<i64> {
1014
+ let deleted = conn.execute(
1015
+ "DELETE FROM _honker_rate_limits
1016
+ WHERE window_start < unixepoch() - ?1",
1017
+ rusqlite::params![older_than_s],
1018
+ )?;
1019
+ Ok(deleted as i64)
1020
+ }
1021
+
1022
+ // ---------------------------------------------------------------------
1023
+ // Scheduler state
1024
+ // ---------------------------------------------------------------------
1025
+
1026
+ /// Register (or re-register) a periodic task. `next_fire_at` is
1027
+ /// computed as the next cron boundary strictly after
1028
+ /// `unixepoch()`. Calling twice with the same name replaces the
1029
+ /// first registration entirely.
1030
+ pub fn scheduler_register(
1031
+ conn: &Connection,
1032
+ name: &str,
1033
+ queue: &str,
1034
+ cron_expr: &str,
1035
+ payload: &str,
1036
+ priority: i64,
1037
+ expires_s: Option<i64>,
1038
+ ) -> rusqlite::Result<i64> {
1039
+ let now = now_unix(conn)?;
1040
+ let next_fire_at = super::cron::next_after_unix(cron_expr, now).map_err(to_sql_err)?;
1041
+ conn.execute(
1042
+ "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)
1045
+ ON CONFLICT(name) DO UPDATE SET
1046
+ queue = excluded.queue,
1047
+ cron_expr = excluded.cron_expr,
1048
+ payload = excluded.payload,
1049
+ priority = excluded.priority,
1050
+ expires_s = excluded.expires_s,
1051
+ next_fire_at = excluded.next_fire_at",
1052
+ rusqlite::params![
1053
+ name,
1054
+ queue,
1055
+ cron_expr,
1056
+ payload,
1057
+ priority,
1058
+ expires_s,
1059
+ next_fire_at
1060
+ ],
1061
+ )?;
1062
+ // Wake any sleeping scheduler leader so it re-computes
1063
+ // honker_scheduler_soonest() against the new task set. Without
1064
+ // this, a leader that went to sleep for an hour before a newly-
1065
+ // registered 1-minute-from-now task existed would oversleep past
1066
+ // its first fire.
1067
+ scheduler_wake(conn)?;
1068
+ Ok(1)
1069
+ }
1070
+
1071
+ pub fn scheduler_unregister(conn: &Connection, name: &str) -> rusqlite::Result<i64> {
1072
+ let n = conn.execute(
1073
+ "DELETE FROM _honker_scheduler_tasks WHERE name = ?1",
1074
+ rusqlite::params![name],
1075
+ )?;
1076
+ if n > 0 {
1077
+ // Unregister can only make the "soonest" later, so a sleeping
1078
+ // leader wouldn't miss anything by oversleeping. But waking it
1079
+ // lets the loop observe the removal and notice if the table is
1080
+ // now empty (soonest() returns 0 → leader exits cleanly).
1081
+ scheduler_wake(conn)?;
1082
+ }
1083
+ Ok(n as i64)
1084
+ }
1085
+
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
+ )?;
1095
+ Ok(())
1096
+ }
1097
+
1098
+ /// For each registered task whose `next_fire_at <= now_unix`,
1099
+ /// enqueue the payload into its queue and advance `next_fire_at`
1100
+ /// to the next boundary. Keeps advancing within one tick while
1101
+ /// boundaries remain in the past (catches up after a scheduler
1102
+ /// outage) — same semantics as the previous Python `_fire_due`.
1103
+ /// Returns a JSON array of `{name, queue, fire_at, job_id}` fires.
1104
+ pub fn scheduler_tick(conn: &Connection, now_unix: i64) -> rusqlite::Result<String> {
1105
+ #[allow(clippy::type_complexity)]
1106
+ let tasks: Vec<(String, String, String, String, i64, Option<i64>, i64)> = {
1107
+ let mut stmt = conn.prepare_cached(
1108
+ "SELECT name, queue, cron_expr, payload, priority, expires_s, next_fire_at
1109
+ FROM _honker_scheduler_tasks
1110
+ WHERE next_fire_at <= ?1 AND enabled = 1",
1111
+ )?;
1112
+ stmt.query_map(rusqlite::params![now_unix], |r| {
1113
+ Ok((
1114
+ r.get::<_, String>(0)?,
1115
+ r.get::<_, String>(1)?,
1116
+ r.get::<_, String>(2)?,
1117
+ r.get::<_, String>(3)?,
1118
+ r.get::<_, i64>(4)?,
1119
+ r.get::<_, Option<i64>>(5)?,
1120
+ r.get::<_, i64>(6)?,
1121
+ ))
1122
+ })?
1123
+ .collect::<Result<Vec<_>, _>>()?
1124
+ };
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 {
1128
+ while next_fire_at <= now_unix {
1129
+ // Enqueue at this boundary. `run_at` is NULL (claimable
1130
+ // immediately); `expires` is the task's expires_s if set.
1131
+ let job_id = enqueue(
1132
+ conn, &queue, &payload, None, None, priority, 3, /* max_attempts default */
1133
+ expires_s,
1134
+ )?;
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
+ ));
1146
+ // Advance to the next boundary strictly after this one.
1147
+ next_fire_at =
1148
+ super::cron::next_after_unix(&cron_expr, next_fire_at).map_err(to_sql_err)?;
1149
+ }
1150
+ // Persist the advanced next_fire_at.
1151
+ conn.execute(
1152
+ "UPDATE _honker_scheduler_tasks
1153
+ SET next_fire_at = ?2 WHERE name = ?1",
1154
+ rusqlite::params![name, next_fire_at],
1155
+ )?;
1156
+ }
1157
+ out.push(']');
1158
+ Ok(out)
1159
+ }
1160
+
1161
+ pub fn scheduler_soonest(conn: &Connection) -> rusqlite::Result<i64> {
1162
+ Ok(conn
1163
+ .query_row(
1164
+ "SELECT COALESCE(MIN(next_fire_at), 0) FROM _honker_scheduler_tasks WHERE enabled = 1",
1165
+ [],
1166
+ |r| r.get(0),
1167
+ )
1168
+ .unwrap_or(0))
1169
+ }
1170
+
1171
+ /// Toggle `enabled` on a registered schedule. Returns 1 if updated, 0
1172
+ /// if the name doesn't exist. Wakes the leader so `scheduler_soonest`
1173
+ /// is recomputed against the new active set.
1174
+ pub fn scheduler_pause(conn: &Connection, name: &str) -> rusqlite::Result<i64> {
1175
+ let n = conn.execute(
1176
+ "UPDATE _honker_scheduler_tasks SET enabled = 0 WHERE name = ?1 AND enabled = 1",
1177
+ rusqlite::params![name],
1178
+ )?;
1179
+ if n > 0 {
1180
+ scheduler_wake(conn)?;
1181
+ }
1182
+ Ok(n as i64)
1183
+ }
1184
+
1185
+ pub fn scheduler_resume(conn: &Connection, name: &str) -> rusqlite::Result<i64> {
1186
+ let n = conn.execute(
1187
+ "UPDATE _honker_scheduler_tasks SET enabled = 1 WHERE name = ?1 AND enabled = 0",
1188
+ rusqlite::params![name],
1189
+ )?;
1190
+ if n > 0 {
1191
+ scheduler_wake(conn)?;
1192
+ }
1193
+ Ok(n as i64)
1194
+ }
1195
+
1196
+ /// Return all registered schedules as a JSON array. Each row:
1197
+ /// `{name, queue, cron_expr, payload, priority, expires_s,
1198
+ /// next_fire_at, enabled}`.
1199
+ pub fn scheduler_list(conn: &Connection) -> rusqlite::Result<String> {
1200
+ let mut stmt = conn.prepare(
1201
+ "SELECT name, queue, cron_expr, payload, priority, expires_s,
1202
+ next_fire_at, enabled
1203
+ FROM _honker_scheduler_tasks
1204
+ ORDER BY name",
1205
+ )?;
1206
+ let rows: Vec<(String, String, String, String, i64, Option<i64>, i64, i64)> = stmt
1207
+ .query_map([], |r| {
1208
+ Ok((
1209
+ r.get(0)?,
1210
+ r.get(1)?,
1211
+ r.get(2)?,
1212
+ r.get(3)?,
1213
+ r.get(4)?,
1214
+ r.get(5)?,
1215
+ r.get(6)?,
1216
+ r.get(7)?,
1217
+ ))
1218
+ })?
1219
+ .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()
1223
+ {
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
+ ));
1243
+ }
1244
+ out.push(']');
1245
+ Ok(out)
1246
+ }
1247
+
1248
+ /// Mutate one or more fields of a registered schedule. Pass `None` for
1249
+ /// fields that should be left unchanged. If `cron_expr` is provided,
1250
+ /// `next_fire_at` is recomputed from `unixepoch()`. Returns 1 if the
1251
+ /// row was updated, 0 if it doesn't exist.
1252
+ #[allow(clippy::too_many_arguments)]
1253
+ pub fn scheduler_update(
1254
+ conn: &Connection,
1255
+ name: &str,
1256
+ cron_expr: Option<&str>,
1257
+ payload: Option<&str>,
1258
+ priority: Option<i64>,
1259
+ expires_s: Option<Option<i64>>,
1260
+ ) -> rusqlite::Result<i64> {
1261
+ // Verify exists first so we can return 0 cleanly without dynamic SQL gymnastics.
1262
+ let exists: bool = conn
1263
+ .query_row(
1264
+ "SELECT 1 FROM _honker_scheduler_tasks WHERE name = ?1",
1265
+ rusqlite::params![name],
1266
+ |_| Ok(true),
1267
+ )
1268
+ .unwrap_or(false);
1269
+ if !exists {
1270
+ return Ok(0);
1271
+ }
1272
+ let any_field = cron_expr.is_some()
1273
+ || payload.is_some()
1274
+ || priority.is_some()
1275
+ || expires_s.is_some();
1276
+ if !any_field {
1277
+ // No fields to change. Don't wake the leader for a no-op.
1278
+ return Ok(0);
1279
+ }
1280
+ // Wrap field UPDATEs in a SAVEPOINT so a concurrent reader can't
1281
+ // observe half-applied state. SAVEPOINT instead of BEGIN/COMMIT so
1282
+ // we play nicely if the caller already holds an outer tx.
1283
+ let next_fire_at = if let Some(expr) = cron_expr {
1284
+ let now = now_unix(conn)?;
1285
+ Some(super::cron::next_after_unix(expr, now).map_err(to_sql_err)?)
1286
+ } else {
1287
+ None
1288
+ };
1289
+ conn.execute_batch("SAVEPOINT honker_sched_update")?;
1290
+ let result: rusqlite::Result<()> = (|| {
1291
+ if let Some(p) = payload {
1292
+ conn.execute(
1293
+ "UPDATE _honker_scheduler_tasks SET payload = ?2 WHERE name = ?1",
1294
+ rusqlite::params![name, p],
1295
+ )?;
1296
+ }
1297
+ if let Some(p) = priority {
1298
+ conn.execute(
1299
+ "UPDATE _honker_scheduler_tasks SET priority = ?2 WHERE name = ?1",
1300
+ rusqlite::params![name, p],
1301
+ )?;
1302
+ }
1303
+ if let Some(e) = expires_s {
1304
+ conn.execute(
1305
+ "UPDATE _honker_scheduler_tasks SET expires_s = ?2 WHERE name = ?1",
1306
+ rusqlite::params![name, e],
1307
+ )?;
1308
+ }
1309
+ if let Some(expr) = cron_expr {
1310
+ conn.execute(
1311
+ "UPDATE _honker_scheduler_tasks
1312
+ SET cron_expr = ?2, next_fire_at = ?3 WHERE name = ?1",
1313
+ rusqlite::params![name, expr, next_fire_at.unwrap()],
1314
+ )?;
1315
+ }
1316
+ Ok(())
1317
+ })();
1318
+ if result.is_err() {
1319
+ let _ = conn.execute_batch("ROLLBACK TO SAVEPOINT honker_sched_update; \
1320
+ RELEASE SAVEPOINT honker_sched_update");
1321
+ result?;
1322
+ }
1323
+ conn.execute_batch("RELEASE SAVEPOINT honker_sched_update")?;
1324
+ scheduler_wake(conn)?;
1325
+ Ok(1)
1326
+ }
1327
+
1328
+ // ---------------------------------------------------------------------
1329
+ // Task result storage
1330
+ // ---------------------------------------------------------------------
1331
+
1332
+ pub fn result_save(
1333
+ conn: &Connection,
1334
+ job_id: i64,
1335
+ value: &str,
1336
+ ttl_s: i64,
1337
+ ) -> rusqlite::Result<i64> {
1338
+ if ttl_s > 0 {
1339
+ conn.execute(
1340
+ "INSERT INTO _honker_results (job_id, value, expires_at)
1341
+ VALUES (?1, ?2, unixepoch() + ?3)
1342
+ ON CONFLICT(job_id) DO UPDATE
1343
+ SET value = excluded.value,
1344
+ expires_at = excluded.expires_at",
1345
+ rusqlite::params![job_id, value, ttl_s],
1346
+ )?;
1347
+ } else {
1348
+ conn.execute(
1349
+ "INSERT INTO _honker_results (job_id, value, expires_at)
1350
+ VALUES (?1, ?2, NULL)
1351
+ ON CONFLICT(job_id) DO UPDATE
1352
+ SET value = excluded.value,
1353
+ expires_at = NULL",
1354
+ rusqlite::params![job_id, value],
1355
+ )?;
1356
+ }
1357
+ Ok(1)
1358
+ }
1359
+
1360
+ pub fn result_get(conn: &Connection, job_id: i64) -> rusqlite::Result<Option<String>> {
1361
+ let row: Option<(Option<String>, Option<i64>)> = conn
1362
+ .query_row(
1363
+ "SELECT value, expires_at FROM _honker_results WHERE job_id = ?1",
1364
+ rusqlite::params![job_id],
1365
+ |r| Ok((r.get(0)?, r.get(1)?)),
1366
+ )
1367
+ .ok();
1368
+ match row {
1369
+ None => Ok(None),
1370
+ Some((_, Some(exp))) if exp <= now_unix(conn)? => Ok(None),
1371
+ Some((value, _)) => Ok(value),
1372
+ }
1373
+ }
1374
+
1375
+ pub fn result_sweep(conn: &Connection) -> rusqlite::Result<i64> {
1376
+ let deleted = conn.execute(
1377
+ "DELETE FROM _honker_results
1378
+ WHERE expires_at IS NOT NULL AND expires_at <= unixepoch()",
1379
+ [],
1380
+ )?;
1381
+ Ok(deleted as i64)
1382
+ }
1383
+
1384
+ // ---------------------------------------------------------------------
1385
+ // Streams
1386
+ // ---------------------------------------------------------------------
1387
+
1388
+ pub fn stream_publish(
1389
+ conn: &Connection,
1390
+ topic: &str,
1391
+ key: Option<&str>,
1392
+ payload: &str,
1393
+ ) -> rusqlite::Result<i64> {
1394
+ let offset: i64 = conn.query_row(
1395
+ "INSERT INTO _honker_stream (topic, key, payload)
1396
+ VALUES (?1, ?2, ?3)
1397
+ RETURNING offset",
1398
+ rusqlite::params![topic, key, payload],
1399
+ |r| r.get(0),
1400
+ )?;
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
+ Ok(offset)
1408
+ }
1409
+
1410
+ /// Returns JSON: `[{"offset":N,"topic":"t","key":"k_or_null","payload":"...","created_at":T}, ...]`.
1411
+ /// `key` is a raw JSON token — `null` for SQL NULL, otherwise a JSON
1412
+ /// string literal.
1413
+ pub fn stream_read_since(
1414
+ conn: &Connection,
1415
+ topic: &str,
1416
+ offset: i64,
1417
+ limit: i64,
1418
+ ) -> rusqlite::Result<String> {
1419
+ let mut stmt = conn.prepare_cached(
1420
+ "SELECT offset, topic, key, payload, created_at
1421
+ FROM _honker_stream
1422
+ WHERE topic = ?1 AND offset > ?2
1423
+ ORDER BY offset ASC
1424
+ LIMIT ?3",
1425
+ )?;
1426
+ let rows = stmt.query_map(rusqlite::params![topic, offset, limit], |r| {
1427
+ Ok((
1428
+ r.get::<_, i64>(0)?,
1429
+ r.get::<_, String>(1)?,
1430
+ r.get::<_, Option<String>>(2)?,
1431
+ r.get::<_, String>(3)?,
1432
+ r.get::<_, i64>(4)?,
1433
+ ))
1434
+ })?;
1435
+ let mut out = String::from("[");
1436
+ let mut first = true;
1437
+ for row in rows {
1438
+ 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
+ ));
1455
+ }
1456
+ out.push(']');
1457
+ Ok(out)
1458
+ }
1459
+
1460
+ pub fn stream_save_offset(
1461
+ conn: &Connection,
1462
+ consumer: &str,
1463
+ topic: &str,
1464
+ offset: i64,
1465
+ ) -> rusqlite::Result<i64> {
1466
+ // Monotonic upsert: WHERE excluded.offset > existing. The CHANGES
1467
+ // pragma reports affected rows, which we translate to 1/0.
1468
+ let changed = conn.execute(
1469
+ "INSERT INTO _honker_stream_consumers (name, topic, offset)
1470
+ VALUES (?1, ?2, ?3)
1471
+ ON CONFLICT(name, topic) DO UPDATE SET offset = excluded.offset
1472
+ WHERE excluded.offset > _honker_stream_consumers.offset",
1473
+ rusqlite::params![consumer, topic, offset],
1474
+ )?;
1475
+ Ok(if changed > 0 { 1 } else { 0 })
1476
+ }
1477
+
1478
+ pub fn stream_get_offset(conn: &Connection, consumer: &str, topic: &str) -> rusqlite::Result<i64> {
1479
+ Ok(conn
1480
+ .query_row(
1481
+ "SELECT offset FROM _honker_stream_consumers
1482
+ WHERE name = ?1 AND topic = ?2",
1483
+ rusqlite::params![consumer, topic],
1484
+ |r| r.get(0),
1485
+ )
1486
+ .unwrap_or(0))
1487
+ }
1488
+
1489
+ // ---------------------------------------------------------------------
1490
+ // Helpers
1491
+ // ---------------------------------------------------------------------
1492
+
1493
+ fn now_unix(conn: &Connection) -> rusqlite::Result<i64> {
1494
+ conn.query_row("SELECT unixepoch()", [], |r| r.get(0))
1495
+ }
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
+ }