Skip to content

fix(redis): harden delayed-member Lua decode against malformed members#143

Merged
jotarios merged 2 commits into
mainfrom
fix/harden-delayed-member-lua-decode
May 20, 2026
Merged

fix(redis): harden delayed-member Lua decode against malformed members#143
jotarios merged 2 commits into
mainfrom
fix/harden-delayed-member-lua-decode

Conversation

@jotarios

Copy link
Copy Markdown
Owner

What

Hardens the two Lua scripts in chasquimq/src/redis/commands.rs that decode the slice-3 length-prefixed delayed-ZSET member (PROMOTE_SCRIPT and SCHEDULE_REPEATABLE_SCRIPT) so a malformed/short member is skipped and cleansed instead of permanently wedging promotion.

Why

Both scripts decoded the member with string.byte(member, 1, 4) followed by name_len arithmetic and no bounds check. On a member shorter than the 4-byte length prefix, string.byte returns nil for the missing positions; arithmetic on nil aborts the entire EVAL — and in PROMOTE_SCRIPT it aborts before the per-member ZREM. So the poison member is never removed, every subsequent promote tick re-reads it and re-errors, and delayed-job promotion for that queue is wedged forever (also starving valid jobs scored at/after the poison).

The Rust decoder (delayed_member::decode_delayed_member) already guards both len < 4 and len < 4 + name_len, returning None. The Lua side now mirrors that exactly. This is internal robustness/correctness hardening — no public API change, no config change.

Changes

  • PROMOTE_SCRIPT: bounds-check before string.byte. A member shorter than the prefix, or whose declared name_len overruns the buffer, is ZREM'd (cleansed) and skipped instead of aborting. payloads now uses its own dense counter (np) rather than the loop index, so a skipped member can't punch a nil hole into the returned array (a nil in a Lua array truncates the RESP reply, which would silently drop didx cleanup for valid members after the poison).
  • SCHEDULE_REPEATABLE_SCRIPT: same bounds guard on the fire branch. A poison member is dropped (it can't be dispatched) and explicitly does not count toward fired/fired_now — counting it would burn a fire slot against the repeatable limit and could prematurely retire the spec. The loop still advances unconditionally; the rest of the batch fires normally. Well-formed members hit the identical command sequence as before.
  • Bounds equivalence verified against the Rust decoder, including the empty-payload boundary (name_len == len - 4).

Tests

  • New live-Redis regression delayed.rs::poison_member_does_not_wedge_promotion: ZADDs a 2-byte runt and an oversized-name_len member alongside a real delayed job, runs the promoter, asserts the valid job is promoted and consumed, both poison members are cleansed from the ZSET, and the ZSET drains to empty (proving repeated ticks keep working — no permanent wedge).
  • In-crate structural guards for both scripts so a future edit can't silently strip the check back out.
  • Full workspace suite green: cargo test --workspace -- --include-ignored → 291 passed (34 suites) against live Redis 8.6.2. cargo fmt --all -- --check and cargo clippy --all-targets --workspace -- -D warnings clean.

Performance

worker-delayed-end-to-end A/B on the same host (scale=5, repeats=5, drop-slowest=1): main 17,249 jobs/s vs branch ~16.5k–17.4k jobs/s — within run-to-run stddev (300–790). The added per-member bounds checks are O(1) integer comparisons with zero extra Redis round trips or allocations. No measurable regression.

jotarios added 2 commits May 20, 2026 00:18
PROMOTE_SCRIPT and SCHEDULE_REPEATABLE_SCRIPT decoded the slice-3
length-prefixed delayed-ZSET member with string.byte(member, 1, 4)
followed by name_len arithmetic and no bounds check. A member shorter
than the 4-byte prefix made string.byte return nil; arithmetic on nil
aborted the whole EVAL *before* the per-member ZREM, so the poison
member was never removed and every promote tick re-read it and
re-errored — delayed-job promotion for the queue was permanently
wedged, also starving valid jobs scored at/after the poison.

Mirror the existing Rust decoder (delayed_member::decode_delayed_member)
in Lua: reject members shorter than the prefix and reject a declared
name_len that overruns the buffer. In PROMOTE_SCRIPT a malformed member
is ZREM'd (cleansed) and skipped; payloads uses its own dense counter so
a skipped member can't punch a nil hole that truncates the reply for
valid members after it. In SCHEDULE_REPEATABLE_SCRIPT a malformed member
on the fire branch is dropped without counting toward fired/fired_now
(it can't be dispatched, so it must not burn a fire slot against the
repeatable limit); the loop still advances and the rest of the batch
fires normally. Well-formed members hit the identical command sequence
as before — no behavior change, no API/config change.

Tests: new live-Redis regression poison_member_does_not_wedge_promotion
(valid job still promoted, poison cleansed, ZSET drains, no wedge) plus
in-crate structural guards so a future edit can't strip the check back
out. Existing delayed/promoter/repeatable suites unchanged and green.
@jotarios jotarios force-pushed the fix/harden-delayed-member-lua-decode branch from 20d1734 to 28cfb29 Compare May 20, 2026 03:31
@jotarios jotarios merged commit 7558a39 into main May 20, 2026
24 checks passed
@jotarios jotarios deleted the fix/harden-delayed-member-lua-decode branch May 20, 2026 03:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant