honker 0.4.0 → 0.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8dae25a4f10b6fe9125f3f5f56a0a5e482c17a9c716c58e3ccb785d7bccd2a6b
4
- data.tar.gz: 996f23893693f8c8a5ca3c47ac1e391ca858efc835e8a0c04229721ecafc50fe
3
+ metadata.gz: 26a3d929e2e2266567868784cc70e65fe06a60b8e93956f9d65721f39b4ed5c0
4
+ data.tar.gz: 41d9411865cb1d0774d3fd3f4147606b508980ac66f39f7cda339a71e6ca9981
5
5
  SHA512:
6
- metadata.gz: ced93ac94928611a6060ae308bf4637d6ff97c33ce8716281f64fa680f128329db7fb22f1a35d9d955f373061e7e705bdfeca670f397335a5771002cb3ab324e
7
- data.tar.gz: 5efad42d288ad8dffe9bfed6aed633ad2f96dd00dbc5df9dbf310282e9e103eb8d2c8f2746d87b5fcc537e6fbd737b2ec719e60264d4ddaeb57c117c97f834ef
6
+ metadata.gz: c20a4106adbe01b12b380991cf3dea7ffb6fdf175d1f0aa430ef0cf7abd029d755535109c67bf69315cc7060d0e335dee45534fa4ea3858ae27daebabda8a10f
7
+ data.tar.gz: ec076836d653243e5a50204943263f0dfecb8e664704b95f28c3edc14644ba6a7051d8cc73a163c90486f5a9e778e7dc002b0528858369b018dc979a7f78b915
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "honker-core"
3
- version = "0.4.0"
3
+ version = "0.5.0"
4
4
  edition = "2024"
5
5
  description = "Shared Rust foundation for Honker bindings (SQLite loadable extension, PyO3, napi-rs, and friends). Not intended for direct use."
6
6
  license = "MIT OR Apache-2.0"
@@ -27,14 +27,14 @@ name = "honker_core"
27
27
  [dependencies]
28
28
  chrono = { version = "0.4", default-features = false, features = ["clock"] }
29
29
  parking_lot = "0.12.5"
30
- rusqlite = { version = "0.39.0", features = ["functions", "hooks"] }
30
+ rusqlite = { version = "0.40.1", features = ["functions", "hooks"] }
31
31
  serde_json = "1"
32
32
  thiserror = "2.0.18"
33
33
  # Optional: kernel-watch backend.
34
34
  # macos_kqueue: on macOS, use kqueue (RecommendedWatcher = KqueueWatcher) instead of
35
35
  # FSEvents. FSEvents has a 1 s batching latency — worse than 1 ms polling. kqueue
36
36
  # delivers per-event notifications immediately, matching the intent of this backend.
37
- notify = { version = "6", optional = true, features = ["macos_kqueue"] }
37
+ notify = { version = "8", optional = true, features = ["macos_kqueue"] }
38
38
  # Optional: shm fast path.
39
39
  memmap2 = { version = "0.9", optional = true }
40
40
  libc = { version = "0.2", optional = true }
@@ -45,6 +45,12 @@ libc = { version = "0.2", optional = true }
45
45
  file-id = "0.2.3"
46
46
 
47
47
  [dev-dependencies]
48
+ # libc in tests regardless of feature selection: the issue-#80 lock-survival
49
+ # tests probe SQLite's POSIX locks with fcntl(F_GETLK) from a child process,
50
+ # and must run for the shm-fast-path build too (where `libc` is not a
51
+ # regular dependency).
52
+ libc = "0.2"
48
53
  # chrono-tz only in tests — lets us exercise DST edge cases with a
49
54
  # fixed timezone (US/Eastern) regardless of where tests run.
50
55
  chrono-tz = "0.10"
56
+
@@ -88,8 +88,8 @@ impl CronSchedule {
88
88
  self.seconds.contains(&dt.second())
89
89
  && self.minutes.contains(&dt.minute())
90
90
  && self.hours.contains(&dt.hour())
91
- && self.days.contains(&(dt.day() as u32))
92
- && self.months.contains(&(dt.month() as u32))
91
+ && self.days.contains(&dt.day())
92
+ && self.months.contains(&dt.month())
93
93
  && self.dows.contains(&cron_dow)
94
94
  }
95
95
  }
@@ -187,7 +187,7 @@ fn cron_day_matches(sched: &CronSchedule, dt: &NaiveDateTime) -> bool {
187
187
  Weekday::Fri => 5,
188
188
  Weekday::Sat => 6,
189
189
  };
190
- sched.days.contains(&(dt.day() as u32)) && sched.dows.contains(&cron_dow)
190
+ sched.days.contains(&dt.day()) && sched.dows.contains(&cron_dow)
191
191
  }
192
192
 
193
193
  fn make_dt(year: i32, month: u32, day: u32, hour: u32, minute: u32, second: u32) -> NaiveDateTime {
@@ -19,15 +19,71 @@
19
19
  //! tested once and inherited by every consumer.
20
20
 
21
21
  use rusqlite::Connection;
22
- use rusqlite::functions::FunctionFlags;
22
+ use rusqlite::functions::{Context, FunctionFlags};
23
+ use rusqlite::types::ValueRef;
23
24
  use serde_json::{Value, json};
24
25
 
26
+ /// Read an integer argument, accepting the REAL that dynamically typed
27
+ /// clients send for whole numbers.
28
+ ///
29
+ /// `better-sqlite3` binds every JavaScript number as SQLite REAL, whole
30
+ /// ones included. So `honker_enqueue(..., priority, max_attempts, ...)`
31
+ /// called from Drizzle or Kysely arrives as REAL and used to fail with
32
+ /// "Invalid function parameter type Real". SQLite is dynamically typed
33
+ /// and these are integer arguments; refusing `3.0` because it arrived
34
+ /// as a double was our bug, not the caller's.
35
+ ///
36
+ /// A REAL with a fractional part is still an error. Truncating `1.5` to
37
+ /// `1` would hide a real mistake, and a job id is not a rounding
38
+ /// candidate.
39
+ pub fn arg_i64(ctx: &Context<'_>, idx: usize) -> rusqlite::Result<i64> {
40
+ match ctx.get_raw(idx) {
41
+ ValueRef::Real(f) => real_to_i64(f, idx),
42
+ // Integer, and everything else, keep rusqlite's own behavior.
43
+ _ => ctx.get::<i64>(idx),
44
+ }
45
+ }
46
+
47
+ /// Nullable form of [`arg_i64`]. NULL stays None.
48
+ pub fn arg_opt_i64(ctx: &Context<'_>, idx: usize) -> rusqlite::Result<Option<i64>> {
49
+ match ctx.get_raw(idx) {
50
+ ValueRef::Real(f) => real_to_i64(f, idx).map(Some),
51
+ _ => ctx.get::<Option<i64>>(idx),
52
+ }
53
+ }
54
+
55
+ fn real_to_i64(f: f64, idx: usize) -> rusqlite::Result<i64> {
56
+ // 2^63 exactly; i64::MAX as f64 rounds *up* to it, so compare
57
+ // against the power of two and exclude the top end.
58
+ const LIMIT: f64 = 9_223_372_036_854_775_808.0;
59
+ if f.fract() == 0.0 && (-LIMIT..LIMIT).contains(&f) {
60
+ return Ok(f as i64);
61
+ }
62
+
63
+ // "Invalid function parameter type Real" is useless here: the type
64
+ // is fine, the value is not. Say which value and why, because the
65
+ // caller is often an ORM and the number came from somewhere else.
66
+ let why = if f.is_nan() {
67
+ "not a number".to_string()
68
+ } else if f.is_infinite() {
69
+ "infinite".to_string()
70
+ } else if f.fract() != 0.0 {
71
+ format!("{f} has a fractional part")
72
+ } else {
73
+ format!("{f:e} is outside the range of a 64-bit integer")
74
+ };
75
+ Err(rusqlite::Error::UserFunctionError(Box::new(
76
+ std::io::Error::other(format!(
77
+ "honker: argument {idx} must be a whole number, but {why}. \
78
+ Whole values bind fine even when the client sends them as \
79
+ REAL, which better-sqlite3 does for every JavaScript number."
80
+ )),
81
+ )))
82
+ }
83
+
25
84
  /// Wrap a Displayable error for SQLite scalar-function returns.
26
85
  fn to_sql_err<E: std::fmt::Display>(e: E) -> rusqlite::Error {
27
- rusqlite::Error::UserFunctionError(Box::new(std::io::Error::new(
28
- std::io::ErrorKind::Other,
29
- e.to_string(),
30
- )))
86
+ rusqlite::Error::UserFunctionError(Box::new(std::io::Error::other(e.to_string())))
31
87
  }
32
88
 
33
89
  /// Register all `honker_*` honker scalar functions on `conn`. Idempotent
@@ -43,8 +99,8 @@ pub fn attach_honker_functions(conn: &Connection) -> rusqlite::Result<()> {
43
99
  conn.create_scalar_function("honker_claim_batch", 4, FunctionFlags::SQLITE_UTF8, |ctx| {
44
100
  let queue: String = ctx.get(0)?;
45
101
  let worker_id: String = ctx.get(1)?;
46
- let n: i64 = ctx.get(2)?;
47
- let timeout_s: i64 = ctx.get(3)?;
102
+ let n: i64 = arg_i64(ctx, 2)?;
103
+ let timeout_s: i64 = arg_i64(ctx, 3)?;
48
104
  let db = unsafe { ctx.get_connection() }?;
49
105
  claim_batch(&db, &queue, &worker_id, n, timeout_s).map_err(to_sql_err)
50
106
  })?;
@@ -85,7 +141,7 @@ pub fn attach_honker_functions(conn: &Connection) -> rusqlite::Result<()> {
85
141
  |ctx| {
86
142
  let name: String = ctx.get(0)?;
87
143
  let owner: String = ctx.get(1)?;
88
- let ttl: i64 = ctx.get(2)?;
144
+ let ttl: i64 = arg_i64(ctx, 2)?;
89
145
  let db = unsafe { ctx.get_connection() }?;
90
146
  lock_acquire(&db, &name, &owner, ttl).map_err(to_sql_err)
91
147
  },
@@ -110,7 +166,7 @@ pub fn attach_honker_functions(conn: &Connection) -> rusqlite::Result<()> {
110
166
  conn.create_scalar_function("honker_lock_renew", 3, FunctionFlags::SQLITE_UTF8, |ctx| {
111
167
  let name: String = ctx.get(0)?;
112
168
  let owner: String = ctx.get(1)?;
113
- let ttl: i64 = ctx.get(2)?;
169
+ let ttl: i64 = arg_i64(ctx, 2)?;
114
170
  let db = unsafe { ctx.get_connection() }?;
115
171
  lock_renew(&db, &name, &owner, ttl).map_err(to_sql_err)
116
172
  })?;
@@ -121,8 +177,8 @@ pub fn attach_honker_functions(conn: &Connection) -> rusqlite::Result<()> {
121
177
  FunctionFlags::SQLITE_UTF8,
122
178
  |ctx| {
123
179
  let name: String = ctx.get(0)?;
124
- let limit: i64 = ctx.get(1)?;
125
- let per: i64 = ctx.get(2)?;
180
+ let limit: i64 = arg_i64(ctx, 1)?;
181
+ let per: i64 = arg_i64(ctx, 2)?;
126
182
  let db = unsafe { ctx.get_connection() }?;
127
183
  rate_limit_try(&db, &name, limit, per).map_err(to_sql_err)
128
184
  },
@@ -133,7 +189,7 @@ pub fn attach_honker_functions(conn: &Connection) -> rusqlite::Result<()> {
133
189
  1,
134
190
  FunctionFlags::SQLITE_UTF8,
135
191
  |ctx| {
136
- let older_than_s: i64 = ctx.get(0)?;
192
+ let older_than_s: i64 = arg_i64(ctx, 0)?;
137
193
  let db = unsafe { ctx.get_connection() }?;
138
194
  rate_limit_sweep(&db, older_than_s).map_err(to_sql_err)
139
195
  },
@@ -155,8 +211,8 @@ pub fn attach_honker_functions(conn: &Connection) -> rusqlite::Result<()> {
155
211
  let queue: String = ctx.get(1)?;
156
212
  let cron_expr: String = ctx.get(2)?;
157
213
  let payload: String = ctx.get(3)?;
158
- let priority: i64 = ctx.get(4)?;
159
- let expires_s: Option<i64> = ctx.get(5)?;
214
+ let priority: i64 = arg_i64(ctx, 4)?;
215
+ let expires_s: Option<i64> = arg_opt_i64(ctx, 5)?;
160
216
  let db = unsafe { ctx.get_connection() }?;
161
217
  scheduler_register(
162
218
  &db, &name, &queue, &cron_expr, &payload, priority, expires_s, 3,
@@ -173,9 +229,9 @@ pub fn attach_honker_functions(conn: &Connection) -> rusqlite::Result<()> {
173
229
  let queue: String = ctx.get(1)?;
174
230
  let cron_expr: String = ctx.get(2)?;
175
231
  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)?;
232
+ let priority: i64 = arg_i64(ctx, 4)?;
233
+ let expires_s: Option<i64> = arg_opt_i64(ctx, 5)?;
234
+ let max_attempts: i64 = arg_i64(ctx, 6)?;
179
235
  let db = unsafe { ctx.get_connection() }?;
180
236
  scheduler_register(
181
237
  &db,
@@ -215,7 +271,7 @@ pub fn attach_honker_functions(conn: &Connection) -> rusqlite::Result<()> {
215
271
  1,
216
272
  FunctionFlags::SQLITE_UTF8,
217
273
  |ctx| {
218
- let now_unix: i64 = ctx.get(0)?;
274
+ let now_unix: i64 = arg_i64(ctx, 0)?;
219
275
  let db = unsafe { ctx.get_connection() }?;
220
276
  scheduler_tick(&db, now_unix).map_err(to_sql_err)
221
277
  },
@@ -285,9 +341,9 @@ pub fn attach_honker_functions(conn: &Connection) -> rusqlite::Result<()> {
285
341
  let name: String = ctx.get(0)?;
286
342
  let cron_expr: Option<String> = ctx.get(1)?;
287
343
  let payload: Option<String> = ctx.get(2)?;
288
- let priority: Option<i64> = ctx.get(3)?;
289
- let expires_s_arg: Option<i64> = ctx.get(4)?;
290
- let touch_expires: i64 = ctx.get(5)?;
344
+ let priority: Option<i64> = arg_opt_i64(ctx, 3)?;
345
+ let expires_s_arg: Option<i64> = arg_opt_i64(ctx, 4)?;
346
+ let touch_expires: i64 = arg_i64(ctx, 5)?;
291
347
  let db = unsafe { ctx.get_connection() }?;
292
348
  let expires_s = if touch_expires != 0 {
293
349
  Some(expires_s_arg)
@@ -314,11 +370,11 @@ pub fn attach_honker_functions(conn: &Connection) -> rusqlite::Result<()> {
314
370
  let name: String = ctx.get(0)?;
315
371
  let cron_expr: Option<String> = ctx.get(1)?;
316
372
  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)?;
373
+ let priority: Option<i64> = arg_opt_i64(ctx, 3)?;
374
+ let expires_s_arg: Option<i64> = arg_opt_i64(ctx, 4)?;
375
+ let touch_expires: i64 = arg_i64(ctx, 5)?;
376
+ let max_attempts_arg: Option<i64> = arg_opt_i64(ctx, 6)?;
377
+ let touch_max_attempts: i64 = arg_i64(ctx, 7)?;
322
378
  let db = unsafe { ctx.get_connection() }?;
323
379
  let expires_s = if touch_expires != 0 {
324
380
  Some(expires_s_arg)
@@ -344,15 +400,15 @@ pub fn attach_honker_functions(conn: &Connection) -> rusqlite::Result<()> {
344
400
  )?;
345
401
 
346
402
  conn.create_scalar_function("honker_result_save", 3, FunctionFlags::SQLITE_UTF8, |ctx| {
347
- let job_id: i64 = ctx.get(0)?;
403
+ let job_id: i64 = arg_i64(ctx, 0)?;
348
404
  let value: String = ctx.get(1)?;
349
- let ttl_s: i64 = ctx.get(2)?;
405
+ let ttl_s: i64 = arg_i64(ctx, 2)?;
350
406
  let db = unsafe { ctx.get_connection() }?;
351
407
  result_save(&db, job_id, &value, ttl_s).map_err(to_sql_err)
352
408
  })?;
353
409
 
354
410
  conn.create_scalar_function("honker_result_get", 1, FunctionFlags::SQLITE_UTF8, |ctx| {
355
- let job_id: i64 = ctx.get(0)?;
411
+ let job_id: i64 = arg_i64(ctx, 0)?;
356
412
  let db = unsafe { ctx.get_connection() }?;
357
413
  result_get(&db, job_id).map_err(to_sql_err)
358
414
  })?;
@@ -376,11 +432,11 @@ pub fn attach_honker_functions(conn: &Connection) -> rusqlite::Result<()> {
376
432
  conn.create_scalar_function("honker_enqueue", 7, FunctionFlags::SQLITE_UTF8, |ctx| {
377
433
  let queue: String = ctx.get(0)?;
378
434
  let payload: String = ctx.get(1)?;
379
- let run_at: Option<i64> = ctx.get(2)?;
380
- let delay: Option<i64> = ctx.get(3)?;
381
- let priority: i64 = ctx.get(4)?;
382
- let max_attempts: i64 = ctx.get(5)?;
383
- let expires: Option<i64> = ctx.get(6)?;
435
+ let run_at: Option<i64> = arg_opt_i64(ctx, 2)?;
436
+ let delay: Option<i64> = arg_opt_i64(ctx, 3)?;
437
+ let priority: i64 = arg_i64(ctx, 4)?;
438
+ let max_attempts: i64 = arg_i64(ctx, 5)?;
439
+ let expires: Option<i64> = arg_opt_i64(ctx, 6)?;
384
440
  let db = unsafe { ctx.get_connection() }?;
385
441
  enqueue(
386
442
  &db,
@@ -398,7 +454,7 @@ pub fn attach_honker_functions(conn: &Connection) -> rusqlite::Result<()> {
398
454
  // honker_ack(job_id, worker_id) -> 1 if ack'd, 0 if claim expired /
399
455
  // not ours.
400
456
  conn.create_scalar_function("honker_ack", 2, FunctionFlags::SQLITE_UTF8, |ctx| {
401
- let job_id: i64 = ctx.get(0)?;
457
+ let job_id: i64 = arg_i64(ctx, 0)?;
402
458
  let worker_id: String = ctx.get(1)?;
403
459
  let db = unsafe { ctx.get_connection() }?;
404
460
  ack(&db, job_id, &worker_id).map_err(to_sql_err)
@@ -410,9 +466,9 @@ pub fn attach_honker_functions(conn: &Connection) -> rusqlite::Result<()> {
410
466
  // to pending. Fires a notify on the queue's channel on successful
411
467
  // pending-flip (so waiting workers wake).
412
468
  conn.create_scalar_function("honker_retry", 4, FunctionFlags::SQLITE_UTF8, |ctx| {
413
- let job_id: i64 = ctx.get(0)?;
469
+ let job_id: i64 = arg_i64(ctx, 0)?;
414
470
  let worker_id: String = ctx.get(1)?;
415
- let delay_s: i64 = ctx.get(2)?;
471
+ let delay_s: i64 = arg_i64(ctx, 2)?;
416
472
  let error: String = ctx.get(3)?;
417
473
  let db = unsafe { ctx.get_connection() }?;
418
474
  retry(&db, job_id, &worker_id, delay_s, &error).map_err(to_sql_err)
@@ -421,7 +477,7 @@ pub fn attach_honker_functions(conn: &Connection) -> rusqlite::Result<()> {
421
477
  // honker_fail(job_id, worker_id, error) -> 1 if failed-to-dead, 0 if
422
478
  // not our claim.
423
479
  conn.create_scalar_function("honker_fail", 3, FunctionFlags::SQLITE_UTF8, |ctx| {
424
- let job_id: i64 = ctx.get(0)?;
480
+ let job_id: i64 = arg_i64(ctx, 0)?;
425
481
  let worker_id: String = ctx.get(1)?;
426
482
  let error: String = ctx.get(2)?;
427
483
  let db = unsafe { ctx.get_connection() }?;
@@ -431,9 +487,9 @@ pub fn attach_honker_functions(conn: &Connection) -> rusqlite::Result<()> {
431
487
  // honker_heartbeat(job_id, worker_id, extend_s) -> 1 if extended, 0
432
488
  // if not our claim.
433
489
  conn.create_scalar_function("honker_heartbeat", 3, FunctionFlags::SQLITE_UTF8, |ctx| {
434
- let job_id: i64 = ctx.get(0)?;
490
+ let job_id: i64 = arg_i64(ctx, 0)?;
435
491
  let worker_id: String = ctx.get(1)?;
436
- let extend_s: i64 = ctx.get(2)?;
492
+ let extend_s: i64 = arg_i64(ctx, 2)?;
437
493
  let db = unsafe { ctx.get_connection() }?;
438
494
  heartbeat(&db, job_id, &worker_id, extend_s).map_err(to_sql_err)
439
495
  })?;
@@ -441,14 +497,14 @@ pub fn attach_honker_functions(conn: &Connection) -> rusqlite::Result<()> {
441
497
  // honker_cancel(job_id) -> 1 if a pending/processing row was removed,
442
498
  // 0 otherwise. Idempotent on missing.
443
499
  conn.create_scalar_function("honker_cancel", 1, FunctionFlags::SQLITE_UTF8, |ctx| {
444
- let job_id: i64 = ctx.get(0)?;
500
+ let job_id: i64 = arg_i64(ctx, 0)?;
445
501
  let db = unsafe { ctx.get_connection() }?;
446
502
  cancel(&db, job_id).map_err(to_sql_err)
447
503
  })?;
448
504
 
449
505
  // honker_get_job(job_id) -> JSON object on hit, empty string on miss.
450
506
  conn.create_scalar_function("honker_get_job", 1, FunctionFlags::SQLITE_UTF8, |ctx| {
451
- let job_id: i64 = ctx.get(0)?;
507
+ let job_id: i64 = arg_i64(ctx, 0)?;
452
508
  let db = unsafe { ctx.get_connection() }?;
453
509
  get_job(&db, job_id).map_err(to_sql_err)
454
510
  })?;
@@ -463,7 +519,7 @@ pub fn attach_honker_functions(conn: &Connection) -> rusqlite::Result<()> {
463
519
  FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC,
464
520
  |ctx| {
465
521
  let expr: String = ctx.get(0)?;
466
- let from_unix: i64 = ctx.get(1)?;
522
+ let from_unix: i64 = arg_i64(ctx, 1)?;
467
523
  super::cron::next_after_unix(&expr, from_unix).map_err(to_sql_err)
468
524
  },
469
525
  )?;
@@ -494,8 +550,8 @@ pub fn attach_honker_functions(conn: &Connection) -> rusqlite::Result<()> {
494
550
  FunctionFlags::SQLITE_UTF8,
495
551
  |ctx| {
496
552
  let topic: String = ctx.get(0)?;
497
- let offset: i64 = ctx.get(1)?;
498
- let limit: i64 = ctx.get(2)?;
553
+ let offset: i64 = arg_i64(ctx, 1)?;
554
+ let limit: i64 = arg_i64(ctx, 2)?;
499
555
  let db = unsafe { ctx.get_connection() }?;
500
556
  stream_read_since(&db, &topic, offset, limit).map_err(to_sql_err)
501
557
  },
@@ -512,7 +568,7 @@ pub fn attach_honker_functions(conn: &Connection) -> rusqlite::Result<()> {
512
568
  |ctx| {
513
569
  let consumer: String = ctx.get(0)?;
514
570
  let topic: String = ctx.get(1)?;
515
- let offset: i64 = ctx.get(2)?;
571
+ let offset: i64 = arg_i64(ctx, 2)?;
516
572
  let db = unsafe { ctx.get_connection() }?;
517
573
  stream_save_offset(&db, &consumer, &topic, offset).map_err(to_sql_err)
518
574
  },
@@ -1677,3 +1733,159 @@ pub fn stream_get_offset(conn: &Connection, consumer: &str, topic: &str) -> rusq
1677
1733
  fn now_unix(conn: &Connection) -> rusqlite::Result<i64> {
1678
1734
  conn.query_row("SELECT unixepoch()", [], |r| r.get(0))
1679
1735
  }
1736
+
1737
+ #[cfg(test)]
1738
+ mod real_arg_tests {
1739
+ use crate::{attach_honker_functions, bootstrap_honker_schema};
1740
+ use rusqlite::Connection;
1741
+
1742
+ fn db() -> Connection {
1743
+ let conn = Connection::open_in_memory().unwrap();
1744
+ attach_honker_functions(&conn).unwrap();
1745
+ bootstrap_honker_schema(&conn).unwrap();
1746
+ conn
1747
+ }
1748
+
1749
+ // better-sqlite3 binds every JavaScript number as REAL, so this is
1750
+ // the exact shape a Drizzle or Kysely caller sends. Before the
1751
+ // arg_i64 coercion this failed with "Invalid function parameter
1752
+ // type Real at index 4".
1753
+ #[test]
1754
+ fn enqueue_accepts_real_priority_and_max_attempts() {
1755
+ let conn = db();
1756
+ let id: i64 = conn
1757
+ .query_row(
1758
+ "SELECT honker_enqueue('emails', '{}', NULL, NULL, ?1, ?2, NULL)",
1759
+ (0.0_f64, 3.0_f64),
1760
+ |r| r.get(0),
1761
+ )
1762
+ .unwrap();
1763
+ assert!(id > 0);
1764
+ }
1765
+
1766
+ #[test]
1767
+ fn ack_accepts_a_real_job_id() {
1768
+ let conn = db();
1769
+ let id: i64 = conn
1770
+ .query_row(
1771
+ "SELECT honker_enqueue('emails', '{}', NULL, NULL, 0, 3, NULL)",
1772
+ [],
1773
+ |r| r.get(0),
1774
+ )
1775
+ .unwrap();
1776
+ let _: String = conn
1777
+ .query_row(
1778
+ "SELECT honker_claim_batch('emails', 'w1', 8, 300)",
1779
+ [],
1780
+ |r| r.get(0),
1781
+ )
1782
+ .unwrap();
1783
+
1784
+ let acked: i64 = conn
1785
+ .query_row("SELECT honker_ack(?1, 'w1')", [id as f64], |r| r.get(0))
1786
+ .unwrap();
1787
+ assert_eq!(acked, 1, "a REAL job id must ack the same row");
1788
+ }
1789
+
1790
+ // Coercion is not rounding. A fractional argument is a caller
1791
+ // mistake and has to stay an error.
1792
+ #[test]
1793
+ fn fractional_reals_are_still_rejected() {
1794
+ let conn = db();
1795
+ let err = conn
1796
+ .query_row(
1797
+ "SELECT honker_enqueue('emails', '{}', NULL, NULL, ?1, 3, NULL)",
1798
+ [1.5_f64],
1799
+ |r| r.get::<_, i64>(0),
1800
+ )
1801
+ .unwrap_err();
1802
+ let msg = err.to_string();
1803
+ assert!(
1804
+ msg.contains("must be a whole number") && msg.contains("fractional part"),
1805
+ "expected a message naming the value and why, got: {err}"
1806
+ );
1807
+ }
1808
+
1809
+ #[test]
1810
+ fn null_optional_args_stay_none() {
1811
+ let conn = db();
1812
+ // expires is the argument where None and Some(0) actually
1813
+ // differ in the stored row: `expires.map(|e| now + e)` writes
1814
+ // NULL for None and `now` for Some(0). run_at and delay both
1815
+ // collapse to `now` either way, so neither can discriminate.
1816
+ let id: i64 = conn
1817
+ .query_row(
1818
+ "SELECT honker_enqueue('emails', '{}', NULL, NULL, 0, 3, NULL)",
1819
+ [],
1820
+ |r| r.get(0),
1821
+ )
1822
+ .unwrap();
1823
+ let expires_at: Option<i64> = conn
1824
+ .query_row(
1825
+ "SELECT expires_at FROM _honker_live WHERE id = ?1",
1826
+ [id],
1827
+ |r| r.get(0),
1828
+ )
1829
+ .unwrap();
1830
+ assert_eq!(
1831
+ expires_at, None,
1832
+ "a NULL expires argument must stay None, not become Some(0)"
1833
+ );
1834
+ }
1835
+
1836
+ #[test]
1837
+ fn whole_real_delay_coerces() {
1838
+ let conn = db();
1839
+ let now: i64 = conn
1840
+ .query_row("SELECT unixepoch()", [], |r| r.get(0))
1841
+ .unwrap();
1842
+ let id: i64 = conn
1843
+ .query_row(
1844
+ "SELECT honker_enqueue('emails', '{}', NULL, ?1, 0, 3, NULL)",
1845
+ [30.0_f64],
1846
+ |r| r.get(0),
1847
+ )
1848
+ .unwrap();
1849
+ let run_at: i64 = conn
1850
+ .query_row("SELECT run_at FROM _honker_live WHERE id = ?1", [id], |r| {
1851
+ r.get(0)
1852
+ })
1853
+ .unwrap();
1854
+ assert!(
1855
+ (run_at - (now + 30)).abs() <= 1,
1856
+ "a REAL delay of 30.0 must schedule now + 30, got {run_at} (now = {now})"
1857
+ );
1858
+ }
1859
+
1860
+ // The bounds in real_to_i64 are the part most likely to be wrong,
1861
+ // so pin every edge rather than trusting the comparison reads right.
1862
+ #[test]
1863
+ fn real_bounds_are_exact() {
1864
+ let conn = db();
1865
+ let probe = |v: f64| -> Result<i64, rusqlite::Error> {
1866
+ conn.query_row("SELECT honker_ack(?1, 'w1')", [v], |r| r.get::<_, i64>(0))
1867
+ };
1868
+
1869
+ // 2^63 is not representable as i64; i64::MAX as f64 rounds up to
1870
+ // it, which is why the check is `f < LIMIT` and not `<=`.
1871
+ assert!(
1872
+ probe(9_223_372_036_854_775_808.0).is_err(),
1873
+ "2^63 must reject"
1874
+ );
1875
+ // Largest f64 strictly below 2^63.
1876
+ assert!(
1877
+ probe(9_223_372_036_854_774_784.0).is_ok(),
1878
+ "2^63-1024 must pass"
1879
+ );
1880
+ // -2^63 is exactly i64::MIN and must pass.
1881
+ assert!(
1882
+ probe(-9_223_372_036_854_775_808.0).is_ok(),
1883
+ "-2^63 must pass"
1884
+ );
1885
+ assert!(probe(f64::INFINITY).is_err(), "infinity must reject");
1886
+ assert!(probe(f64::NEG_INFINITY).is_err(), "-infinity must reject");
1887
+ assert!(probe(f64::NAN).is_err(), "NaN must reject");
1888
+ // Negative zero is whole and must coerce to 0, not error.
1889
+ assert!(probe(-0.0).is_ok(), "-0.0 must pass");
1890
+ }
1891
+ }