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