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 +4 -4
- data/ext/honker/honker-core/Cargo.toml +9 -3
- data/ext/honker/honker-core/src/cron.rs +3 -3
- data/ext/honker/honker-core/src/honker_ops.rs +258 -46
- data/ext/honker/honker-core/src/kernel_watcher.rs +191 -24
- data/ext/honker/honker-core/src/lib.rs +577 -36
- data/ext/honker/honker-core/src/shm_watcher.rs +237 -22
- data/ext/honker/honker-extension/Cargo.toml +4 -3
- data/ext/honker/honker-extension/src/lib.rs +7 -9
- data/lib/honker/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 26a3d929e2e2266567868784cc70e65fe06a60b8e93956f9d65721f39b4ed5c0
|
|
4
|
+
data.tar.gz: 41d9411865cb1d0774d3fd3f4147606b508980ac66f39f7cda339a71e6ca9981
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c20a4106adbe01b12b380991cf3dea7ffb6fdf175d1f0aa430ef0cf7abd029d755535109c67bf69315cc7060d0e335dee45534fa4ea3858ae27daebabda8a10f
|
|
7
|
+
data.tar.gz: ec076836d653243e5a50204943263f0dfecb8e664704b95f28c3edc14644ba6a7051d8cc73a163c90486f5a9e778e7dc002b0528858369b018dc979a7f78b915
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "honker-core"
|
|
3
|
-
version = "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.
|
|
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 = "
|
|
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(&
|
|
92
|
-
&& self.months.contains(&
|
|
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(&
|
|
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::
|
|
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
|
|
47
|
-
let timeout_s: i64 = ctx
|
|
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
|
|
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
|
|
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
|
|
125
|
-
let per: i64 = ctx
|
|
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
|
|
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
|
|
159
|
-
let expires_s: Option<i64> = ctx
|
|
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
|
|
177
|
-
let expires_s: Option<i64> = ctx
|
|
178
|
-
let max_attempts: i64 = ctx
|
|
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
|
|
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
|
|
289
|
-
let expires_s_arg: Option<i64> = ctx
|
|
290
|
-
let touch_expires: i64 = ctx
|
|
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
|
|
318
|
-
let expires_s_arg: Option<i64> = ctx
|
|
319
|
-
let touch_expires: i64 = ctx
|
|
320
|
-
let max_attempts_arg: Option<i64> = ctx
|
|
321
|
-
let touch_max_attempts: i64 = ctx
|
|
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
|
|
403
|
+
let job_id: i64 = arg_i64(ctx, 0)?;
|
|
348
404
|
let value: String = ctx.get(1)?;
|
|
349
|
-
let ttl_s: i64 = ctx
|
|
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
|
|
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
|
|
380
|
-
let delay: Option<i64> = ctx
|
|
381
|
-
let priority: i64 = ctx
|
|
382
|
-
let max_attempts: i64 = ctx
|
|
383
|
-
let expires: Option<i64> = ctx
|
|
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
|
|
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
|
|
469
|
+
let job_id: i64 = arg_i64(ctx, 0)?;
|
|
414
470
|
let worker_id: String = ctx.get(1)?;
|
|
415
|
-
let delay_s: i64 = ctx
|
|
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
|
|
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
|
|
490
|
+
let job_id: i64 = arg_i64(ctx, 0)?;
|
|
435
491
|
let worker_id: String = ctx.get(1)?;
|
|
436
|
-
let extend_s: i64 = ctx
|
|
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
|
|
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
|
|
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
|
|
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
|
|
498
|
-
let limit: i64 = ctx
|
|
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
|
|
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
|
+
}
|