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.
- checksums.yaml +4 -4
- data/README.md +193 -2
- data/ext/honker/extconf.rb +69 -0
- data/ext/honker/honker-core/Cargo.toml +49 -0
- data/ext/honker/honker-core/LICENSE +6 -0
- data/ext/honker/honker-core/LICENSE-APACHE +176 -0
- data/ext/honker/honker-core/LICENSE-MIT +21 -0
- data/ext/honker/honker-core/README.md +30 -0
- data/ext/honker/honker-core/src/cron.rs +473 -0
- data/ext/honker/honker-core/src/honker_ops.rs +1518 -0
- data/ext/honker/honker-core/src/kernel_watcher.rs +434 -0
- data/ext/honker/honker-core/src/lib.rs +3116 -0
- data/ext/honker/honker-core/src/shm_watcher.rs +250 -0
- data/ext/honker/honker-extension/Cargo.toml +36 -0
- data/ext/honker/honker-extension/LICENSE +6 -0
- data/ext/honker/honker-extension/LICENSE-APACHE +176 -0
- data/ext/honker/honker-extension/LICENSE-MIT +21 -0
- data/ext/honker/honker-extension/README.md +41 -0
- data/ext/honker/honker-extension/src/lib.rs +330 -0
- data/honker.gemspec +24 -1
- data/lib/honker/README.md +28 -0
- data/lib/honker/railtie.rb +11 -0
- data/lib/honker/scheduler.rb +59 -0
- data/lib/honker/version.rb +1 -1
- data/lib/honker.rb +111 -3
- metadata +39 -8
|
@@ -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
|
+
}
|