fix(botevent): monotonic bot event id allocator behind an activation gate (#697) - #702
fix(botevent): monotonic bot event id allocator behind an activation gate (#697)#702an9xyz wants to merge 24 commits into
Conversation
…OSS#697) Spec the replacement of octo-lib GenSeq as the score source for robotEvent:{robotID}, so POST /v1/bot/events' exclusive-cursor pagination becomes lossless. Records the production measurements behind Mininglamp-OSS#697 (19 ordering inversions on 1000-block boundaries with a 6.7 day max regression; 2624 colliding scores across three queues, still accumulating) and the three distinct loss paths: a page boundary landing on a shared score, a late enqueue from a lower block, and ack collateral damage via ZRemRangeByScore(id, id). Decisions D1-D5 are settled with their evidence, notably that a Redis INCR counter is safe here because it shares one RDB domain with the queue it guards -- a rollback takes the counter and the members it could collide with together. That argument is why the counter must stay in the same instance and db as the queue, which is recorded as a hard constraint. One open question remains and needs a human: whether to renumber the existing collided members, the only option that writes production data.
…OSS#697) Reproduces all three Mininglamp-OSS#697 loss paths against a local MySQL and Redis using the real config.Context.GenSeq and the real read/ack shapes from modules/bot_api/events.go, so the defect is demonstrable without production access. Two child processes are required rather than two allocator values in one process: seqMap and seqLock are package-level in octo-lib, so a single process cannot hold two independent block states, and a test written that way would pass while proving nothing. Each child cold-starts and waits on a shared wall-clock barrier so both read seq.min_seq before either writes it back -- the condition a rolling restart of N replicas produces. The collision it demonstrates is total rather than partial: both replicas issue the same ids from min_seq+1, which is the shape observed in production where every duplicated score holds exactly two members and every duplicate range starts at a block boundary.
…glamp-OSS#697) robotEvent:{robotID} scores came from octo-lib GenSeq, a per-process HiLo block allocator whose blocks are cached in a package-level map behind a process-local mutex, with min_seq written back from process-local state under an unconditional ON DUPLICATE KEY UPDATE. With three replicas that yields both colliding and time-inverted scores -- and the queue is read with an exclusive cursor (ZRANGEBYSCORE key (cursor +inf) and acked by score, neither of which tolerates either. Measured in production: 19 ordering inversions on block boundaries with a 6.7 day maximum regression, and 2624 colliding scores across three queues, still accumulating. tools/genseq-repro reproduces all three resulting loss paths -- a page boundary landing on a shared score, a late enqueue from a lower block, and ack destroying an undelivered event via ZRemRangeByScore(id, id). Replaces it with a per-bot Redis INCR counter. The counter deliberately shares the queue's Redis instance and db: production runs appendonly no, so a crash rewinds the counter -- but it rewinds the queue with it, so resuming cannot collide with anything that survived. That co-recovery property is the whole durability argument and is recorded as a hard constraint, because "moving the counter somewhere more durable" would silently break it. maxmemory 0 with noeviction rules out the counter being evicted. First use per bot seeds the counter above max(queue ceiling, legacy seq row) so no new event is born below a client's existing cursor. Both sources are needed: the queue covers ids already enqueued, and the seq row covers a bot whose queue has drained but whose clients still hold a high cursor -- seeding from the queue alone would inflict this very outage at deploy time. The seed is idempotent, so any replica may seed at any time and the fix ships single-stage. Allocation failure fails the enqueue, as a GenSeq error did. There is deliberately no fallback: two live id sources on one queue is the defect. Also isolates addInlineQuery, a sixth caller the new source guard found. It allocated from the same sequence while appending to a process-local map that nothing reads, so it consumed ids from and advanced min_seq of the queue's sequence without producing any deliverable event. It keeps GenSeq on its own key -- an id nobody consumes needs no monotonicity. Two source guards, both proven able to fail: every queue ZADD must go through the allocator, and no bot event id may come from GenSeq. The docstring that claimed a single chokepoint was wrong once before, which is why these are tests rather than comments. Tests run against a real MySQL and Redis in a dedicated database rather than the shared test one, because seq is owned by a migration and creating it bare would break the next package's NewTestServer. Related to Mininglamp-OSS#698 and Mininglamp-OSS#700.
…ininglamp-OSS#697) Three findings worth keeping rather than quietly folding in: the guard found a sixth GenSeq caller the brief and an existing code comment both missed, the queue key had to be consolidated (which tripped the sibling guard's anti-blindness floor, as designed), and the tests needed a dedicated database because seq is migration-owned.
Jerry-Xin
left a comment
There was a problem hiding this comment.
The PR is in-scope for octo-server, but the Redis-backed activation/counter durability model can still generate event IDs below bot-held cursors after activation.
🔴 Blocking
- 🔴 Critical:
pkg/botevent/seq.go:34claims Redis RDB rollback is safe because the counter and queue recover together, but bot cursors do not recover with Redis. After activation, a bot can receive or store a highevent_id; if Redis later rolls back to an older RDB snapshot,botEventSeq:{robotID}can resume below that client-held cursor. The seeded fast path skips reseeding atpkg/botevent/seq.go:293and goes straight toINCR, so it can issue below-cursor IDs. IfbotEventSeq:modealso rolls back or is deleted,pkg/botevent/seq.go:309andpkg/botevent/seq.go:330explicitly fall back tolegacyEventID, which is the same below-cursor loss mode in reverse. The operator tool only does a RedisSETattools/botevent-seq/main.go:131, so activation state has the same rollback problem. This needs a durable activation/high-water design, or a fail-closed expected-mode guard plus a floor that cannot roll back below IDs already exposed to clients. Same-Redis co-recovery is insufficient for a cursor-bearing wire contract.
💬 Non-blocking
- 🟡 Warning: The PR includes a committed root-level binary,
botevent-seq, which is a 41 MB Linux aarch64 executable with debug info. The source tool exists undertools/botevent-seq/main.go; the built artifact should not be committed to the repo.
✅ Highlights
- The producer migration is broadly consistent: direct queue writers now route through
botevent.NextEventID, andaddInlineQuerycorrectly shares the same ID source because it is merged into the same cursor stream. - The guard tests for GenSeq reintroduction and queue writers are useful and directly target the historical failure mode.
- I ran
go test ./pkg/botevent -run 'Test(NotActivated|Activation|Seed|NextEventID|ExclusiveCursor|NoGenSeq|EveryBot)' -count=1; it passed.
lml2468
left a comment
There was a problem hiding this comment.
PR Review — PR #702 (Mininglamp-OSS/octo-server)
Reviewed at head d34bdfb055e9, merge-base 40627cc0. Mode: RELIABILITY / DATA-INTEGRITY. Intent: replace the per-process HiLo GenSeq allocator for robotEvent:{robotID} scores — non-monotonic/colliding across 3 replicas, causing permanent event loss under the exclusive cursor + ack-by-score contract — with a per-bot Redis INCR counter behind an activation gate, so merging is behaviour-neutral until an operator flips the mode. Spec: bot-event-score-monotonic/brief.md.
Build/vet/test: go build/go vet (botevent + robot + group + tool) exit 0 ✅. pkg/botevent suite (guards + fake-Scripter unit) PASS; the Redis-backed seq_test.go (real-Redis monotonicity/seed/cursor) is env-gated → CI.
Gate 1 — Spec compliance ✅
Ships exactly the brief's deliverable and honours every load-bearing item: (1) all bot-event producers moved onto the shared NextEventID; (2) the score==event_id equality invariant preserved at each producer; (3) migration one-directionality handled by seeding above max(queue ceiling, legacy seq row); (4) activation-gated, behaviour-neutral merge; (5) does not close #697 (activation + verification is a separate step, correctly stated). No over-build.
Gate 2 — Code quality ✅ (the hard parts are right and tested)
- All producers switched + a source guard that enforces it.
NextEventIDis the single allocator acrossmodules/robot(enqueueBotEventGeneric, enqueueBotTypedEventGeneric, saveRobotMessage, robot/event.go) andmodules/group(bothnotifyBotJoinedGroup).TestNoGenSeqForBotEventIDswalks the whole repo and fails on anyGenSeq(…RobotEventSeqKey|"robotEventSeq:)outside the one allowlisted site — and also assertsseq.gostill contains the delegation, so the legacy branch can't be deleted as "dead code" and silently break pre-activation safety. A repo-wide grep confirmslegacyEventIDis the only GenSeq call for event ids (all otherGenSeqcalls are unrelated seq keys). This closes the "partial switch = two live id sources" P0. - Equality invariant preserved. At each producer the same
seqis used as both the payloadEventIDand theZAddscore (float64(seq)), soZRemRangeByScore(id,id)ack still targets the right member. Verified at robot/api.go:262 & :323; the rest share the same helper. - Atomic activation, no mixed-source window.
gateSourcereadsModeKeyandINCRs in one Lua script, so a flip takes effect on the very next allocation everywhere — no per-process mode cache (the file's reviewer-P0).TestActivationTakesEffectWithoutAProcessCacheWindowlocks this. The activation tool refuses to flip without-yes+ operator confirmation that every replica runs the post-#697 image, and documents why legacy-below-new is the danger and that there is no online deactivate. - Seed-above-cursor, fail-closed. Seed runs before the first INCR, raises the counter above both the queue ceiling and the frozen legacy
seqrow (covering the fully-acked-queue-with-live-cursor case), is idempotent + never-lowers (GT Lua), and a seed failure fails the enqueue rather than handing out an unseeded id. Tested:TestSeedRaisesAboveQueueCeiling,TestSeedRaisesAboveLegacySeqRowWithEmptyQueue,TestSeedIsIdempotentAndNeverLowers,TestNextEventIDFailsClosedWhenSeedFails,TestExclusiveCursorIsLosslessWithMonotonicIDs. - msgSem stall avoided. Tight timeouts (500/300/200 ms) +
seqMaxRetries=1keep a degraded-Redis allocation ~1 s, not ~3 s, so it can't holdmsgSemslots and stall fan-out for every bot; failure fails one enqueue (recoverable), matching the ring's rationale.
HIGH — resolve before ACTIVATION (not a merge blocker): the durability argument proves uniqueness, not cursor-monotonicity across an RDB-loss restart
The file's co-recovery section argues an RDB crash is safe because counter C0 and queue max S0<=C0 roll back together, so C0+1 "cannot collide with anything that survived." That is correct — for uniqueness. It does not establish the other invariant the brief calls the hard precondition: new ids must stay above every client-held cursor. Client cursors are external and do not roll back. The two durable floor sources don't help post-activation: the queue max rolls back with the counter (same RDB), and the legacy seq row (MySQL) is frozen at activation and never tracks the post-activation high-water — and the process-local seeded cache means a running replica won't re-seed after a Redis restart anyway, it just INCRs the regressed counter.
Failure scenario: activated bot; snapshot at T0 with counter 49000; events 49001–50000 issued+read, client cursor advances to 49900; Redis restarts from the T0 RDB (prod runs appendonly no, save … 60 10000 → up to 60 s–1 h loss). Counter restores to 49000, queue co-recovers, but the client still holds cursor 49900. New events 49001–49900 land below the exclusive cursor → permanently invisible to that client until the counter re-climbs past 49900 — the exact #697 loss, re-inflicted by a crash. This is strictly worse than GenSeq on this axis, whose min_seq lives in MySQL and does not regress on a Redis crash. It's untested (the suite doesn't simulate counter rollback beneath a live cursor) and unmentioned in the otherwise-thorough durability note.
Fix options before activation: persist a durable, non-co-rolling-back high-water (e.g. periodically advance the seq row past the counter, or floor re-seed to it after a detected restart), or explicitly accept + document the risk with a recovery runbook (after any Redis data-loss restart, counters may sit below live cursors and re-inflict #697 until they climb back). At minimum, correct the co-recovery comment to scope its guarantee to uniqueness.
Coverage / blind spots
Byte-verified: the all-producers-on-NextEventID switch + the repo-wide GenSeq guard, the score==event_id equality at the producers, the atomic gate script, seed-above-cursor ordering + fail-closed, and the msgSem-budget timeouts; ran the pure guard/unit suite green. The real-Redis seq_test.go (monotonicity-under-concurrency, cursor-losslessness) is env-gated → CI. Adversarial checks that did not find a bug: partial-producer-switch (guard blocks it), mode-cache divergence (atomic script), seed-below-cursor (seeded before INCR, tested), ack mis-delete from broken equality (preserved), float64 score precision (ids well inside 2^53). One residual note: clearing ModeKey after activation (unsupported) drops all replicas to legacy, whose lower GenSeq ids would then land below live cursors — worth a one-line "never clear the mode" in the runbook.
Verdict
APPROVE — the merge is behaviour-neutral and the mechanism is correct where it is hardest: every producer is on the single gated allocator with a repo-wide source guard, the score/event_id equality is preserved, the flip is atomic with no mixed-source window, and seeding puts the first activated id above both the queue ceiling and the legacy cursor with a fail-closed path — all locked by tests. Both gates pass for merging. Before activation, close the one real gap: the durability argument guarantees uniqueness but not monotonicity relative to client cursors across an RDB-loss Redis restart, which can re-inflict the #697 invisibility (and is strictly worse than GenSeq on that axis) — resolve with a durable high-water or an explicit accept-and-runbook, and tighten the co-recovery comment. This is a pre-activation reliability item, consistent with the PR's own "does not close the issue / load-test before activation" framing, not a reason to hold the merge.
Suggested decision: Safe to merge (gated, neutral); do not activate until the RDB-restart cursor-regression is resolved or explicitly accepted with a runbook, and CI runs the real-Redis seq_test.go green. merge: MERGEABLE.
mochashanyao
left a comment
There was a problem hiding this comment.
[Octo-Q · automated review]
Verdict: Approve — no blocking findings; notes below (data-flow traced).
Code Review — PR #702 (octo-server)
Summary
This PR replaces the GenSeq HiLo block allocator with a Redis INCR-backed monotonic counter for bot event ID allocation, gated behind an activation switch (botEventSeq:mode = incr). The motivation is well-documented: GenSeq's per-process block allocation produces score collisions under concurrent cold-start or block-extend races, which breaks the consumer's exclusive-cursor pagination and score-based ack. The new allocator seeds the counter above max(queue ceiling, legacy seq row) + 2000 to guarantee forward progress, and the Lua gate script atomically checks mode and increments to avoid a per-process cache window.
Five production call sites (enqueueBotEventGeneric, prepareBotTypedEvent, addInlineQuery, saveRobotMessage, and two notifyBotJoinedGroup variants) are migrated from GenSeq to botevent.NextEventID. Queue key construction is consolidated via botevent.QueueKey(). A source guard test ensures no stale GenSeq call remains. Two operator tools (botevent-seq for preflight/activate, genseq-repro for reproducing the old collision) are included.
Verification
- ✅ All GenSeq call sites migrated — confirmed via diff and the
genseq_guard_test.gosource guard, which walks the repo and fails if any.gofile (exceptpkg/botevent/seq.go) callsGenSeqwithRobotEventSeqKey. - ✅ Consumer compatibility —
getEventsResultatmodules/bot_api/events.go:215uses exclusiveMin: "(cursor"pagination andZRemRangeByScore(id, id)for ack. Both require unique scores; the monotonicINCRallocator guarantees this post-activation. - ✅ Activation gate atomicity —
runGateatpkg/botevent/seq.go:206runs a Lua script that readsModeKeyandINCRsSeqKey(robotID)in oneEVALcall. No TOCTOU window between mode check and allocation. - ✅ Seed safety margin —
seedCounterat:307raises the counter abovemax(queueCeiling, legacyCeiling) + seedSafetyMargin(2000 =2 * legacyGenSeqStep). This clears any score the old allocator could have issued. - ✅ Co-recovery property — counter (
botEventSeq:{robotID}) and queue (robotEvent:{robotID}) live in the same Redis instance/db. An RDB crash restores both atomically; no split-brain on counter vs. queue. - ✅ Fails closed — if seeding fails,
nextEventIDreturns the error without allocating. TheseedFailuresatomic counter tracks this for alerting. - ✅ Inline query parity —
addInlineQueryatmodules/robot/api.go:1021shares the allocator with queue events. This is correct: inline query events are merged into the same response stream viainlineQueryEventsMapat:1093-1095and must not collide. - ✅ Semaphore safe —
saveRobotMessageruns inside amsgSemslot (capacity 100). The allocator's Redis call adds one round-trip (~1ms) inside the slot; the semaphore already bounds concurrency to 100 concurrent listeners.
Static analysis only at head d34bdfb0; build and tests not executed in this environment.
Findings
No P0/P1 issues. Four P2 items and one nit below.
P2 — 42MB compiled binary committed to repo root (botevent-seq:1)
A 42MB ARM64 ELF binary (botevent-seq) is committed at the repository root. This permanently adds ~42MB to every future clone and fetch, is platform-specific (aarch64 only — useless on amd64/macOS dev machines), and build artifacts should not live in source control. The tools/botevent-seq/main.go source is already committed; operators can go build on their target platform. Remove the binary, add botevent-seq to .gitignore, and consider git filter-branch or BFG to purge it from history before merge.
P2 — legacyCeiling may error on missing seq row (pkg/botevent/seq.go:419)
legacyCeiling reads seq.min_seq from the database for the bot's seq key. If the bot has never been assigned a GenSeq block (no row in the seq table), dbr.Load returns an error that propagates up through seedCounter. The inline comment at :371-373 states "bots with no queue and no legacy seq row" should start from 1, but the code does not handle the no-row case — it returns the error, causing seedCounter (and thus nextEventID) to fail. Verify whether octo-lib's Load returns dbr.ErrNotFound for missing rows and handle it explicitly (e.g. return 0 when errors.Is(err, dbr.ErrNotFound)).
P2 — seeded sync.Map grows without bound (pkg/botevent/seq.go:237)
seeded is a process-lifetime sync.Map that caches which robotIDs have been seeded. Every unique bot ID seen by this process is permanently stored with no eviction. On a long-running server serving thousands of bots, this grows unbounded. The memory per entry is small (a string key + struct{}{} value), but consider either: (a) accepting the growth as negligible (a few KB per 10K bots), or (b) using a TTL-based cache or periodic cleanup. Not a blocker — flagging for awareness.
P2 — Plaintext credentials in default DSN (tools/genseq-repro/main.go:35)
The genseq-repro tool's default MySQL DSN is root:demo@tcp(127.0.0.1:3306)/octo?charset=utf8mb4, containing a plaintext password. While this is a developer/operator tool (not production code), committed credentials — even for localhost — set a bad precedent and may trigger secret-scanning alerts. Move to environment variables or require the -mysql flag with no default.
Nit — No operational metric for INCR latency (pkg/botevent/seq.go:206)
The brief recommends a pre-activation load test at 2x peak QPS. Consider adding a Prometheus histogram for per-bot INCR latency so the load-test recommendation has operational follow-through after activation. Without this, there's no way to detect Redis saturation from the allocator in production.
Things I checked that are fine
- Exclusive cursor pagination — the consumer at
modules/bot_api/events.go:215usesMin: fmt.Sprintf("(%d", eventID)(exclusive lower bound). The monotonic allocator guarantees no two events share a score, so no events are skipped or double-delivered. - Ack-by-score safety —
ZRemRangeByScore(key, id, id)atevents.go:269removes exactly the member with that score. With unique scores, this removes exactly one member. - Queue key consolidation —
botevent.QueueKey(robotID)producesrobotEvent:{robotID}, matching the existing key format. No data migration needed. - Test coverage —
pkg/botevent/seq_test.gocovers: not-activated fallback, activation without cache window, seed clearing legacy ceiling, strict monotonicity under concurrency, seed above queue ceiling, seed above legacy seq row with empty queue, seed idempotency, fail-closed on seed error, and exclusive cursor losslessness. - Chokepoint guard test —
pkg/botevent/chokepoint_guard_test.gois updated to includebotevent.QueueKeyin the allowed pattern.
Verdict: APPROVED
No correctness, security, or build-blocking issues. The four P2 items are non-blocking: the committed binary is a housekeeping issue (remove before merge if possible), the legacyCeiling no-row case should be verified against octo-lib's Load behavior, the seeded map growth is negligible in practice, and the plaintext DSN is in a developer tool. The core allocator design is sound — atomic Lua gate, proper seeding above both queue and legacy ceilings, co-recovery with the queue, and comprehensive test coverage.
yujiawei
left a comment
There was a problem hiding this comment.
Code Review — PR #702 (octo-server)
Reviewed at head SHA d34bdfb055e9ef6f22977c27b0f3900bdd7d5966. I read the full diff, the linked task brief committed in this PR, octo-lib@79f78844bfab config/seq.go, the consumer side in modules/bot_api/events.go, and ran the source guards and go vet locally.
The diagnosis in this PR is excellent and the direction is right: GenSeq is a per-process HiLo allocator and it genuinely cannot satisfy an exclusive-cursor sorted set. The activation gate is the correct shape, and the reasoning quality throughout pkg/botevent/seq.go is unusually high. The blockers below are not about the idea — they are about a red CI check, a gap in the durability argument, and several acceptance criteria from this PR's own brief that are not actually implemented.
1. Spec compliance
Spec: ❌
The linked brief (.octospec/tasks/bot-event-score-monotonic/brief.md, added in this PR) is the spec. Most of it is met — the six producers are all migrated, addInlineQuery correctly shares the id source, the queue key is consolidated, the floor-bootstrap and idempotence criteria have tests. Three acceptance items are not.
Missing (漏建)
-
The write-side non-monotonicity counter does not exist.
brief.mdAcceptance: "新增「score 非单调」计数器(写入时新 score ≤ 队列当前 max 即计数),低基数 label;上线后该计数应恒为 0", restated in D1 ("并在写入侧对「新 score ≤ 队列当前 max」计数告警"). No such counter is in the diff —grep -n 'metric\|prometheus' pkg/botevent/seq.goreturns nothing. The only counter added isseedFailures(pkg/botevent/seq.go:243), which is a different signal and is explicitly not wired to Prometheus (seq.go:241-243).This is not a bookkeeping omission. It is the only runtime detector for the P0 in §2.1: a counter rollback that puts new ids below client cursors produces no error, no log, and no failed enqueue.
preflightcannot see it either, because it counts duplicate scores and this failure mode creates no duplicates. Without this metric, the failure is silent by construction. -
The migration-verification runbook is not executable as specified. Acceptance: "切换前记录各队列 max score 与
seq表min_seq,切换后断言新号段整体高于两者".tools/botevent-seq preflightnever reads theseqtable at all, and printsmaxScoreonly for queues that already have duplicates (tools/botevent-seq/main.go:117-121). It also defaults to-sample 20against ~1948 queues, so "各队列" is not covered unless the operator knows to pass-sample 0. The Rollout section in the PR body inherits the same gap. -
./modules/messageregression not reported. Acceptance namesgo test -race ./modules/bot_api ./modules/robot ./pkg/botevent ./modules/message -count=1. The PR body reportsmodules/robot,modules/group,modules/bot_api—modules/messageis absent, and it is the package that owns thecard_actionD4 idempotency claim the brief calls load-bearing.
Added beyond scope (超建)
- A 42,846,728-byte compiled binary is committed at the repo root.
botevent-seq—ELF 64-bit LSB executable, ARM aarch64, with debug_info, not stripped. It is not in.gitignore(git check-ignore botevent-seq→ no match) and not in.dockerignore, soDockerfile:24'sCOPY . .pulls it into the build context. It does not reach the prod image (that stage copies only/go/release/*), so the cost is not runtime — it is that a 42 MB blob becomes permanent git history in a public repository the moment this merges, and it is an unreviewable executable in a change labelledneeds-human-review. This must be removed from the branch (andbotevent-seqadded to.gitignore) before merge, not after.
Deviation (偏离)
brief.mdD1 specifies the counter key asbotevent:seq:{robotID}; the implementation usesbotEventSeq:{robotID}(seq.go:103). Cosmetic on its own, but see §2.7 — the brief's spelling would not have collided with the mode key.
2. Code quality
Quality: Changes-Requested
P0
2.1 — pkg/redis guard test fails on this head SHA; the Test CI check is red.
--- FAIL: TestNoRawRedisClientOutsideChokepoint (0.14s)
chokepoint_guard_test.go:57: raw redis client construction outside the octoredis chokepoint:
tools/genseq-repro/main.go
tools/genseq-repro/main.go:156 does client := rd.NewClient(&rd.Options{Addr: *redisAddr}). pkg/redis/chokepoint_guard_test.go:19-53 scans every non-_test.go file outside pkg/redis for \b(rd|redis)\.NewClient\(. Reproduced locally in 0.14s; also the cause of the red Test job on run 31000644877.
Two things follow. The fix is one line — route it through octoredis.NewInstrumentedClient, exactly as tools/botevent-seq/main.go:41 already does. But the PR body states "go build ./..., go vet, gofmt … all clean" and lists three module packages as passing; a whole-repo go test ./... was evidently never run, and the brief's regression command does not include ./pkg/redis. Separately, seq.go:245-247 claims the allocator is built through octoredis "so pkg/redis's raw-client chokepoint guard stays satisfied" — true of seq.go, false of the tool shipped alongside it in the same PR.
2.2 — The RDB co-recovery argument does not cover the consumer cursor, which is the one piece of state that is not in Redis.
seq.go:34-47 argues that appendonly no is safe because counter and queue share an RDB domain, so a rollback rewinds both together and "resuming from C0+1 cannot collide with anything that survived." That is correct about collisions and silent about cursors.
Concretely: snapshot at T0 with counter C0 = 5000. Between T0 and the crash, ids 5001–5100 are issued, delivered, and acked; the bot now holds cursor 5100 — in its own process, not in Redis. Redis crashes and restores T0. The counter is back at 5000, so the next 100 events are born at 5001–5100, below the bot's cursor. ZRANGEBYSCORE key (5100 +inf excludes every one of them; they sit in the queue until Robot.MessageExpire (7 days by default) and are never delivered. This is precisely the loss class the PR exists to remove, reintroduced by the fix's own storage choice.
The seeded fast path makes it worse: seq.go:295-299 goes straight to runGate for any bot already in the sync.Map, so no reseed is attempted and the floor is never re-validated. Even if it were, seedCounter could not help — queueCeiling reads the rolled-back queue and legacyCeiling reads a stale min_seq; neither knows the cursor. Bot cursors are entirely client-supplied (modules/bot_api/events.go:77, req.EventID) and never persisted server-side, so this is structural, not an oversight.
Worth noting how the repo already solved the same problem: internal/msgextraseq keeps its activation state in a MySQL row with an epoch, validates the cutover floor against max(DB version, DB seq, observed Redis cursor evidence) and refuses the flip with ErrFloorTooLow otherwise (internal/msgextraseq/activation.go:181-196), and carries a fail-closed expected-mode env guard (store.go:96-116). seq.go:78 describes this design as a "DB-authoritative-style state flag" — but the implementation is a plain Redis key with no epoch, no floor validation, and no expected-mode assertion. The mode key has the same rollback exposure as the counter, and seq.go:302-306 and :330-332 both fail open to GenSeq when it disappears, which is the mirror-image loss (counter-era ids are above min_seq, so legacy resumes below every live cursor) and contradicts this PR's own stated no-fallback principle at seq.go:53-64.
This one was independently raised in the existing review; I confirmed the mechanism against the code and I agree it blocks. It needs either a durable high-water record outside the RDB domain, or a fail-closed guard plus the §1.1 metric so the condition is at least detectable.
P1
2.3 — seedSafetyMargin = 2 × step is justified by an argument that does not hold.
seq.go:127-136 says two steps "covers one reserved block plus one such regression." min_seq regression is not bounded by one step. In octo-lib config/seq.go, each replica's write-back is min_seq = its own CurSeq + seqStep under an unconditional ON DUPLICATE KEY UPDATE. If replica A has advanced to 61000 while replica B is still in the 53000 block, B's next extend writes 54000 — a 7-step regression below the highest id ever issued. The PR's own production data (19 inversions on block boundaries, 6.7-day time regressions) is evidence that replicas drift many blocks apart, so multi-step regressions are the expected case, not the tail.
The queue ceiling normally covers this — but not for the bots most likely to be affected. Robot.MessageExpire defaults to 7 days (octo-lib config/config.go:439) and is applied to the queue key on every enqueue, so any bot idle for a week has an empty queue and queueCeiling returns 0 (seq.go:403-405). For those bots the seed rests on min_seq + 2000 alone, against a client cursor equal to the highest id ever delivered.
Note that the "a larger margin makes it worse" reasoning at seq.go:74-76 is about the un-gated immediate-switch design, where legacy was still issuing from below. Once the gate guarantees no legacy writer, a much larger margin is strictly safer and costs nothing — ids are int64 and exact in float64 up to 2^53. This looks like an argument that survived the design change it was written for.
2.4 — All nine behavioural tests silently skip in CI.
pkg/botevent/seq_test.go:32 points at a botevent_test database. The CI Test job provisions only test (ci.yml:122) and drops/recreates only test between packages (ci.yml:276); nothing ever creates botevent_test. So ctx.DB().Exec("CREATE TABLE IF NOT EXISTS seq …") fails and seq_test.go:60 takes the t.Skipf path. Observed locally on a machine with no MySQL:
--- SKIP: TestNotActivatedDelegatesToLegacy
--- SKIP: TestActivationTakesEffectWithoutAProcessCacheWindow
--- SKIP: TestSeedClearsTheLegacyCeilingAtActivation
--- SKIP: TestNextEventIDIsStrictlyMonotonicUnderConcurrency
--- SKIP: TestSeedRaisesAboveQueueCeiling
--- SKIP: TestSeedRaisesAboveLegacySeqRowWithEmptyQueue
--- SKIP: TestSeedIsIdempotentAndNeverLowers
--- SKIP: TestNextEventIDFailsClosedWhenSeedFails
--- SKIP: TestExclusiveCursorIsLosslessWithMonotonicIDs
ok github.com/Mininglamp-OSS/octo-server/pkg/botevent 0.083s
Every test carrying the safety argument — fail-closed on seed failure, no-cache-window activation, lossless exclusive cursor at page size 1 — reports ok while running nothing. The tests themselves are genuinely good; they just never execute where it matters, so nothing prevents a regression from landing. The dedicated-database rationale at seq_test.go:22-31 is sound, and the repo already has the pattern that satisfies both constraints: modules/message/issue557_creator_thread_e2e_test.go creates its own isolated database from the CI job's MySQL (see the note at ci.yml:253-255). Doing the same — CREATE DATABASE IF NOT EXISTS botevent_test from the fixture — would make these run in CI without touching the shared migration ledger.
2.5 — The activation drain window is documented only where the operator will never see it.
seq.go:81-83 states the residual window honestly: "a request that has already read legacy and not yet ZADDed when the flip commits; drain writes for a few seconds around the flip to close it." That mitigation appears nowhere else. tools/botevent-seq/main.go:135 prints "activated. Every replica switches on its next allocation." with no mention of draining, and the PR body's Rollout step 4 goes straight from "confirm no pre-fix replica" to -action activate -yes. The unseeded path is where the window lives: seq.go:309 reads the mode with a plain GET outside the gate script, so between that read and the ZADD in the caller the flip can commit and a legacy-numbered id lands in a counter-era queue.
Small window, but the safety property depends on an operator step that only exists in a source comment. Put it in the tool's pre-flip output and in the Rollout section.
P2
2.6 — ModeLegacy is dead and its docstring is false. seq.go:113-115 says it is "written explicitly by the operator tool so preflight can tell 'not yet activated' from 'never configured'". grep -rn 'botevent.ModeLegacy' across the repo returns nothing — tools/botevent-seq only ever writes ModeIncr (main.go:132), and currentMode reports "(unset — legacy)" for a missing key. Either write it in a -action deactivate-marker-style path or delete the constant and the claim.
2.7 — ModeKey shares the per-bot counter namespace. SeqKeyPrefix = "botEventSeq:" (seq.go:103) and ModeKey = "botEventSeq:mode" (seq.go:108), so SeqKey("mode") is ModeKey. Not reachable today — bot ids always carry the _bot suffix (modules/botfather/const.go:20, command.go:955, api_user.go:175) — but the invariant lives in another module and the docstring at seq.go:100-102 reasons only about robotEvent: and robotEventBell:, not about the mode key inside its own prefix. If it ever were reachable, seedSource's tonumber(cur) < floor would evaluate nil < number and error on every allocation for that bot. The brief's botevent:seq:{robotID} spelling (§1.5) avoids this for free.
2.8 — The latency bound is understated, and the expensive path is not the one analysed. seq.go:216-228 reasons about the steady state: one round trip, "~1s worst case". With MaxRetries = 1, DialTimeout 500ms, ReadTimeout 300ms, PoolTimeout 200ms plus go-redis retry backoff, two attempts are closer to ~1.8–2.3s. More importantly the unseeded path — taken once per bot per process, i.e. for every bot after every rollout — does GET mode + ZREVRANGE + a MySQL SELECT (legacyCeiling, seq.go:418-419, no context or timeout) + seed EVALSHA + gate EVALSHA, all inside the same held msgSem slot (modules/robot/api.go:379, cap 100) that the whole bound exists to protect. A stalled MySQL there blocks with no deadline at all. This deserves to be in the analysis and in the rollout step 3 load test, not just the steady state.
2.9 — legacyCeiling's key format is only asserted against itself. seq.go:417 builds "seq:" + common.RobotEventSeqKey + robotID. I verified this matches octo-lib config/seq.go's fmt.Sprintf("seq:%s", flag), so it is correct today. But no test pins it: TestSeedRaisesAboveLegacySeqRowWithEmptyQueue hand-writes the same string in its INSERT (seq_test.go:271), and TestSeedClearsTheLegacyCeilingAtActivation would still pass with legacyCeiling hard-wired to 0, because the three enqueued ids give queueCeiling enough margin to clear its assertion on its own. If octo-lib ever changes the key, legacyCeiling silently returns 0 for every bot — losing exactly the drained-queue protection §2.3 depends on — and the suite stays green. Driving the fixture through ctx.GenSeq(common.RobotEventSeqKey + robotID) instead of a literal INSERT would close it.
2.10 — The GenSeq guard is evadable by a routine refactor. genseq_guard_test.go:31 uses GenSeq\([^)]*(RobotEventSeqKey|"robotEventSeq:), matched line by line. This passes cleanly:
key := common.RobotEventSeqKey + robotID
seq, err := ctx.GenSeq(key)
TestGenSeqGuardWouldCatchAReintroduction only exercises three inline spellings, so it gives false confidence about the escape. There is a strictly tighter guard available: common.RobotEventSeqKey currently appears in exactly one non-test production file (pkg/botevent/seq.go), so asserting the symbol itself appears nowhere else outside the allowlist catches both the inline and the hoisted form. The sibling minKnownQueueWriters = 5 floor in chokepoint_guard_test.go has the matching limitation — it detects a lost writer, not an added one using a new spelling — though its own comments are honest about being a vacuity defence.
2.11 — Every new process bumps the counter by 2000 on first touch of a bot. seedCounter adds the margin whenever floor > 0 (seq.go:388-393), and floor includes queueCeiling. Post-activation steady state, a fresh pod's first allocation for a bot computes queueMax + 2000 and seedSource raises the counter to it. Harmless — monotonic forward jumps never land below a cursor, and int64 has room for centuries of this — but it means the margin is a recurring behaviour, not the "first-time seed" the docstring describes, and it makes the counter= value preflight prints unreliable as a volume signal. Worth either scoping the margin to legacyMax or correcting the comment.
2.12 — Lua/float64 id representation (informational). seedSource round-trips the floor through Lua numbers (doubles) while INCR returns a true int64, and queueCeiling truncates float64 back to int64 (seq.go:406). Exact below 2^53. At realistic magnitudes — min_seq in the 10^5–10^7 range plus 2000 per seed — this is unreachable, so it is a documentation note rather than a defect; I do not think it can produce shared scores in practice.
What is good, and worth keeping exactly as-is
- The activation gate reading the mode inside the allocation script (
gateSource,seq.go:177-179) with a deliberate refusal to cache it. The reasoning atseq.go:169-176is correct and the reason it must not be "optimised" later is stated where the next person will find it. - Routing
addInlineQuerythrough the same allocator (modules/robot/api.go:1005-1016). The note explaining that a separate sequence would push the shared cursor into the millions is the kind of correction that usually gets silently reverted; recording why the first attempt was wrong is the right call. - Consolidating producers onto
botevent.QueueKeyso the seed reads the exact key producers write. TestExclusiveCursorIsLosslessWithMonotonicIDsdriving the real read shape at page size 1, andtools/genseq-reproas its failing mirror — the observation that a whole-queue read in one pass loses nothing (genseq-repro/main.go:206) is what makes the intermittency legible.
3. Overall verdict
REQUEST_CHANGES
Spec ❌ (§1.1–§1.4) and Quality Changes-Requested (§2.1, §2.2). Minimum to clear:
tools/genseq-repro/main.go:156→octoredis.NewInstrumentedClient, so theTestcheck goes green.- Remove
botevent-seqfrom the branch and add it to.gitignore. - Add the write-side non-monotonicity counter the brief requires — it is the detector for §2.2.
- Resolve §2.2: a durable high-water record, or fail-closed on a missing/rolled-back mode key instead of falling through to
GenSeq. - Make the
pkg/boteventtests actually run in CI (§2.4).
4. Suggested direction
For §2.2 specifically, the smallest change that preserves the design is to stop treating a missing mode key as "legacy". Once a process has allocated from the counter it knows activation happened; reverting to GenSeq from that state is never safe, so seq.go:302-306 and :330-332 should return an error rather than delegate. Pair that with an expected-mode assertion at startup, in the shape internal/msgextraseq/store.go:96-116 already uses, so an operator can pin "this build expects incr" and have a rolled-back mode key fail loudly instead of quietly.
For the counter itself, a durable high-water mark — the largest id ever handed out per bot, written outside the RDB domain on a coarse interval (every Nth allocation, not every one) — would let the seed floor survive a rollback without putting MySQL on the hot path. That also gives §2.3 a real bound instead of a 2 × step guess.
For §2.3 in the interim: raise seedSafetyMargin substantially. The "larger is worse" constraint no longer applies now that the gate guarantees no concurrent legacy writer, and int64 headroom is effectively free.
5. Additional notes and coverage gaps
Things a human should verify that I could not, and areas this review did not cover:
- The production Redis configuration is quoted, not verified.
appendonly no,save 3600 1 300 100 60 10000,maxmemory 0/noeviction, single master with 0 replicas — the entire durability argument rests on these, andnoevictionin particular is what keeps the counter from being evicted. Please confirm against the live instance, and confirm nothing in the environment can promote a replica or point the client at a different db (pkg/redis/options.go:26-28andocto-lib config/context.go:107-110both leaveDBunset, so co-location holds today by omission rather than by assertion — adbconfig field appearing later would break it silently). - The load test in Rollout step 3 has not been run. The I/O shape change is real and stated plainly by the author:
GenSeqserved 999 of 1000 allocations with no I/O; every allocation here is a round trip, on the hottest producer path, inside a bounded semaphore. This is the item I would least want to discover in production. - The three acceptance criteria for the "control must fail" assertions live only in a manually-run tool.
tools/genseq-reprois never executed by CI (it only has to compile), so the demonstration that the old allocator loses events can rot without anything noticing. ackEventprecision is not tested here. The brief lists "ackEvent能按event_id精确删到目标 member" as acceptance; the consumer is unchanged, so this is pre-existing coverage rather than a regression, but the invariant is not pinned by anything in this PR.- Not covered by this review: the behaviour of real bot clients' cursor persistence across a 7-day idle window (unobservable server-side, and §2.3's blast radius depends on it); whether the ~1948 production queues include bots whose
min_seqhas regressed far enough to matter, which only a read-only measurement before activation can answer; and the queue-sideDelsites (modules/botfather/command.go:624,:761,:792,modules/robot/api_manager.go:374,modules/botfather/api_user.go:472) which delete the queue but correctly leave the counter — worth confirming that stays deliberate, since deleting the counter there would reintroduce a below-cursor seed on the next allocation.
Addendum to my review above — two additional findings, same head SHA (
|
…nglamp-OSS#697) Addresses two blocking review findings, both of which would have lost events. P0 -- mixed old/new replicas were unsafe. The previous revision seeded the counter above max(queue ceiling, min_seq) and switched immediately, reasoning that new ids are always higher so coexistence is safe. That is backwards: a legacy replica issues ids from the bottom of its block upward, so while the new allocator hands out 7001 a legacy replica is still handing out 5001, 5002, and once a consumer's cursor reaches 7001 every one of those is permanently invisible. seedSafetyMargin made it worse rather than better -- the margin is the gap that swallows them. The switch now reads a botEventSeq:mode flag, defaulting to legacy, so deploying is behaviour-neutral and an operator flips it only after confirming no pre-fix replica remains. The flag is read inside the same Lua script as the INCR, deliberately without a per-process cache: caching it even for a second would mean some replicas allocating from the counter while others still use GenSeq, which is the two-live-sources defect itself. A flip therefore takes effect on the next allocation everywhere. tools/botevent-seq does preflight and activation; it cannot verify the replica precondition, so it demands -yes and says so. This makes pkg/botevent/seq.go the single allowlisted GenSeq call site, the same shape internal/msgextraseq uses. The guard now allows it there and nowhere else, and additionally fails if that delegation disappears -- deleting it as dead code would silently make a deploy switch allocators on its own. P1 -- inline query events do have a consumer. They were split onto their own GenSeq key on the belief that nothing read inlineQueryEventsMap. getEventsResult reads it, merges it with the queue, sorts the union by EventID and filters by one shared cursor. A fresh GenSeq key starts near 1000001 while the counter starts low, so a single inline event would push the cursor into the millions and permanently filter every ordinary event behind it -- worse than the bug being fixed. They now share NextEventID: one GenSeq sequence before activation, one counter after, single-sourced at all times. The earlier claim rested on a grep truncated by `| head`, which hid the read site. A truncated search cannot establish absence.
…ninglamp-OSS#697) The allocator runs inside a msgSem slot on saveRobotMessage, the highest-volume producer. With 100 slots held on listener goroutines, a slow allocation holds a slot longer and 100 held slots stall message fan-out for every bot in the process -- including bots that never send an interactive card. That is the same hazard that forced the doorbell off the producer goroutine, except the allocator cannot be made asynchronous: there is no id to enqueue without it. So it fails fast instead. 500ms dial / 300ms read with one retry bounds a degraded Redis at roughly 1s rather than 3s. The trade is explicit: giving up fails one enqueue, which a D8 re-tap or a resend recovers, while waiting stalls fan-out for everyone. Also records the I/O shape change this introduces, which is a genuine regression risk rather than a footnote: GenSeq served 999 of every 1000 allocations from its process-local block with no I/O at all, while every allocation here is a Redis round trip. Batching back into blocks would recreate a second id source, and caching the mode would recreate the mixed-source window, so neither escape is available -- the hottest producer needs a load test before activation.
d34bdfb to
aedde27
Compare
…ininglamp-OSS#697) pkg/redis's TestNoRawRedisClientOutsideChokepoint scans the whole repo, tools included, and failed CI on tools/genseq-repro constructing a raw rd.NewClient. Caught by CI rather than locally because the run only covered the changed packages, while this guard is a repo-wide scan -- the same shape as the two guards this branch adds. Repo-wide guards have to be run for any change that adds code anywhere, not just for changes to their own package. Also ignores the operator-tool binaries in the repo root. A 41MB build artifact was committed earlier in this branch by a broad 'git add'; the history has been rewritten to drop the blob, and the ignore entry stops it recurring.
Jerry-Xin
left a comment
There was a problem hiding this comment.
This PR is in-scope for octo-server and addresses a real bot event delivery defect, but the activation state can silently roll back to legacy after Redis data loss/restart.
🔴 Blocking
🔴 Critical — Activation mode is stored only in Redis, but missing mode means legacy allocation.
In pkg/botevent/seq.go, ModeKey read failure to find "incr" delegates to legacyEventID; the seeded fast path also falls back to legacy when the gate script returns -1 at pkg/botevent/seq.go. The operator tool activates by a plain Redis SET at tools/botevent-seq/main.go. With production appendonly no, a Redis restart before the next RDB snapshot can restore a pre-activation state where botEventSeq:mode is absent. At that point all post-fix replicas silently resume GenSeq allocation, issuing IDs below clients that have already observed counter-issued cursors, recreating the exact loss mode this PR is meant to prevent.
There is a second edge on the same failure path: if the operator re-sets ModeKey to "incr" while the server process stayed alive, seeded may still contain the bot ID, so pkg/botevent/seq.go skips seedCounter; if the restored Redis no longer has that bot’s counter key, INCR starts at 1, below the queue/legacy ceiling. The activation gate needs durable state or a restart/reseed-safe protocol before merge.
💬 Non-blocking
🟡 Warning — preflight -sample inspects the first sorted keys only. That is acceptable as an operator convenience, but the default can easily miss the specific hot queues whose duplicate counts matter. Consider making the “verify duplicate count stopped growing” path default to all queues, or require an explicit sample for partial scans.
✅ Highlights
The PR correctly identifies all direct bot-event producers and routes them through a shared allocator, including the inline-query path that shares a cursor with queue events.
The source guards in pkg/botevent are useful and non-vacuous, and go test ./pkg/botevent passes locally.
lml2468
left a comment
There was a problem hiding this comment.
PR Review (re-review) — PR #702 (Mininglamp-OSS/octo-server) — REQUEST_CHANGES (flips my APPROVE)
Re-reviewed at new head aedde27a0bbc. This corrects my APPROVE at d34bdfb0. Delta since then: the committed binary is removed and a .gitignore added; pkg/botevent/seq.go is byte-unchanged — the durability gap three reviewers raised is untouched. go build + the pure pkg/botevent guard suite still pass ✅.
Owning two things
- I missed the 42 MB committed binary.
botevent-seq(an unstripped aarch64 ELF) was sitting at the repo root infiles.txtand I read past it as the tool directory. mochashanyao, yujiawei, Steve and Jerry-Xin all caught it. It's now fixed: the binary is gone and.gitignoreignores/botevent-seq,/msgextra-version,/card-action-dlq,/genseq-repro,/i18nmarkers. Credit to them; that finding is resolved. - I under-ranked the durability gap as "pre-activation, not a merge blocker." On reflection that was wrong, and I'm flipping. I did flag the cursor-regression correctly, but I treated the merge as clean because it's gated. The problem is that the PR's core design justification ships incorrect:
seq.go:38-51and brief D1 present "回退是自洽的,这是选INCR的核心理由" as settled — and it is self-consistent only for uniqueness, not for the invariant the brief itself calls the hard precondition (new ids stay above every client cursor). Shipping a load-bearing safety claim that is wrong on the exact failure mode this package exists to remove is a code-quality (Gate 2) defect regardless of the flag, because activation is a one-lineSET(tools/botevent-seq/main.go:131) with no code guard against the unsafe condition — the operator relies on that claim. Steve and Jerry-Xin blocked on it; they're right.
Blocking — durability argument proves uniqueness, not cursor-monotonicity across an RDB-loss restart (unchanged at this head)
The co-recovery argument (seq.go:34-47, brief D1): counter C0 and queue max S0≤C0 share the RDB domain, so a crash rolls both back and C0+1 "cannot collide with anything that survived." Correct — for collisions. But client cursors live outside Redis and do not roll back. Prod runs appendonly no, save 3600 1 300 100 60 10000 → a restart loses 60 s–1 h.
Failure scenario: activated bot, snapshot at T0 (counter 49000); events 49001–50000 issued+read, client cursor → 49900; Redis restarts from the T0 RDB. Counter restores to 49000, queue co-recovers, but the client still holds 49900. New events 49001–49900 land below the exclusive cursor → permanently invisible until the counter re-climbs — the #697 loss, self-inflicted by a crash. Strictly worse than GenSeq on this axis, whose min_seq is in MySQL and does not regress on a Redis crash. The two durable floor sources don't help: the queue max co-rolls-back, and the legacy seq row is frozen at activation; and the seeded fast-path (seq.go:293) means a running replica won't re-seed after the restart — it INCRs the regressed counter. If mode also rolls back / is cleared, seq.go:309/:330 fall to legacyEventID, whose lower ids land below live cursors — the mirror-image loss.
Fix (any one, before this is a clean approve): (a) a durable, non-co-rolling high-water — e.g. advance the seq row past the counter periodically and floor re-seeds to it after a detected restart; or (b) detect a counter/queue rollback and re-seed above the last-exposed id; or (c) explicitly scope the claim to uniqueness, add the write-side "new score ≤ observed ceiling" regression counter the brief promised (yujiawei also flagged its absence), and ship a pre-activation runbook covering RDB-restart recovery. At minimum, correct the "回退是自洽的(核心理由)" claim in both seq.go and brief D1.
Still verified-good (unchanged, not re-litigated)
All-producers-on-NextEventID + the repo-wide TestNoGenSeqForBotEventIDs source guard (with the delete-proof allowlist assertion); score==event_id equality at each producer; the atomic gateSource flip with no per-process mode window; seed-before-INCR / fail-closed; the msgSem-protecting timeout budget (500/300/200 ms, 1 retry). These remain correct and well-tested; the mechanism is excellent where it isn't relying on the incomplete durability claim.
Verdict
REQUEST_CHANGES (flipping my d34bdfb0 APPROVE) — the 42 MB binary I missed is fixed (credit mochashanyao/yujiawei/Steve/Jerry-Xin), but the dominant issue is unaddressed at this head: the allocator's core durability rationale guarantees only uniqueness, while an RDB-loss Redis restart drops the counter below surviving external client cursors and re-inflicts the exact #697 invisibility this PR exists to remove — and the shipped code + brief assert that path is safe. Because activation is a bare SET gated only by that (incorrect) claim, this belongs blocked at the PR, not deferred to an operator's judgment. Land a durable high-water (or detect-and-reseed on restart), or scope the claim + add the write-side regression counter + an RDB-restart runbook. Everything else — the all-producers switch, the source guard, the atomic gate, equality, fail-closed, the msgSem budget — is correct and stays.
Suggested decision: Request changes on the RDB-restart cursor-regression (durable high-water or detect-and-reseed) + correct the co-recovery claim; the binary removal + .gitignore is resolved. merge: blocked on the durability fix.
Superseded: re-posting with repository-relative file paths.
Jerry-Xin
left a comment
There was a problem hiding this comment.
🔄 Re-review at aedde27a0bbc (prior CR was d34bdfb0 / REQUEST_CHANGES).
The only change since my last review is infrastructural: the committed botevent-seq binary was removed from the repo root and .gitignore now excludes /botevent-seq and the other operator-tool build artifacts (/msgextra-version, /card-action-dlq, /genseq-repro, /i18nmarkers). That resolves my earlier 🟡 note. Every source file is byte-identical to the prior head (pkg/botevent/seq.go, tools/botevent-seq/main.go, modules/robot/event.go, modules/group/service.go, pkg/botevent/seq_test.go all carry the same blob hashes), so the critical durability issue from the last round is unaddressed.
🔴 Blocking
🔴 Critical — Activation state and the counter live only in Redis, and both failure directions still recreate the loss this PR fixes.
- Missing mode ⇒ legacy:
ModeKeynot equal to"incr"delegates tolegacyEventIDatpkg/botevent/seq.go:309, and the seeded fast path also falls back to legacy when the gate returns-1atpkg/botevent/seq.go:295. The operator tool activates with a plain RedisSETattools/botevent-seq/main.go:132. With productionappendonly no, a Redis restart before the next RDB snapshot can restore a pre-activation state wherebotEventSeq:modeis absent. Every post-fix replica then silently resumesGenSeqallocation, issuing IDs below clients that have already observed counter-issued cursors — the exact unreachable-event loss this PR is meant to prevent. - Reseed skipped after rollback ⇒ IDs below cursor: the seeded fast path at
pkg/botevent/seq.go:295skipsseedCounterwhenseededalready holds the bot ID. If the operator re-setsModeKeyto"incr"while the process stays alive but the restored Redis no longer has that bot's counter key,INCRstarts at1, below the queue/legacy ceiling. New events land below already-exposed cursors and are permanently unreachable.
The client cursor is external and does not roll back with Redis, so the "counter and queue recover together" durability argument does not cover it. This needs a durable/high-water floor that never rewinds below already-exposed IDs, or a reseed-on-reconnect (drop seeded and re-seed on any counter/mode discontinuity), before merge.
💬 Non-blocking
🟡 preflight -sample inspects only the first sorted keys, so the "verify duplicate count stopped growing" check can miss the specific hot queues that matter. Consider defaulting the verification scan to all queues, or requiring an explicit flag for partial scans.
✅ Highlights
- The
botevent-seqbinary removal +.gitignorehardening is the right fix for the committed-artifact issue. - All direct bot-event producers, including the inline-query path that shares a cursor with queue events, correctly route through the shared allocator.
- The source guards in
pkg/boteventare non-vacuous and the package tests pass.
mochashanyao
left a comment
There was a problem hiding this comment.
[Octo-Q · automated review]
Verdict: Approve — no blocking findings; notes below (data-flow traced).
Code Review — PR #702 (octo-server)
Reviewer: Octo-Q (automated review)
Summary
This PR replaces the per-process HiLo block allocator (octo-lib GenSeq) for bot event IDs with a Redis INCR-based monotonic allocator, gated behind an explicit activation flag (botEventSeq:mode). The motivation is sound: production measurements show 2624 colliding scores and 19 ordering inversions across replicas, which corrupt the exclusive-cursor pagination and score-based ack in modules/bot_api/events.go. The design is carefully thought through — the activation gate (Lua-atomic mode-check + INCR) prevents the mixed-source defect, the seed logic (max(queue ceiling, legacy min_seq) + 2000) correctly clears both existing queue members and legacy-reserved blocks, and pre-activation behaviour is byte-identical to the old binary. All five production call sites are migrated, guard tests lock in the fix, and an operator tool (botevent-seq) enforces correct rollout ordering.
Verification
Static analysis only at head aedde27a; build and tests not executed in this environment.
- ✅ All 5 production GenSeq call sites migrated —
enqueueBotEventGeneric(api.go:262),prepareBotTypedEvent(api.go:323),addInlineQuery(api.go:1021),saveRobotMessage(event.go:357),notifyBotJoinedGroup(group/api.go:1967,group/service.go:2068). Zero remainingGenSeq(RobotEventSeqKey…)calls outside the allowlisted legacy path inseq.go:363. - ✅ Queue key consolidated — All writers now use
botevent.QueueKey(robotID)(= "robotEvent:" + robotID), matching the consumer'srobotEventPrefixinmodules/bot_api/events.go:32. - ✅ Lua script atomicity —
gateSourcereads mode and INCRs in one Redis script (no interleaving).seedSourcedoes compare-and-SET atomically. - ✅ No fallback after activation — After seed succeeds and mode is
incr, Redis failure returns error to caller (fails the enqueue). No silent fallback to GenSeq, which would reintroduce the dual-source defect. - ✅ Inline query events share the allocator —
addInlineQuery(api.go:1021) usesNextEventID, critical because inline and queue events merge into one cursor-sorted stream (api.go:1093-1120). - ✅ Consumer contract preserved —
ZRangeByScorewith exclusive(cursor+ZRemRangeByScore(id, id)remain correct under strict monotonicity + uniqueness, whichINCRguarantees by construction. - ✅ Guard tests cover reintroduction —
TestNoGenSeqForBotEventIDsscans the codebase and asserts the allowlisted file still contains the legacy delegation (guards against accidental deletion).TestGenSeqGuardWouldCatchAReintroductionproves the regex catches reintroductions and doesn't over-match. - ✅ Chokepoint guard updated —
chokepoint_guard_test.go:35regex now includesbotevent\.QueueKey. - ✅ Seed idempotency —
seedSourceLua only SETs whencur < floor, so concurrent replicas seeding the same bot converge safely. - ✅ Bounded latency —
seqDialTimeout=500ms,seqReadTimeout=300ms,seqMaxRetries=1caps worst-case allocation at ~1.6s, protecting the 100-slotmsgSemfrom stalling all bot fan-out.
Findings
No P0/P1 issues. Two P2 notes and one nit below.
P2 — legacyGenSeqStep fragile duplicate of unexported octo-lib constant (pkg/botevent/seq.go:115)
legacyGenSeqStep = 1000 mirrors octo-lib's internal config.seqStep, which is not exported. The comment explains the dependency and why it matters (the 2000 safety margin is meaningless without the real value). If octo-lib changes seqStep, this constant silently drifts and the seed margin becomes wrong. Consider exporting seqStep from octo-lib (e.g. config.SeqStep) or adding a runtime cross-check test that asserts the value against actual GenSeq behaviour.
Note — Bot reset path does not clean up botEventSeq: keys (modules/robot/api_manager.go:374, unchanged file)
The admin bot-reset flow deletes robotEvent:{robotID} but not the new botEventSeq:{robotID} counter. This is not a correctness issue — the counter is idempotent, stale values only cost one extra seed round trip, and the seed raises above the existing value anyway. But over many bot lifecycles orphaned counters accumulate. Consider adding client.Del(botevent.SeqKey(robotID)) alongside the existing queue deletion.
Nit — SeqClient singleton uses first-config-wins (pkg/botevent/seq.go:228-237)
sync.Once means subsequent calls with different *config.Config values silently reuse the first client. This matches the existing pattern in the codebase (the bell's ring client uses the same approach) and is correct given the co-recovery invariant (counter must stay in the same Redis as the queue). Flagging for awareness only.
Data Flow Tracing
- Production entry → allocator: All 5 call sites pass
(ctx, robotID)toNextEventID.ctxis nil-checked;robotIDisTrimSpaced and empty-checked.robotIDcomes from authenticated context in every path (robot request, group member UID), never from request body. - Allocator → queue write: Returned
seqis used as both sorted-set score (float64(seq)inZAdd) and payloadEventID. Consumer reads score-as-cursor and payload-event_idinterchangeably — equality holds because both derive from the sameINCRresult. - Seed path data flow:
queueCeilingreadsZRevRangeWithScores(key, 0, 0)→ max score or 0.legacyCeilingreadsSELECT min_seq FROM seq WHERE key=…→ value or 0 (no row = 0, bot never issued).floor = max(queue, legacy) + (2000 if floor>0). Seed Lua atomically SETs if current < floor. First INCR returnsfloor+1or higher. - Failure paths: Redis error in gate → error returned → caller fails enqueue (no silent fallback). Seed failure →
seedFailures.Add(1)→ error returned → caller fails enqueue. Mode cleared after activation → gate returns -1 → falls back tolegacyEventID(safe: all other writers would also be in legacy mode). - Inline query merge:
addInlineQueryallocates from sameNextEventID, stores in process-local map.getEvents(robot-side,:1085-1120) merges with Redis queue results and sorts byEventID. Same monotonic source → correct merge order.
Blind-spot Checklist (C1–C6, security_sensitive)
- C1 — Dual-path parity: N/A. No symmetric add/remove or create/delete pairs in this PR.
- C2 — Control-flow ordering / nesting reuse: N/A. The allocator is called at discrete points; no nested/double-invocation risk. Lua scripts are Redis-atomic.
- C3 — Authorization boundary ≠ capability boundary: N/A. No new API endpoints.
NextEventIDis an internal function;robotIDis always from authenticated context. - C4 — Authorization lifecycle / container-member state cascade: N/A. No auth changes.
- C5 — Build/note pass ≠ runtime correctness: N/A. No build artifacts, browser extensions, or packaging changes.
.gitignoreadditions are standard binary exclusions. - C6 — Governance/policy/doc self-consistency: Clear.
.octospec/tasks/bot-event-score-monotonic/brief.mdaccurately documents the design rationale, activation procedure, and co-recovery argument. Consistent with implementation.
Cross-round Blocker Recheck (R6)
N/A — first review of this PR.
Verdict
The design correctly addresses the root cause of #697 (per-process HiLo blocks producing collisions and ordering inversions across replicas). The activation gate eliminates the mixed-source window, Lua atomicity prevents mode-check races, seed idempotency allows any deployment order, and bounded timeouts protect the msgSem fan-out. All production call sites are migrated, guard tests lock in the fix, and pre-activation deployments are byte-identical to the old binary. The two P2 notes are maintainability/operational-hygiene observations, not correctness concerns.
[Octo-Q] verdict: APPROVE — No P0/P1 findings. Design is sound, implementation is careful, deployment story is safe. Two P2 notes for future consideration.
yujiawei
left a comment
There was a problem hiding this comment.
Code Review — PR #702 (octo-server)
Reviewed at head SHA f782959699add7244709aa48cb32a6aeaefe0d6f, merge-base 40627cc0. Note this is one commit past the SHA this review was originally requested for (aedde27a); the branch moved twice while the review was in progress. I re-ran everything against the current head and state below exactly what the two new commits fixed.
I read the full diff, the task brief committed in this PR, octo-lib@79f78844bfab (config/seq.go, config/context.go, pkg/redis/redis.go), the consumer in modules/bot_api/events.go, and .github/workflows/ci.yml. I ran the source guards, go build ./..., go vet, and the pkg/botevent suite locally.
The diagnosis remains excellent and the direction is right: GenSeq is a per-process HiLo allocator and genuinely cannot satisfy an exclusive-cursor sorted set. The activation gate is the correct shape, and the reasoning density in pkg/botevent/seq.go is unusually high. Two blockers from the previous round are now genuinely fixed. The rest still stand, and this round adds three mechanisms that were not previously on the record — one of which changes what "lossless" can mean here.
0. What changed since the last review round
Fixed, verified:
-
The committed 42 MB binary is gone — properly. Not just deleted at the tip:
botevent-seqappears in no commit of the current branch history (git rev-list 40627cc0..HEAD×git cat-file→ absent at all six commits), so the blob will not enter this public repository's permanent history on merge..gitignorenow also covers/botevent-seq,/msgextra-version,/card-action-dlq,/genseq-repro,/i18nmarkers. This was the right way to fix it. -
The raw-redis-client guard failure is fixed.
tools/genseq-repro/main.gonow routes throughoctoredis.NewInstrumentedClientvia a newnewRedis()helper (tools/genseq-repro/main.go:48-56), and threadsRedisAddrinto the config. Verified locally at this head:ok github.com/Mininglamp-OSS/octo-server/pkg/redis 0.157splus
go build ./...andgo vet ./pkg/botevent/... ./tools/...clean. The comment added at:50-53explaining why the tool is in scope for the guard is a good addition.
Everything else below is unchanged code. pkg/botevent/, tools/botevent-seq/, modules/, and .octospec/ are byte-identical to the previously reviewed head — I diffed them explicitly rather than assuming.
1. Spec compliance
Spec: ❌
The linked brief (.octospec/tasks/bot-event-score-monotonic/brief.md, added in this PR) is the spec. Most of it is met — the six producers are migrated, addInlineQuery correctly shares the id source, the floor-bootstrap and idempotence criteria have tests. Three acceptance items are still not implemented.
Missing (漏建)
-
The write-side non-monotonicity counter does not exist.
brief.md:182Acceptance: "新增「score 非单调」计数器(写入时新 score ≤ 队列当前 max 即计数),低基数 label;上线后该计数应恒为 0". Re-verified at this head:grep -rn "prometheus\|metric" pkg/botevent/returns exactly one hit, a comment inbell.go:113. The only counter added isseedFailures(pkg/botevent/seq.go:243), which is a different signal and is explicitly not wired to Prometheus (seq.go:239-243).This is not bookkeeping. It is the only runtime detector for §2.1 below: a counter that resumes below client cursors produces no error, no log, and no failed enqueue.
preflightcannot see it either — it counts duplicate scores, and this failure mode creates no duplicates. Without this metric the failure is silent by construction. -
The migration-verification runbook is not executable as specified.
brief.md:184-185: "切换前记录各队列 max score 与seq表min_seq,切换后断言新号段整体高于两者".tools/botevent-seqnever touches MySQL at all —grep -n "DB()\|min_seq\|MySQL\|NewContext" tools/botevent-seq/main.goreturns nothing, so half the required precondition cannot be recorded. It also printsmaxScoreonly for queues that already have duplicates (tools/botevent-seq/main.go:113-116), and defaults to-sample 20(main.go:34) against ~1948 queues, so "各队列" is not covered unless the operator knows to pass-sample 0. The PR body's Rollout section inherits the same gap. -
./modules/messageregression not reported.brief.md:180namesgo test -race ./modules/bot_api ./modules/robot ./pkg/botevent ./modules/message -count=1. The PR body reportsmodules/robot,modules/group,modules/bot_api—modules/messageis absent, and it owns thecard_actionD4 idempotency claim the brief calls load-bearing.
Deviation (偏离)
-
brief.mdD1 specifies the counter key asbotevent:seq:{robotID}; the implementation usesbotEventSeq:{robotID}(seq.go:103). Cosmetic alone, but see §3.7 — the brief's spelling would not have collided with the mode key. -
"Queue keys consolidated onto
botevent.QueueKey" is only 4/5 true. The PR body states the keys were consolidated "so the seed reads the exact key producers write." Four writers do (modules/robot/api.go:271,:335,modules/group/service.go:2082,modules/group/api.go:1982). The highest-volume writer does not:modules/robot/event.go:367still buildsfmt.Sprintf("%s%s", rb.robotEventPrefix, robotID), and the entire consumer side keeps its own constant (modules/bot_api/bot_api.go:32, used atevents.go:131,:250,:323). Functionally identical today because both literals are"robotEvent:", but the drift this consolidation exists to prevent is still possible in exactly the two places that matter most.
No over-build found beyond item 5's overstatement — the binary that was previously the 超建 finding is gone.
2. Code quality
Quality: Changes-Requested
P0
2.1 — After activation, a missing or rolled-back mode key falls open to GenSeq, which is the same data loss mirrored.
seq.go:300-306 (seeded fast path) and seq.go:330-332 (unseeded path):
if v >= 0 { return v, nil }
// Activated, then not: ... Legacy is the safe answer —
// it is what every other writer would now be doing too.
return legacyEventID(ctx, robotID)
Legacy is not the safe answer. Once the counter has issued ids, every counter-era id sits above what GenSeq will hand out next (min_seq was never advanced by the counter), so resuming from GenSeq puts new ids below every live consumer cursor — permanently invisible, which is precisely #697. This also contradicts the file's own stated principle at seq.go:53-64 ("It must NOT quietly reach for GenSeq").
The mode key is a plain Redis SET with no TTL, no epoch, and no durable record (tools/botevent-seq/main.go:131), in an instance running appendonly no. So the trigger is not exotic: a partial RDB restore, a snapshot predating activation, a stray DEL, or a FLUSHDB all produce it, and the process stays up throughout.
The RDB co-recovery argument at seq.go:34-47 is correct about collisions and silent about cursors. Counter and queue rewind together, so ids cannot collide with survivors — but bot cursors are entirely client-supplied (modules/bot_api/events.go:77, req.EventID) and never persisted server-side, so they do not rewind. Snapshot at T0 with counter 5000; ids 5001–5100 are issued, delivered and acked; Redis restores T0; the next 100 events are born at 5001–5100, below the client's cursor 5100, and ZRANGEBYSCORE key (5100 +inf excludes all of them until Robot.MessageExpire (7 days, octo-lib config/config.go:439) reaps them.
The repo already contains the shape that solves this: internal/msgextraseq keeps activation state in a MySQL row with an epoch, validates the cutover floor and refuses with ErrFloorTooLow (internal/msgextraseq/activation.go:181-196), and carries a fail-closed expected-mode env guard (store.go:96-116). seq.go:78 describes this design as a "DB-authoritative-style state flag", but what is implemented is a bare Redis key with none of those three properties.
This was independently raised in the earlier review round and I confirmed the mechanism against the code again at this head. It blocks.
2.2 — gateSource has no EXISTS guard on the counter, and the "already seeded" invariant is held in process memory.
seq.go:177-179:
if redis.call('GET', KEYS[1]) ~= ARGV[1] then return -1 end
return redis.call('INCR', KEYS[2])
INCR on a missing key treats it as 0 and returns 1. The only thing preventing an unseeded INCR is seeded, a process-local sync.Map (seq.go:237) consulted at seq.go:295.
So: the mode key survives, the counter key does not, and the Go process stays up. seeded.Load(robotID) is still true, seedCounter is skipped entirely, runGate returns 1, and every subsequent event for that bot is numbered from 1 — arbitrarily far below its client's cursor. This is strictly worse than 2.1's rollback: there the counter self-heals after one snapshot window; here recovery requires re-issuing the bot's entire historical id range before a single event becomes visible.
The code states this exact invariant at seq.go:317-319 ("Seed before the first INCR, never after") but enforces it with process state guarding Redis state. Note the maxmemory 0 / noeviction argument at seq.go:49-51 rules out eviction only — it says nothing about partial restore, DEL, or FLUSHDB.
Targeted fix, one extra EXISTS on a path that is already one round trip:
if redis.call('GET', KEYS[1]) ~= ARGV[1] then return -1 end
if redis.call('EXISTS', KEYS[2]) == 0 then return -2 end
return redis.call('INCR', KEYS[2])
and on -2, drop robotID from seeded and re-seed before retrying.
P1
2.3 — Ids are monotonic in allocation order, but the queue becomes visible in ZADD order, and the exclusive cursor follows visibility. This residual is not addressed and not disclosed.
Allocation and publication are two separate Redis operations at every producer, e.g. modules/robot/event.go:357 then :368:
seq, err := botevent.NextEventID(rb.ctx, robotID) // INCR -> N
...
err = rb.ctx.GetRedisConn().ZAdd(key, float64(seq), messageUpdateJson) // publish N
Interleaving: producer A allocates N and stalls (JSON marshal, GC, a slow ZADD round trip). Producer B allocates N+1, ZADDs, and rings the doorbell (modules/robot/event.go:379, modules/robot/api.go:286). The long-poll consumer added in #685 wakes immediately, reads (cursor +inf (modules/bot_api/events.go:253), receives N+1, and advances its cursor to N+1 (events.go:239-243). A's ZADD then lands at score N. Every subsequent read is (N+1 +inf — A's event is permanently unreachable.
What this PR does fix is real and large: colliding scores, and the multi-day inversions that came from block allocation across restarts. What it does not fix is this window, and the difference matters because:
- The doorbell makes it materially more likely than before, not less: the wake is triggered by the very ZADD that creates the inversion. The justification at
modules/robot/api.go:280-284("After the ZADD, so a waiter is never woken toward an event the queue does not have yet") reasons only about the ringing producer's own event, not about another producer's in-flight lower id. - The PR body and brief present the exclusive cursor as becoming lossless once ids are monotonic.
modules/bot_api/events.go:186-199is more careful — it says uniqueness is the assumption and notes that closing the gap "needs a Redis-side allocator or a re-delivery window below the cursor". This PR delivers the allocator, which closes the collision half; the reordering half needs the re-delivery window (or a small lookback below the cursor) and is still open.
I am flagging this as a residual to be stated and scoped, not as a reason to abandon the design. But it should not ship described as lossless.
2.4 — The test named as proof of losslessness cannot observe 2.3.
pkg/botevent/seq_test.go:370-430. Two concurrent producers, then:
wg.Wait() // :397 — all production finished
...
page, err := client.ZRangeByScore(QueueKey(robotID), rd.ZRangeBy{
Min: fmt.Sprintf("(%d", cursor), Max: "+inf", Count: 1,
}).Result() // :409 — consumption starts here
Consumption begins only after every ZADD has landed, so the test asserts that a quiesced sorted set with unique monotonic scores can be drained by an exclusive cursor — very nearly a tautology about ZRANGEBYSCORE. It cannot fail for the reason 2.3 describes. The concurrency in the producers exercises the allocator's uniqueness (valuable, and ZCard at :399-403 is a real assertion), but the test's name and the PR's COMPREHENSION §3 both claim it "drives the real read shape" as an end-to-end losslessness proof, which overstates what it covers. A version that consumes concurrently with production, with an injected pause between one producer's INCR and its ZADD, would fail today.
2.5 — All nine behavioural tests silently skip, in CI and locally.
seq_test.go:32 points at a botevent_test database. The CI Test job provisions and recreates only test (ci.yml:276); nothing anywhere creates botevent_test (grep -n "botevent_test" .github/workflows/ci.yml → no match). So ctx.DB().Exec("CREATE TABLE IF NOT EXISTS seq …") fails and seq_test.go:60 takes t.Skipf. Observed at this head:
--- SKIP: TestNotActivatedDelegatesToLegacy
--- SKIP: TestActivationTakesEffectWithoutAProcessCacheWindow
--- SKIP: TestSeedClearsTheLegacyCeilingAtActivation
--- SKIP: TestNextEventIDIsStrictlyMonotonicUnderConcurrency
--- SKIP: TestSeedRaisesAboveQueueCeiling
--- SKIP: TestSeedRaisesAboveLegacySeqRowWithEmptyQueue
--- SKIP: TestSeedIsIdempotentAndNeverLowers
--- SKIP: TestNextEventIDFailsClosedWhenSeedFails
--- SKIP: TestExclusiveCursorIsLosslessWithMonotonicIDs
ok github.com/Mininglamp-OSS/octo-server/pkg/botevent
Every test carrying the safety argument reports ok while running nothing. The tests are good; they just never execute where it matters, so nothing prevents a regression from landing. The dedicated-database rationale at seq_test.go:22-31 is sound, and the repo already has the pattern that satisfies both constraints: modules/message/issue557_creator_thread_e2e_test.go creates its own isolated database from the CI job's MySQL (see the note at ci.yml:253-255). A CREATE DATABASE IF NOT EXISTS botevent_test from the fixture would make these run without touching the shared migration ledger.
2.6 — Merging is semantically neutral but not I/O-neutral, and the rollout's load-test gate is therefore in the wrong place.
Pre-activation, seeded is never populated (nothing stores into it on the legacy path), so every allocation reaches seq.go:309:
mode, err := client.Get(ModeKey).Result()
if err != nil && err != rd.Nil { return 0, fmt.Errorf("botevent: read allocator mode: %w", err) }
if mode != ModeIncr { return legacyEventID(ctx, robotID) }
That is one Redis round trip per bot event, on the hottest producer path, inside a held msgSem slot — added at deploy time, before any operator action. GenSeq served 999 of every 1000 allocations with no I/O at all, so the PR body's "Merging is behaviour-neutral" and COMPREHENSION §1's "the deploy is a no-op" are true of the ids and false of the I/O profile and of the failure surface. It also arrives on the allocator's own separate pool and timeout budget (seq.go:223-228), so a degraded Redis can fail this GET — and thus drop an enqueue — in situations where the old binary would have allocated from a process-local block.
Consequence for the plan: Rollout step 3 ("Load-test the hottest producer", positioned between deploy and activation) tests a system that is already carrying the new per-event round trip. The load test needs to happen before the deploy reaches production, or the deploy itself is the experiment.
To be explicit about what I am not asking for: caching the mode is genuinely unsafe, for exactly the reason seq.go:169-176 gives. The fix here is to correct the claim and re-sequence the load test, not to add a cache.
2.7 — seedSafetyMargin = 2 × step rests on an argument that does not hold.
seq.go:127-136 says two steps "covers one reserved block plus one such regression." min_seq regression is not bounded by one step. In octo-lib config/seq.go each replica writes back min_seq = its own CurSeq + seqStep under an unconditional ON DUPLICATE KEY UPDATE, so a replica lagging N blocks writes a value N blocks below the highest id ever issued. This PR's own production data — 19 inversions on block boundaries, 6.7-day maximum time regression — is direct evidence that replicas drift many blocks apart.
The queue ceiling normally covers the gap, but not for the bots most exposed: Robot.MessageExpire defaults to 7 days (octo-lib config/config.go:439) and is refreshed on every enqueue, so a bot idle for a week has an empty queue and queueCeiling returns 0 (seq.go:403-405). For those bots the seed rests on min_seq + 2000 alone, against a client cursor equal to the highest id ever delivered.
The "a larger margin makes it worse" reasoning at seq.go:74-76 is about the un-gated immediate-switch design, where legacy was still issuing from below. Once the gate guarantees no legacy writer, a much larger margin is strictly safer and costs nothing — ids are int64 and exact in float64 below 2^53.
2.8 — Inline-query ids are consumed from the shared counter but leave no durable trace, so queueCeiling is structurally blind to them.
addInlineQuery (modules/robot/api.go:1017-1038) allocates from the shared counter and writes only to inlineQueryEventsMap, a plain in-process map (api.go:349, written at :1036, read at :1094). It never ZAdds. queueCeiling (seq.go:398-407) reads only the sorted set, and nothing writes these ids to MySQL.
Sharing the allocator here is still correct — the comment at api.go:1005-1016 argues that convincingly, and recording why the first attempt was wrong is the right call. But it means ids that clients have genuinely observed exist nowhere durable, so the seed's central assumption (that max(queue ceiling, legacy seq row) bounds everything ever issued) has a hole the width of inline-query traffic. A plain process restart is enough to expose it post-activation; no Redis crash needed. Cheapest fix: have addInlineQuery bump a per-bot high-water key that seedCounter reads alongside the queue ceiling.
2.9 — The activation drain window is documented only where the operator will never see it.
seq.go:81-83 states the residual honestly: "a request that has already read legacy and not yet ZADDed when the flip commits; drain writes for a few seconds around the flip to close it." That mitigation appears nowhere else. tools/botevent-seq/main.go:135 prints "activated. Every replica switches on its next allocation." with no mention of draining, and Rollout step 4 goes straight from "confirm no pre-fix replica" to -action activate -yes. The unseeded path is where the window lives: seq.go:309 reads the mode with a plain GET outside the gate script, so between that read and the caller's ZADD the flip can commit and a legacy-numbered id can land in a counter-era queue. Put it in the tool's pre-flip output and in the Rollout section.
P2
2.10 — ModeLegacy is dead and its docstring is false. seq.go:113-115 says it is "written explicitly by the operator tool so preflight can tell 'not yet activated' from 'never configured'". grep -rn "botevent.ModeLegacy" across the repo returns nothing; tools/botevent-seq only ever writes ModeIncr (main.go:132) and currentMode reports "(unset — legacy)" for a missing key. Either write it or delete the constant and the claim.
2.11 — ModeKey lives inside the per-bot counter namespace. SeqKeyPrefix = "botEventSeq:" (seq.go:103) and ModeKey = "botEventSeq:mode" (seq.go:108), so SeqKey("mode") == ModeKey. Not reachable today — bot ids always carry the _bot suffix (modules/botfather/const.go:20) — but the invariant lives in another module, and the docstring at seq.go:100-102 reasons only about robotEvent: and robotEventBell:, not about the mode key inside its own prefix. If it ever became reachable, seedSource's tonumber(cur) < floor would evaluate nil < number and error on every allocation for that bot. The brief's botevent:seq:{robotID} spelling avoids this for free.
2.12 — The latency bound is understated and the expensive path is not the one analysed. seq.go:216-228 reasons about the steady state: one round trip, "~1s worst case". With MaxRetries = 1, DialTimeout 500ms, ReadTimeout 300ms, PoolTimeout 200ms plus go-redis retry backoff, two attempts land closer to ~1.8–2.3s. More importantly the unseeded path — taken once per bot per process, i.e. for every bot after every rollout — does GET mode + ZREVRANGE + a MySQL SELECT (legacyCeiling, seq.go:418-419, with no context and no timeout) + seed EVALSHA + gate EVALSHA, all inside the same held msgSem slot (cap 100) the bound exists to protect. A stalled MySQL there blocks with no deadline at all. This belongs in the analysis and in the load test.
2.13 — seedCounter has no de-duplication. At activation every concurrent enqueue for a not-yet-seeded bot independently runs queueCeiling (Redis) + legacyCeiling (MySQL, untimeouted) + seedSource. Across ~1948 bots at cutover that is a correlated spike of MySQL selects on the msgSem-held path. A singleflight.Group keyed by robotID around seq.go:320-324 removes it, and also removes the redundant repeated +2000 jumps from overlapping seeds (see 2.15).
2.14 — legacyCeiling's key format is only asserted against itself. seq.go:417 builds "seq:" + common.RobotEventSeqKey + robotID. I verified this matches octo-lib config/seq.go's fmt.Sprintf("seq:%s", flag), so it is correct today. But no test pins it: TestSeedRaisesAboveLegacySeqRowWithEmptyQueue hand-writes the same literal in its INSERT (seq_test.go:271), and TestSeedClearsTheLegacyCeilingAtActivation would still pass with legacyCeiling hard-wired to 0. If octo-lib changes the key, legacyCeiling silently returns 0 for every bot — losing exactly the drained-queue protection 2.7 depends on — and the suite stays green. Driving the fixture through ctx.GenSeq(common.RobotEventSeqKey + robotID) would close it.
2.15 — Every new process bumps the counter by 2000 on first touch of a bot. seedCounter adds the margin whenever floor > 0 (seq.go:388-393), and floor includes queueCeiling. Post-activation, a fresh pod's first allocation for a bot computes queueMax + 2000. Harmless — forward jumps never land below a cursor — but the margin is a recurring behaviour, not the "first-time seed" the docstring describes, and it makes the counter= value preflight prints unreliable as a volume signal.
2.16 — The GenSeq guard is evadable by a routine refactor. genseq_guard_test.go:31 uses GenSeq\([^)]*(RobotEventSeqKey|"robotEventSeq:), matched line by line. This passes cleanly:
key := common.RobotEventSeqKey + robotID
seq, err := ctx.GenSeq(key)
TestGenSeqGuardWouldCatchAReintroduction only exercises three inline spellings, so it gives false confidence about the escape. A strictly tighter guard is available: common.RobotEventSeqKey appears in exactly one non-test production file today, so asserting the symbol appears nowhere outside the allowlist catches both the inline and the hoisted form. (The allowlisted-file-still-contains-the-delegation assertion at :78-87 is a genuinely good idea and should stay.)
2.17 — robotID is trimmed inside the allocator but not at the caller's ZADD. NextEventID does strings.TrimSpace(robotID) (seq.go:279) and derives SeqKey, QueueKey (for the ceiling read) and the seeded entry from the trimmed value, but returns only the id. Callers then use the untrimmed value for the queue key, e.g. modules/robot/api.go:271 key := botevent.QueueKey(robotID). A robotID with surrounding whitespace would allocate against one bot's counter and enqueue into a different key, breaking both the ceiling logic and co-location. Not reachable through today's call sites (ids come from authenticated context / member UIDs), so P2 — but the asymmetry is free to remove: either trim once at the boundary and return the canonical id, or don't trim at all.
2.18 — Numeric upper bound (informational). seedSource passes a Lua number to redis.call('SET', …) (seq.go:158-161), which Redis formats with %.17g. Above ~1e17 that produces scientific notation (1e+17), which INCR then rejects as a non-integer, wedging allocation for that bot permanently; above 2^53 the float64 ZSET score at each producer stops distinguishing adjacent ids, recreating shared scores. Both are unreachable at realistic magnitudes (min_seq in the 1e5–1e7 range plus 2000 per seed), so this is a note rather than a defect — but it is a cliff rather than a graceful degradation, so it is worth a comment.
Verified negatives — please don't spend a cycle re-litigating these
legacyCeilingdoes not need to fail on a missingseqrow. This has now been raised across multiple rounds.seq.go:419calls.Load(&minSeq), notLoadOne.dbrv2.7.5 returnsErrNotFoundfromLoadOneonly;load.go'sfor rows.Next()loop leavescountat 0 for zero rows and returns(0, nil).octo-lib's ownquerySeqWithKeydepends on exactly this (it callsLoadthen testsif seqM == nil). SolegacyCeilingreturns(0, nil), which is the documented intent atseq.go:388-391. No change needed.- Redis topology does not break co-location or the two-key gate script. I checked for this specifically because a multi-key
EVALacrossbotEventSeq:modeandbotEventSeq:<id>wouldCROSSSLOT-fail on every allocation in cluster mode.grep -rn "NewClusterClient\|NewRing\|NewFailoverClient"across octo-lib returns nothing; bothoctoredis.BuildOptions(pkg/redis/options.go:25-28) and octo-lib'sNewRedisCache(config/context.go:107-110) build a single-noderd.NewClientfrom the samecfg.DB.RedisAddrwithDBunset. So co-location holds and the script is safe — by omission rather than by assertion, which is why a futuredbor cluster config field would break it silently. Worth a guard or at least a comment.
What is good, and worth keeping exactly as-is
- The activation gate reading the mode inside the allocation script (
gateSource,seq.go:177-179) with a deliberate refusal to cache it. The reasoning atseq.go:169-176is correct, and the reason it must not be "optimised" later is recorded where the next person will find it. - Routing
addInlineQuerythrough the same allocator, and documenting why the first attempt was wrong (modules/robot/api.go:1005-1016). That correction usually gets silently reverted. TestNoGenSeqForBotEventIDsasserting that the allowlisted delegation still exists, so the legacy branch cannot be deleted as dead code.tools/genseq-reproas the failing mirror of the new tests, and the observation that a whole-queue read in one pass loses nothing — that is what makes the intermittency legible.- The way the binary was removed: rewritten out of branch history, not just deleted at the tip.
3. Overall verdict
CHANGES_REQUESTED
Spec ❌ (§1.1–§1.3) and Quality Changes-Requested (§2.1, §2.2). Credit where due: the two items that made the last round hardest to land — the red guard and the 42 MB blob — are properly fixed, and the blob was removed the right way.
Minimum to clear, in the order I'd do it:
- §2.1 — stop treating a missing mode key as "legacy" once this process has allocated from the counter; return an error instead. Pair with a fail-closed expected-mode assertion in the shape
internal/msgextraseq/store.go:96-116already uses. - §2.2 — add the
EXISTSguard togateSourceand re-seed on-2. - §1.1 — add the write-side non-monotonicity counter. It is the only detector for both of the above.
- §2.5 — make the
pkg/boteventtests actually run in CI (CREATE DATABASE IF NOT EXISTS botevent_testfrom the fixture). - §2.3 / §2.4 — either add the re-delivery window / cursor lookback, or scope the claim: say plainly that this closes score collisions and leaves the allocate-then-publish reordering window open, and rename the test to what it actually proves.
- §2.6 — correct "behaviour-neutral" to "id-neutral, not I/O-neutral", and move the load test ahead of the production deploy.
4. Suggested direction
For §2.1 and §2.7 together, the durable high-water mark is the change that retires both: record the largest id ever handed out per bot outside the RDB domain, written on a coarse interval (every Nth allocation, not every one) so MySQL stays off the hot path. That gives the seed a floor that survives a rollback and replaces the 2 × step guess with a real bound. §2.8's inline-query high-water key composes with it directly.
For §2.3, the cheapest honest option is a bounded lookback: read from (cursor - K instead of (cursor and let the client's own idempotency (which D8 already requires for card_action) absorb the re-delivery. That trades a little duplicate traffic for closing the window, and it is the "re-delivery window below the cursor" that modules/bot_api/events.go:196-199 already names as the alternative.
For §2.6, if the pre-activation GET turns out to cost too much under load, the option that does not reintroduce the mixed-source defect is to gate on a process-start read of an expected-mode env var (fail-closed if it disagrees with Redis) rather than a per-allocation read — i.e. accept a restart as the unit of activation instead of a flip. That is a design change, not a tweak, so it belongs in the discussion rather than this round's fix list.
5. Additional notes and coverage gaps
Things a human should verify that I could not, and what this review did not cover. This PR is labelled needs-human-review; these are the items I would actually want a person to check.
- The production Redis configuration is quoted, not verified.
appendonly no,save 3600 1 300 100 60 10000,maxmemory 0/noeviction, single master with 0 replicas — the whole durability argument rests on these. Please confirm against the live instance, and confirm nothing in the environment can promote a replica or point either client at a differentdb. - The load test in Rollout step 3 has not been run, and per §2.6 it needs to move earlier. The I/O shape change is real and stated plainly by the author; this is the item I would least want to discover in production.
- §2.3 needs an empirical answer, not just a design one. The window's width in production is a function of ZADD latency on the hottest bot. Someone should measure it before deciding whether the lookback is required or merely prudent.
- I could not execute the nine behavioural tests — I verified they skip (both here and structurally in CI), not that they pass when the database exists. Their assertions read correct to me, but they are unproven at this head.
tools/genseq-reprois never executed by CI (it only has to compile), so the demonstration that the old allocator loses events can rot silently.ackEventprecision is not tested here. The brief lists "ackEvent能按event_id精确删到目标 member" as acceptance; the consumer is unchanged, so this is pre-existing coverage rather than a regression, but nothing in this PR pins it.- Whether both
notifyBotJoinedGrouphelpers can fire for one membership addition.modules/group/api.go:1964andmodules/group/service.go:2065are near-duplicates that now both allocate. I did not trace every caller to rule out double-enqueue; the ids would be distinct so it is not a loss, but it may be a duplicate event. - Not covered: real bot clients' cursor persistence across a 7-day idle window (unobservable server-side, and §2.7's blast radius depends on it); whether the ~1948 production queues include bots whose
min_seqhas regressed far enough to matter; and the queue-sideDelsites (modules/robot/api_manager.go:374,modules/botfather/command.go:624,:761,:792,modules/botfather/api_user.go:472) which delete the queue but correctly leave the counter — worth confirming that stays deliberate, since deleting the counter there would reintroduce a below-cursor seed on the next allocation. - Round count. This is the second formal review round, and the head advanced twice during this one. The remaining blockers are concentrated in one file and one design question (durable activation state), so I don't think this needs an escalation yet — but if a third round does not close §2.1/§2.2, I'd stop iterating and settle the durable-high-water design first rather than patching the Redis-key approach again.
Addendum to my review above — two further findings, same head SHA (
|
…er (Mininglamp-OSS#697) Closes the review finding that the co-recovery argument proves uniqueness but not monotonicity relative to consumer cursors. Cursors are external state: a bot that read up to 49900 still holds 49900 after an RDB-loss restart drops the counter to 49000, so ids 49001..49900 are re-issued below a live cursor and are permanently invisible -- Mininglamp-OSS#697 re-inflicted by a crash, and on that axis strictly worse than GenSeq, whose min_seq lives in MySQL and does not regress when Redis does. Three mechanisms, none sufficient alone: - A durable high-water mark in MySQL (`seq` row `botEventHigh:{robotID}`), advanced about once per 1000 ids and folded into every seed's floor. It does not share Redis's recovery domain. The write uses GREATEST(min_seq, VALUES(min_seq)) -- an unconditional overwrite is exactly how GenSeq lets a lagging replica drag a floor backwards, which is the root cause being fixed here. - Rollback detection in the issuing process. The durable mark only helps if something re-seeds, and a long-running replica would otherwise keep INCRing the regressed counter forever since its `seeded` entry is already set. Recovery is therefore automatic rather than a runbook step. - OCTO_BOTEVENT_EXPECTED_MODE. ModeKey also lives in Redis, so it regresses under the same rollback, and dropping to legacy then issues lower ids beneath live cursors -- the same loss mirrored. Set it to incr after activation is verified and a lost mode refuses the enqueue instead. Four problems the tests then caught, all mine: - Rollback detection needs a tolerance and a CAS. Concurrent callers do not observe INCR results in issue order, so comparing against a running maximum with no slack flagged ordinary concurrency as a regression; the concurrency test surfaced it as a 6000-wide gap across 200 allocations. One legacy block of slack sits far above any real in-flight count and far below a real rollback. - Concurrent first allocations each seeded. The losers ran after the winner had issued ids and advanced the mark, so their floor was computed from that mark and landed a margin above the live counter, burning a block per racing caller. Single-flighted per bot with a double check. - The mark recorded `v + interval` rather than `v`, which compounded with the seed margin on every consultation. - The gate sentinel was tested with `v < 0`, so a counter that had somehow gone negative would be read as "not activated" and silently degrade to legacy. It is now matched exactly, and a non-positive counter is an error. Tests move back to the standard `test` database. The dedicated one an earlier revision used is never created in CI, so every integration test here silently Skipped there -- indistinguishable from passing. CI drops and recreates `test` before every package, which is what makes creating the migration-owned `seq` table here safe. Verified the way CI runs it: -race -shuffle=on with a per-package database reset, green for pkg/botevent, pkg/redis, modules/robot, modules/group, modules/bot_api.
lml2468
left a comment
There was a problem hiding this comment.
PR Review (re-review) — PR #702 (Mininglamp-OSS/octo-server) — APPROVE (my blocker fixed)
Re-reviewed at new head 37f45fbdd57e (single forward commit fix(botevent): durable high-water and rollback recovery for the counter on my REQUEST_CHANGES'd f7829596). Delta: seq.go +367, seq_test.go +198. go build/go vet (botevent) exit 0 ✅; pure guard suite passes; the Redis+MySQL rollback/concurrency/high-water tests are env-gated → CI.
My blocker is fixed — the durability argument now proves monotonicity, not just uniqueness
The prior head asserted RDB-crash safety via co-recovery, which only established uniqueness; a rollback dropped the counter below surviving external client cursors and re-inflicted #697. This commit closes it with the two mechanisms it needed, and I byte-verified both:
- Durable high-water in MySQL (
seqrowbotEventHigh:{robotID}, disjoint from GenSeq's namespace). Advanced at most once perhighWaterInterval(=1000 ids) viapersistHighWater, and folded into every seed's floor inseedCounter(now three sources: queue max, legacy row, durable mark). Because it lives in MySQL it does not share Redis's recovery domain, so it survives an RDB rollback — exactly the property the queue max and the frozen legacy row lack. The write isON DUPLICATE KEY UPDATE min_seq = GREATEST(min_seq, VALUES(min_seq))and the mark isvnotv+interval— so it can never regress (the GenSeq unconditional-overwrite root cause is explicitly not repeated) and never compounds across seeds. Best-effort (a failed bookkeeping write can't fail the enqueue); a miss only shortens the trail, whichseedSafetyMargin(2×step) absorbs.TestHighWaterNeverMovesBackwardspins the GREATEST. - In-process rollback detection (
lastIssuedper bot). Every allocation runscheckRegression:v + rollbackTolerance <= high→ the counter regressed beneath a long-running (already-seeded) replica that would otherwise keep INCRing the regressed value forever. On detection it re-seeds from the durable floor and retries once; and if the retry is still<= prev, it refuses with an error rather than issue an id below a cursor a client can hold — fail-closed, no silent below-cursor loss.recordIssuedadvances the per-process max via CAS (concurrent out-of-order arrivals can't overwrite the maximum and manufacture a false regression).TestCounterRollbackIsDetectedAndHealeddrives a real regression and asserts detection + above-prior recovery. rollbackTolerance(=1000) is justified, not a fudge. ConcurrentINCRcallers don't observe results in issue order, so a zero-slack check flags ordinary concurrency as a regression — an earlier revision did exactly that and the concurrency test caught a 6000-wide gap. One legacy block is far above any real in-flight count (msgSem ≤100) and far below a real rollback (60–300 s of a live queue).TestNextEventIDIsStrictlyMonotonicUnderConcurrencynow also assertsrollbacksDetected==0under pure concurrency — the no-false-positive guard.- Mode-rollback mirror loss closed too.
ModeKeyalso regresses under RDB rollback, and dropping to legacy then issues lower GenSeq ids beneath live cursors.ExpectedModeEnv(OCTO_BOTEVENT_EXPECTED_MODE, same shape as #627's expected-mode) makesmodeLostfail closed when set toincrinstead of silently degrading.runGateis also hardened to reject a zero/negative counter (corrupted restore / manual SET) instead of mistaking it for the-1not-activated sentinel (now matched exactly viagateNotActivated).
The top-of-file comment is corrected to state plainly that co-recovery buys uniqueness but not monotonicity, and why external cursors force a durable mark.
Residuals (non-blocking, documented)
- Sub-
rollbackToleranceregressions (<1000 ids lost) on a live already-seeded replica aren't flagged, so up to ~1000 events for a bot whose cursor sat near the top could be temporarily invisible until the next seed floors to the durable mark. This is the deliberate cost of the concurrency-false-positive tolerance, explicitly documented, bounded, and vastly smaller than the continuous cross-replica loss this PR removes. Acceptable. persistHighWaterdoes a DB write ~1/1000 allocations on thesaveRobotMessage/msgSem path — best-effort and failure-tolerant, so it can't fail the enqueue, and the amortised cost is negligible against the per-allocation Redis round trip. Fine; worth an eye during the pre-activation load test the PR already mandates.ExpectedModeEnvmust be rolled out toincronly after activation is verified (a replica expectingincrbefore the flip fails every enqueue closed) — an operational step, correctly documented.
Verdict
APPROVE (flipping my f7829596 REQUEST_CHANGES) — the durability gap I blocked on is genuinely closed: a durable MySQL high-water mark that survives RDB rollback is folded into every seed floor, in-process rollback detection re-seeds a long-running replica and refuses to issue below a known-held cursor rather than lose events silently, the rollbackTolerance is sized to separate real regressions from concurrency, and the mirror-image mode-rollback path fails closed behind an expected-mode env. The co-recovery claim is corrected to its true scope. Everything I verified before stands (all-producers-on-NextEventID + the repo-wide GenSeq guard, atomic gate with no mode-cache window, score==event_id equality, seed-before-INCR/fail-closed, msgSem timeout budget), and the earlier 42 MB binary is gone with .gitignore. Both gates pass; merge is still behaviour-neutral until activation. Thanks to yujiawei/Steve/Jerry-Xin for corroborating the durability finding — the fix is thorough and test-locked.
Suggested decision: Safe to merge (gated, neutral) once CI runs the Redis+MySQL rollback/concurrency/high-water suites green. At activation: roll OCTO_BOTEVENT_EXPECTED_MODE=incr only after verifying the flip, load-test the hottest producer (now carrying the amortised high-water write), and watch RollbacksDetected. merge: MERGEABLE.
Jerry-Xin
left a comment
There was a problem hiding this comment.
One-line summary: The PR is in scope and the allocator design is mostly careful, but the Redis key namespace has a control/data collision that can break allocation for a valid bot ID.
🔴 Blocking
- 🔴 Critical:
ModeKeycollides with a legal per-bot counter key.
In pkg/botevent/seq.go:129, counters useSeqKeyPrefix = "botEventSeq:"; in pkg/botevent/seq.go:136, the global mode key isbotEventSeq:mode; and pkg/botevent/seq.go:211 makesSeqKey("mode")exactly equal toModeKey. I did not find a robot ID constraint that makesmodeimpossible. After activation, a bot withrobotID == "mode"would try to seed or increment the allocator mode key as its counter, causing allocation failure and coupling one bot’s data path to the global activation gate. Put the mode key outside the per-bot namespace, or change counters to a non-overlapping namespace such asbotEventSeq:counter:{robotID}, and add a regression test assertingSeqKey("mode") != ModeKey.
💬 Non-blocking
- 🟡 Warning: pkg/botevent/seq.go:570 silently drops durable high-water write failures. The rollback-recovery story depends on that mark; at minimum this should be logged/counted so operators know the Redis rollback safety margin has degraded.
- 🟡 Warning: tools/botevent-seq/main.go:34 defaults preflight to a 20-queue sample, but tools/botevent-seq/main.go:118 prints totals that the rollout text treats as validation. For activation verification, defaulting to all queues or labeling sampled output as non-authoritative would reduce operator error.
- 🔵 Suggestion: pkg/botevent/seq_test.go:45 creates the migration-owned
seqtable directly in the shared test DB. If CI truly recreates the DB per package this is fine, but it is fragile for the documentedgo test ./...workflow.
✅ Highlights
- The PR is relevant to octo-server: it fixes an existing bot event delivery path.
- The producer conversions consistently route through
botevent.NextEventID, including the bot mention Lua commit path viaPrepareBotTypedEvent. - The source guards are useful and I verified the narrow guard tests pass.
- I also verified compile-only runs for
modules/group,modules/robot,tools/botevent-seq, andtools/genseq-repro.
Superseded: re-posting with repo-relative paths (no local path leak).
Jerry-Xin
left a comment
There was a problem hiding this comment.
Summary: The PR is relevant to octo-server, but the activation gate still has a mixed-source window that can keep a replica on GenSeq after activation under a stale/forged future mirror value.
🔴 Blocking
🔴 Critical — Cached denial can swallow the real activation for the same mirror epoch
pkg/botevent/mode.go trusts a cached negative belief when the current mirror equals b.deniedMirror, and pkg/botevent/mode.go stores that denied mirror value after the authority says legacy. This breaks the “flip never waits for TTL when the mirror claims incr” property when the denied value later becomes the real activation mirror.
Concrete path:
- Before activation, Redis contains
botEventSeq:mode = incr:1from a stale/forged/manual write. - Authority is still legacy, so the allocator denies and caches
deniedMirror="incr:1"fornegativeBeliefTTL. - The operator activates from epoch 0 to epoch 1 in pkg/botevent/state.go, and
tools/botevent-seqwrites the exact same mirror valueincr:1at tools/botevent-seq/main.go. - Until the TTL expires, this process keeps returning
decideLegacybecause the mirror matches the cached denial, while other replicas can allocate from the counter.
That reintroduces two live id sources on one queue immediately after activation, which is the core loss mode this PR is meant to prevent. The denial cache needs to distinguish “denied against authority epoch X” from a future activation that can legitimately produce the same mirror value, or otherwise force an authority read when the mirror epoch is ahead of the last confirmed legacy epoch. Add a regression test where MirrorValue(1) is denied before Activate() advances epoch to 1.
💬 Non-blocking
🟡 Warning — The pre-activation path is not fully behavior-neutral in I/O shape. Even when delegating to GenSeq, NextEventID now performs a Redis probe before every allocation in pkg/botevent/seq.go. That is materially different from the old GenSeq hot path, especially for inline query events that previously did not need a Redis round trip for ID allocation. It may be acceptable, but the PR description should not say “exactly as before” without qualifying this.
✅ Highlights
The producer-side consolidation onto botevent.NextEventID / botevent.QueueKey is a strong direction, and the guards for GenSeq reintroduction and unrung queue writers are valuable. The MySQL authority plus generation-stamped Redis mirror is also the right architectural shape for avoiding a Redis-only activation state.
Re-posting with repo-relative paths (removed local absolute paths from review body); verdict unchanged.
Jerry-Xin
left a comment
There was a problem hiding this comment.
Summary: The PR is relevant to octo-server, and this revision byte-verifiably closes every 🔴 from the previous head (e90540e): the durable-mark bound now fails closed, the queue-key consolidation is complete, and existRobot fails open on lookup error. One new blocking issue remains in the reworked activation-gate cache: a denied mirror value can swallow the real activation for the same epoch.
🔴 Blocking
🔴 Critical — Cached denial can swallow the real activation for the same mirror epoch
pkg/botevent/mode.go:347 trusts a cached negative belief when the current mirror equals b.deniedMirror, and pkg/botevent/mode.go:438 stores that denied mirror value after the authority says legacy. This breaks the "the flip never waits for a TTL when the mirror claims incr" property when the denied value later becomes the real activation mirror.
Concrete path:
- Before activation, Redis contains
botEventSeq:mode = incr:1from a stale/forged/manual write. - Authority is still legacy, so the allocator denies and caches
deniedMirror="incr:1"fornegativeBeliefTTL(5s). - The operator activates from epoch 0 to epoch 1 (
pkg/botevent/state.goActivate), andtools/botevent-seq/main.gowriteMirror(client, epoch)writes the exact same mirror valueincr:1. - Until the TTL expires, this process keeps returning
decideLegacybecause the mirror matches the cached denial, while other replicas allocate from the counter.
That reintroduces two live id sources on one queue immediately after activation — the core loss mode this PR exists to prevent — for up to negativeBeliefTTL. The deniedMirror docstring (pkg/botevent/mode.go:143) claims "a different mirror value, including a genuine activation, is still a conflict"; but a genuine activation at epoch 1 produces the same string that was denied at epoch 0→1, so it is not treated as different. The denial cache needs to distinguish "denied against authority epoch X" from a future activation that can legitimately produce the same mirror value (e.g. key the denial on the observed authority epoch, or force an authority read when the mirror epoch is ahead of the last confirmed legacy epoch). Please add a regression test where MirrorValue(1) is denied before Activate() advances the epoch to 1, and assert the next allocation reads the authority rather than delegating to legacy.
✅ Verified fixed from the previous review round
- Jerry-Xin 🔴 P1-B (bound did not fail closed):
persistHighWaternow refuses atseedSafetyMarginmeasured as the span fromlastDurable(advances only on a landed write), andafterIssuepropagates that error as(0, err)so the enqueue fails — not 1-in-1000. The failure-counter and its non-atomic read-modify-write are gone. Recovery is bounded by a time-budget probe (highWaterProbeEvery), not by traffic. - yujiawei 🔴 (score-allocator / all ZADD via allocator): both source guards present;
chokepoint_guard_test.gonow also matches the"robotEvent:" + robotIDconcatenation spelling that the previous pattern missed, with a self-check test. - yujiawei 🔴 (QueueKey constant everywhere):
rb.robotEventPrefixand the bot_api-local constant are deleted;grep '"robotEvent:'outside tests is empty except the single authoritativeQueueKeyPrefixinpkg/botevent/seq.go. All producers and Del sites route throughbotevent.QueueKey. - yujiawei 🔴 (existRobot error dropping events):
modules/robot/event.gonow falls through to prior behaviour on a lookup error (robotID = candidate) and only rejects when the bot is known-not-to-exist.
💬 Non-blocking
🟡 The pre-activation path is not fully behavior-neutral in I/O shape. Even when delegating to GenSeq, NextEventID performs a Redis probe before every allocation (pkg/botevent/seq.go:605), materially different from the old GenSeq hot path — notably for inline-query events that previously needed no Redis round trip for id allocation. Likely acceptable, but the description should qualify "exactly as before".
✅ Highlights
Producer-side consolidation onto botevent.NextEventID / botevent.QueueKey, the GenSeq-reintroduction and unrung-writer guards, and the MySQL authority + generation-stamped Redis mirror are all the right architectural shape.
yujiawei
left a comment
There was a problem hiding this comment.
Code Review — PR #702 (octo-server)
Reviewed at head dacb706b239193641965b8c12a1bb3d93ab44f7c, merge base 40627cc0, 24 files / +4953 −90.
This is the sixth formal round on this PR (earlier heads: d34bdfb0, f7829596, 37f45fbd,
2f7e58da, e90540e2). I reviewed the incremental diff e90540e2..dacb706b against the four
findings that blocked the last round, then re-read the whole allocator. Findings were cross-checked
against two independent automated analysis passes; both ran, but one was degraded and I say where
(see Coverage — its silence on the allocator is absence, not clearance).
Real progress this round: the queue-key consolidation is genuinely finished, the existRobot
drop-on-error is gone, the denied-mirror read storm is capped, the cold-process refusal via
EXISTS botEventSeq:counter:{id} is a good and cheap answer to P1-A, and all five P2s from the last
round are closed. The blocking problems below are both in the machinery that was rewritten this
round, which is also the reason for the process note at the end.
1. Spec compliance
Spec: ❌
Missing
.octospec/tasks/bot-event-score-monotonic/brief.md:163-165still requires a source guard asserting
that everyrobotEvent:ZADDsite takes its score from the new allocator. The two guards that
ship assert (a) every queueZADDrings the doorbell (pkg/botevent/chokepoint_guard_test.go:35)
and (b) the legacy seq key is not named outsidepkg/botevent/seq.go
(pkg/botevent/genseq_guard_test.go:46). Neither constrains the score source: a writer scoring by
time.Now().UnixNano()passes both. Last round I said this could move to the follow-up issue
instead of being built here — it was neither built nor moved (#704's Acceptance section does not
mention it), so the brief now lists an acceptance criterion nothing tracks. Either add the guard or
add it to #704 and mark the brief item deferred; I do not care which.
Over-built
- Nothing. Every change in this revision maps to a finding from the last round.
Deviation — all four are documentation that no longer matches the code it describes, in a PR whose
description is the activation runbook:
pkg/botevent/seq.go:936: "Between the first failed attempt and the span bound there is a full
interval of grace, so a transient failure costs nothing." False for the first allocation after every
seed — see P1-1, where it is deterministic rather than occasional.- PR description, Rollout step 6: "Non-zero means some replica saw a mode mirror the authority did not
confirm — a forged key, a shared Redis, or a restored snapshot. It self-heals to legacy, so the
metric is the only signal." Two of the three shapes named in that sentence no longer self-heal.
legacyDelegate(pkg/botevent/seq.go:733-742) now refuses every enqueue for any bot whose
counter key survives, and reports it on a different counter,
dmwork_bot_event_seq_counter_without_authority_total(pkg/botevent/seq.go:505-511), which the
rollout never mentions. Step 6 needs the new metric and the new failure mode. pkg/botevent/seq.go:834points the reader atnoteHighWaterFailure, deleted in this revision.unpersistedSpan's docstring (pkg/botevent/seq.go:995-999): "known is false for a bot this
process has never seeded and never written for."seedCounterstores a base unconditionally at
pkg/botevent/seq.go:1150(including0), so after any successful seedknownis always true and
the base is a DB row value, not something this process wrote. That difference is exactly what
P1-1 is about.
Everything else I re-checked matches. grep -rn '"robotEvent:' --include=*.go . is now empty outside
tests and the single QueueKeyPrefix definition (pkg/botevent/seq.go:251); the dead
robotEventPrefix field is gone; existRobot errors fall through
(modules/robot/event.go:190-195); the doorbell guard now matches "robotEvent:" + robotID and its
vacuity test asserts each spelling (pkg/botevent/chokepoint_guard_test.go:140-155); TestMain runs
the two real CREATE TABLEs (pkg/botevent/main_test.go:58-66); the expected-mode guard is an atomic
pointer (pkg/botevent/mode.go:243); the CROSSSLOT constraint (pkg/botevent/seq.go:80-86) and the
MySQL ≥ 8.0.16 CHECK assumption
(modules/robot/sql/20260805000001_bot_event_seq_state.sql:48-49) are written down.
2. Code quality
Quality: Changes-Requested
P1-1 — the unpersisted-span bound is already over its bound on the first allocation after every seed, so the documented grace does not exist and a failing seq INSERT drops events fleet-wide
Found independently by me and by one automated pass. The arithmetic is deterministic, not a race.
seedCounter records the span base from the durable row, then seeds the counter a full
seedSafetyMargin above a floor that already includes that row:
// pkg/botevent/seq.go:1150
storeMonotonic(&lastDurable, robotID, durableMax)
floor := queueMax
if legacyMax > floor { floor = legacyMax }
if durableMax > floor { floor = durableMax } // floor >= durableMax
if stateFloor := stateFloorOrZero(ctx); stateFloor > floor { floor = stateFloor }
if floor > 0 {
floor += seedSafetyMargin // pkg/botevent/seq.go:1168
}
So the first id after any seed is >= floor + 1 >= durableMax + seedSafetyMargin + 1, and
unpersistedSpan measures from durableMax. Therefore at pkg/botevent/seq.go:939:
base, span, known := unpersistedSpan(robotID, v)
overBound := known && span >= seedSafetyMargin // span >= seedSafetyMargin + 1 → always true
overBound is true on the first allocation for every bot in every process, whenever floor > 0.
Post-activation floor > 0 unconditionally, because stateFloorOrZero alone is the validated cutover
floor. At activation itself it is worse: no seq:botEventHigh: rows exist yet, so durableMax == 0
and span equals the whole id (millions).
Four consequences:
- The grace claimed at
pkg/botevent/seq.go:936is not there. The very first failed durable write
refuses the allocation, instead of after ~2000 ids. - That refusal loses the event.
modules/robot/event.go:389-392logs a warning and returns; there
is no retry and no fallback. So aseq-tableINSERTthat fails or exceeds its 300 ms deadline
(pkg/botevent/seq.go:963-970) drops bot events for every bot in the process — immediately after
every deploy or restart, and during the activation flip itself. Reachable without a full MySQL
outage: the seed's threeSELECTs must succeed to get here, so the trigger is a write-side
problem — failover to a read-only replica, disk full, lock contention on the sharedseqtable, or
simply the 300 ms deadline under load. - It converts the rare probe into a probe storm, which is the hazard the throttle exists to
prevent. While over the bound,probeDue(pkg/botevent/seq.go:950,highWaterProbeEvery = 200ms
at:216) lets each bot attempt one 300 ms-deadlineINSERTevery 200 ms — and it runs inside a
heldmsgSemslot. That is ~1.5 slot-seconds per second per actively-producing bot against 100
slots, so ~67 bots saturatemsgSemand stall fan-out for the whole process. The docstring at
pkg/botevent/seq.go:940-949names exactly this cliff as the reason the probe stays throttled; the
per-bot throttle does not bound the fleet. (Raised as a standalone finding by one automated pass; I
am folding it here because it only becomes reachable because every bot is over the bound.) - The refusal text is unactionable.
unpersistedSpanError(pkg/botevent/seq.go:1027-1035) prints
base + seedSafetyMarginas the recovery floor. Withbase == durableMax == 0an operator reads
"would land at 2000" for a bot whose live ids are in the millions.
Why the new test cannot see it: pkg/botevent/seq_review_test.go:236-247 performs one healthy
allocation before breaking the write, so lastDurable is set from a successful write and base
equals the real first id. The state the bound exists for — the durable write already failing at seed
time — is never exercised. Its comment ("the first allocation for a bot always does (it is never
throttled)") describes the throttle, which is a different mechanism from the bound.
Minimum fix: measure the span from the same quantity seedCounter would recompute — max(queueMax, legacyMax, durableMax, stateFloor) — rather than from durableMax alone; or store the seeded value as
the base instead of the row. Both keep the inequality on the safe side and restore the grace. Then add
a test whose first allocation happens with the durable write already broken.
P1-2 — invalidateSeeded clears the maps in an order that can leave a bot marked seeded with no durable base, which removes the bound entirely
Raised by one automated pass; I confirmed it and it is a one-line fix.
// pkg/botevent/seq.go:771-778
for _, m := range []*sync.Map{&seeded, &lastPersisted, &lastDurable} {
m.Range(func(k, _ interface{}) bool { m.Delete(k); return true })
}
Three sequential Range passes, no lock. A concurrent reseed for bot X (pkg/botevent/seq.go:884)
stores lastDurable[X] inside seedCounter and then stores seeded[X]. If that happens after the
seeded pass and before the lastDurable pass, X ends up seeded with no durable base. The window
is not microseconds — it is two full Range passes over every active bot.
The consequence is P1-B reopened. With lastDurable[X] absent, unpersistedSpan returns
known == false, so overBound is false, so persistHighWater returns nil on a failed write
(pkg/botevent/seq.go:952-959, 971-980) — and because nothing ever establishes a base while the writes
keep failing, it returns nil forever. That is precisely the "unbounded ids against a frozen mark"
this round was rewritten to stop, reachable through an ordering rather than through the arithmetic.
Needs mirror loss and a durable-write outage and a concurrent first allocation, so it is
narrower than P1-1 — but the fix is to delete seeded last (a bot should only be treated as seeded
once its base is present), and the property is worth a test that reseeds concurrently with an
invalidation.
P2
- A denied mirror value can become the genuine activation mirror without changing — raised by
one automated pass, confirmed.Activatesetsepoch = locked.Epoch + 1
(pkg/botevent/state.go:176), so the first activation fromepoch 0writes exactlyincr:1. If a
leftover or forgedincr:1was already in Redis (a shared Redis whose other tenant activated at
epoch 1, a restored snapshot, a test key), replicas deny it and cache the denial keyed on that
value (pkg/botevent/mode.go:347,:434-438). When the operator then activates, the mirror the
tool writes is byte-identical to the denied one, so those replicas keep servingdecideLegacyfor
up tonegativeBeliefTTL(5 s) without re-reading the authority — which contradicts
pkg/botevent/mode.go:44-50("propagation of the flip never waits for a TTL"). Blast radius is
bounded by this round's other fix: bots whose counter already exists hit thecounterExists
refusal, so only bots with no counter yet can take a GenSeq id. At the flip that is every bot, so
the two fixes cover each other only partially.pkg/botevent/seq_review_test.go:203-215changes the
mirror value and never activates the authority, so it cannot see this. Cheap fix: key the denial on
the observed authority epoch as well as the mirror string. - The remediation named in the
counterExistsrefusal does not work — raised by one automated
pass, confirmed.pkg/botevent/seq.go:737-739tells the operator to "restore the state row (or set
OCTO_BOTEVENT_EXPECTED_MODE=incr)". Setting it does not let the process use the counter:
refreshAuthorityreachesassertExpectedMode(false)(pkg/botevent/mode.go:429), which errors
(pkg/botevent/mode.go:271-275), so the allocation still fails, just with a different message. The
env guard is a fail-closed assertion, not a manual override. Either drop the parenthetical or say
what it actually buys ("fail loudly instead of degrading"). This is the one string an operator reads
during an incident. refreshAuthority's new staleness shortcut can answer from a belief resolved against a different
mirror observation — found by me and by one automated pass.pkg/botevent/mode.go:377-384returns
priorwheneverprior != seen, before the conflict rule and before themirrorClaimsIncr
handling of an inconclusive read. Interleaving: G1 reads the authority just before the flip and
installs a negative belief; G2, which already observed the post-flip mirror, is waiting on
beliefMu, seesprior != seen, and is serveddecideLegacy— then delegates to GenSeq if that
bot has no counter yet. It cannot bypassassertExpectedMode(no negative belief can be
installed whileEXPECTED_MODE=incr,pkg/botevent/mode.go:429-431) and it self-corrects on the
next allocation, so the exposure is one allocation per interleaving — one permanently invisible
event. I am ranking this P2 rather than P1, against the other pass's call: the window is the
authority-read latency at a single supervised operator step, and the runbook already prescribes a
brief write pause there. ButTestActivationTakesEffectWithoutAProcessCacheWindowclaims the
property unconditionally, and the fix is one line — re-check the mirror claim against the shortcut
belief before returning.counterExistsisEXISTSand nothing more. No type, value, or provenance check
(pkg/botevent/seq.go:747), no TTL, and nothing ever removes the key. In a shared or restored Redis
this wedges a bot's entire event stream for as long as the authority legitimately says legacy — a
hard failure where the previous behaviour was a metric increment. Accepting that is defensible (it
is the fail-closed direction), but it should be stated where the shared-Redis case is discussed,
and it is the second half of the Rollout step 6 correction above.- A client-supplied
robot_idis still unbounded on the fail-open path — raised independently by
both automated passes.modules/robot/event.go:190-195now usescandidateverbatim when
existRoboterrors, which re-opens the growth surface the check was added to close: an
arbitrary-length client string becomes a no-TTLbotEventSeq:counter:{candidate}key under
noevictionplus aseq:botEventHigh:{candidate}row that is never reclaimed. Failing open was my
ask last round and I stand by it — dropping events on a DB blip was worse. The resolution that
satisfies both is a cheap syntactic bound before the lookup (length + charset), consistent with
NextEventIDalready rejecting empty and padded ids atpkg/botevent/seq.go:566-570. Reachability
is low (the attacker must make the query error), which is why this is P2 and not higher. - Correlated mirror-loss + unreadable authority still costs one serialized 300 ms read per
allocation. The error branches atpkg/botevent/mode.go:443-451return withoutinstall, so the
newprior != seendedupe never engages and each concurrent allocation pays its own read behind
beliefMuinside a heldmsgSemslot. Much narrower than the shape that was fixed (needs Redis and
MySQL degraded at once) and the enqueues fail anyway, so not blocking — but
pkg/botevent/mode.go:365-368claims "one lost mode key costs one authority read rather than one
per in-flight allocation", which holds only while the authority is readable. - The recovery assertion is tight enough to flake.
pkg/botevent/seq_review_test.go:284-296
allows5 * highWaterProbeEvery(1 s) with a 50 ms poll against a 200 ms probe gate — about five
real attempts, each a 300 ms-deadlineINSERT, after aRENAME TABLE. Under-race -shuffle=onon
a loaded runner that is thin;20 * highWaterProbeEverycosts nothing. lastProbeis not cleared byinvalidateSeeded(pkg/botevent/seq.go:772) while the other
three per-bot maps are, andResetSeededForTestclears all five. Harmless today (a stale timestamp
delays one probe by <200 ms); noted because the maps are otherwise kept in step.
Raised by the automated passes and adjudicated against
- "
pkg/botevent/mode.go:385-394(if prior != nil && !force) is dead code, because execution only
reaches it whenprior == seen, andresolveModealready evaluated that state." Rejected — do not
delete it.seenis loaded atpkg/botevent/mode.go:370, afterresolveMode's own load at
:337, so a belief installed between those two loads makesprior == seenwhileprioris a belief
resolveModenever evaluated — including anactivatedone, which that block correctly answers with
decideCounter. The pass that raised it also could not readpkg/botevent/seq.go(see Coverage), so
it could not see the other caller ofrefreshAuthority. Evidence beats assertion here: the claim came
with no reachable path. - "The high-water probe should be globally rate-limited rather than per-bot." Accepted, but as a
consequence of P1-1 rather than as an independent design change — with the span measured correctly,
bots reach the probe regime only during a genuine prolonged write outage, which is a much smaller
population. If P1-1 is fixed and the cliff still worries you, a process-wide token bucket is the
follow-up, not this PR.
3. Verdict
CHANGES_REQUESTED — Spec: ❌ and Quality: Changes-Requested; either one blocks.
To be precise about what I am not saying. The diagnosis, the design, and the activation gate are
right, and I am not asking for any of them to change. Everything blocking is post-activation-only, and
the merged state is closer to inert than at any previous head. The two P1s are both in code written
this round, and both are small.
4. Suggested split
Fix here:
- Measure the unpersisted span from the floor
seedCounterwould recompute, not fromdurableMax
alone (P1-1). Then a test whose first allocation for a bot happens with the durable write
already broken — that is the only shape that catches this class, and it is the third revision in a
row where the test asserted the mechanism next to the one that was wrong. - Delete
seededlast ininvalidateSeeded, and add a test that reseeds concurrently with an
invalidation (P1-2). - Correct the four documentation deviations in §1, including Rollout step 6 and the
OCTO_BOTEVENT_EXPECTED_MODEparenthetical in the refusal message (P2-2). - Key the denied-mirror cache on the observed epoch as well as the value (P2-1); re-check the mirror
claim in the staleness shortcut (P2-3). Both one-liners. - A length/charset bound on
payload.robot_idbefore theexistRobotlookup (P2-5). - Either add the score-source guard or record the deferral on #704 and mark the brief item deferred.
Defer: P2-4 (counterExists provenance), P2-6 (correlated-failure read amplification), and a
process-wide probe budget belong with the rest of the rollback-recovery design on #704, which is
already an activation gate.
5. Process note — sixth round
This is the sixth review round, and the pattern is worth naming rather than absorbing: rounds 5 and 6
have both found that the previous round's fix to the same mechanism was wrong in a new way. The
durable-mark bound has now been rewritten three times (highWaterFailureLimit → span-from-lastDurable
→ still wrong at the seed boundary), and each revision shipped with a test asserting a neighbouring
property.
The bound is not load-bearing for merging: it is unreachable before activation, and #704 already gates
activation on the rest of the rollback-recovery design. So the recommendation is to stop iterating on it
inside this PR — move the durable-mark bound to #704 in one piece, together with a written statement
of what the recovery floor actually is, and let this PR ship the allocator, the gate, and the
consolidation. That is a plausible one-round path to merge; another local patch to
persistHighWater has now failed twice. This needs a call from whoever owns the change, not another
review round.
6. Coverage and blind spots
Stated so the gaps are not read as clearance.
- Tests were not executed. No MySQL or Redis in this environment, so the 46-test integration claim
is unverified by me. What I did run at this head:go build ./...clean,go vet ./pkg/botevent/...
clean, and the four dependency-free source guards pass —
TestEveryBotEventQueueWriterRingsTheDoorbell,TestGuardWouldCatchAnUnrungWriter,
TestNoGenSeqForBotEventIDs,TestGenSeqGuardWouldCatchAReintroduction. The doorbell guard's
vacuity floor (minKnownQueueWriters = 5) matches the five production queueZADDsites exactly, so
it is tight rather than lax — the last round's concern there is resolved. CI remains the authority on
modules/groupandmodules/botfather, which the author reports as failing at both this head and the
previously reviewed one. - One of the two automated passes was degraded. It ran and returned, but its input was truncated
beforepkg/botevent/seq.go— it says so itself — so it never saw the allocator, the high-water
mark, or the Lua scripts. Its silence on those is absence, not agreement; only one pass plus my
own read covers them. Both passes did independently reachmodules/robot/event.go, which is why P2-5
is credited to both. - Not examined by anyone this round:
tools/botevent-seq/main.goandpkg/botevent/state.go
(unchanged since the last head, where their gaps were adjudicated as accepted design); Redis
namespace isolation between environments, which P2-1 and P2-4 both depend on and which is a
deployment fact, not a code fact; themodules/group/modules/botfatherfailures; and the
consumer-side cursor code beyond the docstring change atmodules/bot_api/events.go:174-186. - Noticed, unresolved by anyone, and probably benign: because
seedCountercomputes
floor = max(queueMax, legacyMax, durableMax, stateFloor) + seedSafetyMarginand runs on each
process's first allocation per bot, every process restart raises every active bot's counter by
~seedSafetyMargin. Harmless for delivery (the consumer's cursor is exclusive and tolerates gaps) and
irrelevant forint64, but it is un-stated id-space burn that compounds with restarts, and it
interacts with the2^50floor cap the operator tool enforces. Worth one sentence somewhere. - For a human to verify, as this PR's
needs-human-reviewlabel asks: thepayload.robot_id
branch (modules/robot/event.go:183-195) checks bot existence, not authorization, while the
sibling DM branch in the same function checks creator and friendship (:105,:112). Raised as P1-D
last round and routed to an owner; still open, still not this PR's job to fix. It stays on the list
because this PR touches that line.
…r instead of remembering it (Mininglamp-OSS#697) Two fixes from the previous head were each wrong in a new way, one of them recorded as done. This revision fixes one and, for the other, stops iterating and hands it over. ## The durable-mark bound moves to Mininglamp-OSS#704 in one piece Review found the span bound is over its bound on the **first allocation after every seed**, deterministically: seedCounter recorded the span base as the durable mark while seeding the counter to `base + seedSafetyMargin`, so span >= margin+1 immediately, for every bot in every process. The first failed durable write then refused with no grace and dropped the event, and the recovery probe became a storm inside msgSem slots (~67 producing bots saturate 100 slots). My test could not see it: it did one healthy allocation first, so the base came from a landed write — the state the bound exists for, a durable write already failing at seed time, was never exercised. Third revision in a row where the test asserted the mechanism next to the one that was wrong. I worked the arithmetic out before patching it again, and it says a patch cannot fix it: A seed sets the counter to S = max(sources) + seedSafetyMargin. A recovery seed replays the *same* computation over the surviving MySQL sources, so it lands at S again. Ids issued after a seed are S+1, S+2, … — all above S. So the first id after any seed is already outside what a recovery from an unchanged mark could reach; the exposure starts immediately rather than after some grace. Closing it requires advancing the mark *at seed time*, and a mark that records the seeded value feeds back into the next seed's floor — every re-seed compounds by a block, which also breaks the no-op property TestSeedIsIdempotentAndNeverLowers guards. That is a change to what the recovery floor *is*, not a comparison to tune, and it is the same question Mininglamp-OSS#704 already owns for rollback detection ("what state can regress, who detects it, what re-establishes the floor"). Both reviewers recommended moving it there rather than a third local attempt. So persistHighWater keeps the throttle (one INSERT attempt per interval, re-armed on failure — the half of P1-7 that stays fixed), keeps the metric, and says plainly that the mark trails **without bound** while writes fail. Mininglamp-OSS#704 gained the arithmetic and both failed attempts so the road is not taken a third time, and TestFailedDurableWriteIsThrottledAndDoesNotFailTheEnqueue asserts the gap is *still there* so it becomes the regression test the day Mininglamp-OSS#704 closes it. lastDurable, the time-based probe and their constants are gone with the refusal. ## A denied mirror is removed, not remembered The previous head cached the denial keyed on the mirror value. Jerry-Xin found it can swallow the genuine activation: Activate bumps epoch 0 → 1 and the tool then writes exactly `incr:1`, byte-identical to a forged `incr:1` that may already have been sitting in Redis, so those replicas keep serving legacy for the whole TTL while others allocate from the counter — two live id sources at the flip, which is the defect. My docstring claimed "a different mirror value, including a genuine activation, is still a conflict"; the first activation produces exactly the value that was denied. Keying on the observed authority epoch does not help either, because the authority is not re-read — the two states are indistinguishable from Redis. So the mirror is now *removed* instead: a compare-and-delete on the exact denied value. That is the symmetric repair to the one the allocator already performs in the other direction (authority says activated, mirror disagrees → rebuild it); when the authority says legacy the correct mirror state is absent, since absent is defined to mean "consult the authority". With no cache there is nothing to go stale, and the read storm P1-C named is closed by the key being gone rather than by remembering it. The only residual is a delete that fails, which gets a 1s cooldown — and in that state the counter's own INCR is failing too. ## Also - invalidateSeeded clears `seeded` **last**. These are separate unlocked Range passes, so a concurrent reseed could interleave and leave a bot marked seeded with its companion state wiped. The worst interleaving now leaves a bot *unseeded* with stale bookkeeping, which the next allocation fixes by re-seeding. - The staleness shortcut in refreshAuthority no longer answers a caller that observed a mirror claiming activation from a negative belief resolved against a *different* mirror observation — that would delegate to GenSeq on evidence the caller has contradicted. - The counterExists refusal message no longer tells an operator that OCTO_BOTEVENT_EXPECTED_MODE=incr will unblock them. It will not: it is a fail-closed assertion, so it only makes every replica refuse loudly instead of some degrading. This is the one string read during an incident. Tests: 45 green with -race -shuffle=on, zero skips under CI=true. Part of Mininglamp-OSS#697. Related to Mininglamp-OSS#704.
…s present (Mininglamp-OSS#697) The allocator's repair for a mode mirror the authority denies is to delete it, and the first activation writes exactly `incr:1` — so a forged `incr:1` sitting in Redis and the genuine mirror are the same bytes. Removing the precondition at the one supervised step is worth more than narrowing the race: if the key is not there when the flip happens, the delete can never meet the real mirror write. It is also independently worth stopping for. A mirror claiming activation against a legacy authority means this Redis is not in the state the operator believes — someone wrote that key, it is shared with another environment, or a snapshot from an activated era was restored. Any of those wants a human look before an irreversible flip. Runs after the floor checks so one pass reports every problem. A malformed value is reported but not fatal: allocators treat it as absent and the activation overwrites it. Part of Mininglamp-OSS#697.
… lookup (Mininglamp-OSS#697) The existence check on payload.robot_id fails open on a query error, which is right — dropping a bot event because of a DB blip was worse than the growth surface. But it leaves the fail-open path able to adopt an arbitrary client-supplied string, and the monotonic allocator turns every distinct value into a permanent botEventSeq:counter:{id} key (no TTL, in a Redis running noeviction) plus a `seq` row that is never reclaimed. So the lookup gets a syntactic gate in front of it, which cannot blip: length at most the width of robot.robot_id (VARCHAR(40)), and no whitespace or control characters. The length bound needs no judgement call — a value longer than the column cannot match any row, so adopting it could only ever create permanent state for a bot that provably does not exist. Whitespace and control characters go for the same reason botevent.NextEventID rejects a padded id: the allocator, the queue key and the doorbell must key off the identical string. Deliberately no charset allowlist beyond that. Bot ids are UUID-hex in production but the column stores whatever it is given, and guessing narrower would silently stop a real bot's events for the sake of a surface the length bound already closes. A test ties the constant to the column so a widened schema cannot leave the gate rejecting real ids. Part of Mininglamp-OSS#697.
…rd deferred (Mininglamp-OSS#697) The entries that matter are the ones where I was wrong again: the span bound recorded as fixed last round is over its bound on the first allocation after every seed, and the denied-mirror cache added last round can swallow the genuine activation because the first activation produces exactly the value that was denied. Both with the arithmetic, so the next reader does not re-derive it. Also records the decision to stop iterating on the durable-mark bound inside this PR and hand it to Mininglamp-OSS#704 with that arithmetic, and marks the score-source guard acceptance item DEFERRED to Mininglamp-OSS#704 Gap 4 — last round I said "build it or move it" and did neither, which left the brief carrying an item nothing tracked. Part of Mininglamp-OSS#697.
|
Round 6 addressed at The durable-mark bound leaves this PR for #704@yujiawei's P1-1 is right and the arithmetic is worse than a tuning error. I worked it out before patching again, and a patch cannot fix it: The first id after any seed is already outside what a recovery from an unchanged mark could So: taken, as both of you recommended. This is strictly more honest than either previous revision: one asserted a bound it did not The rest@Jerry-Xin's 🔴 is correct, and my docstring was the thing that was wrong. I wrote that P1-2 — P2-3 — the staleness shortcut re-checks the mirror claim before serving a negative P2-2 — all four corrected, including the one string an operator reads during an P2-5 — a syntactic bound in front of the fail-open lookup: length ≤ the Spec — score-source guard recorded as #704 Gap 4 and marked DEFERRED in the brief. Last P1-D stays with a human owner, unchanged. Test status
On the process note: agreed, and this round is me acting on it rather than agreeing with it. |
mochashanyao
left a comment
There was a problem hiding this comment.
[Octo-Q · automated review]
Verdict: Approve — no blocking findings; notes below (data-flow traced).
Deep Analysis: pkg/botevent/mode.go (586 lines) and pkg/botevent/state.go (209 lines)
Reviewer: Octo-Q (automated review)
Head SHA: 2b4aac0
1. What Each File Does
state.go — The DB-authoritative activation state for the monotonic bot event ID allocator. It owns a singleton row in octo_bot_event_seq_state with three fields: mode (0=legacy, 1=incr), epoch (generation counter, bumped on each activation), and cutover_floor (minimum ID the counter must start above). It provides:
ReadState/ReadStateContext— read the singleton row, distinguishingErrStateMissing(table or row absent = pre-migration) from other errors (DB unreachable).Activate— atomic flip from legacy to incr underFOR UPDATE, validatingfloor > observedMaxbefore committing.stateFloorOrZero— best-effort read ofcutover_floorfor use as a seed floor source.
mode.go — The per-process mode decision engine. It decides, for each allocation, whether to use the Redis counter (decideCounter) or delegate to legacy GenSeq (decideLegacy). It implements a cached "belief" system with asymmetric trust rules:
- A positive belief (activated) is terminal — never downgraded, even on DB errors.
- A negative belief (not activated) is cached for
negativeBeliefTTL(5s) but overridden immediately if the Redis mirror claims activation (forcing a fresh DB read). - An epoch-stamped mirror value (
incr:{epoch}) prevents hand-written or stale mirrors from opening the gate.
2. Activation Gate Logic
The gate is a two-layer system:
Layer 1 — Authority (MySQL). Activate() in state.go:131-193 performs a SELECT ... FOR UPDATE then a conditional UPDATE ... WHERE mode=0. The floor is validated against observedMax before the flip. The epoch is bumped atomically. This is called by the tools/botevent-seq operator CLI, not by the allocator itself.
Layer 2 — Mirror (Redis) + Belief cache (process-local). The allocator's hot path reads the Redis mirror via probeAllocatorState (seq.go:749), then calls resolveMode (mode.go:382) which:
- If positive belief cached -> return
decideCounterimmediately (line 389-390). - If negative belief within TTL and mirror does not claim incr -> return
decideLegacy(line 391-393). - If mirror claims incr but belief is negative -> force DB read via
refreshAuthority(line 400). refreshAuthorityreads the DB, and if activated, installs a positive belief with the validated epoch.
The gate script itself (gateSource, seq.go:268-271) atomically compares the live Redis mirror against the exact validated string incr:{epoch} and INCRs only if they match. This means even if the belief cache is stale, the Lua script catches a mirror change.
What happens if activation fails partway?
Activate() uses a proper transaction with defer tx.RollbackUnlessCommitted() (state.go:139). The flip is a single conditional UPDATE — if RowsAffected != 1, it returns an error (line 184). There is no partial state possible at the DB level.
However, the mirror write happens later, on the allocator side, not inside Activate. In seq.go:636-642, after seeding the counter, the mirror is written via client.Set(ModeKey, b.mirrorValue(), 0). If this write fails:
- The DB says activated, the Redis mirror is absent/wrong.
- The next allocation from any replica reads the mirror (absent), calls
resolveMode, which callsrefreshAuthority, reads the DB, finds activated, and rebuilds the mirror (seq.go:627-642). - During this window, every allocation costs an extra DB read, bounded by
negativeBeliefTTL.
This is a documented, self-healing gap — not a bug. The design is sound.
3. Concurrency Issues
3a. beliefMu lock ordering — CLEAR (no issue found).
beliefMu (mode.go:173) is held only during refreshAuthority. It is never held while acquiring seedMutex or any Redis lock. The seedLocks map (seq.go) is per-bot and never held during a beliefMu acquisition. No lock ordering violation.
3b. activeBelief atomic pointer — CLEAR.
Read via activeBelief.Load() on the hot path without locks. Written only inside refreshAuthority (under beliefMu) or noteMirrorRepairFailed (under beliefMu). Copy-on-write semantics: a new belief struct is created and the pointer is atomically swapped. No reader can see a half-updated belief. This is correct.
3c. Double-check under lock — CLEAR.
refreshAuthority (mode.go:413-445) captures seen before locking, then re-reads activeBelief as prior after acquiring beliefMu. If prior != seen, someone else refreshed. The logic correctly distinguishes:
prior.activated-> return counter (line 424)!mirrorClaimsIncr-> return legacy (line 427)- Mirror claimed incr but prior is negative -> fall through and re-read (line 429)
This is correct. The third case avoids serving a stale negative belief when the caller observed a mirror claiming activation.
3d. noteMirrorRepairFailed under beliefMu — P2 (minor concern).
At mode.go:539-553, noteMirrorRepairFailed acquires beliefMu to do a copy-on-write update of the belief. This is called from dropUnauthorizedMirror (seq.go:785) which is called from allocate (seq.go:612). The call path does NOT hold beliefMu at that point, so there is no deadlock risk. However, noteMirrorRepairFailed acquires beliefMu while refreshAuthority also holds it — these are serialized, not nested. Clear.
3e. sync.Once for seqClientOnce — CLEAR.
seqClientOnce (seq.go:339) is a standard sync.Once for client construction. No misuse.
3f. seeded sync.Map invalidation ordering — CLEAR with documented risk.
invalidateSeeded (seq.go:798-809) clears lastPersisted before seeded, which is the correct order (documented in the comment at line 798). Clearing seeded first would let a concurrent reseed's marker survive while its companion state was wiped. This is correct.
3g. Race between resolveMode and concurrent activation — CLEAR.
A concurrent activation changes the Redis mirror and DB row. resolveMode reads the mirror first (via probe), then decides. If the mirror changed between probe and gate, the Lua gate script catches it (returns -1). If the DB changed between probe and refreshAuthority, the DB read returns the new state. The system converges.
4. State Persistence
4a. How is state stored?
- Authoritative state: MySQL singleton row in
octo_bot_event_seq_state. Survives Redis restarts, RDB rollbacks, and process restarts. Read with a 300ms deadline (authorityTimeout, mode.go:155). - Mirror: Redis key
botEventSeq:modewith valueincr:{epoch}. No TTL (set with expiration 0). Regresses with RDB snapshots (appendonly noin production). - Process-local belief:
activeBeliefatomic pointer. Lost on process restart.
4b. Can state become stale?
- Negative belief: Stale for up to
negativeBeliefTTL(5s). But a mirror claiming activation forces an immediate re-read, so the only stale window is when the DB says activated but the mirror hasn't been written yet. - Positive belief: Never stale in the downward direction (terminal). Can be stale in the upward direction if epoch changes — but the gate script compares the exact epoch string, so a stale epoch closes the gate rather than allocating.
- Mirror: Can regress with an RDB snapshot. The design explicitly handles this: an absent or mismatched mirror triggers a DB read and rebuild.
4c. Can state be cleared?
ResetModeBeliefForTest()(mode.go:563) clears the process-local belief.ResetSeededForTest()(seq.go:1172) clears all process-local caches including belief.- There is no production mechanism to clear a positive belief. This is by design: once activated, the process never returns to legacy.
- The mirror can be deleted (by
dropUnauthorizedMirroror manually), which triggers a DB re-read on next allocation.
5. Error Handling Gaps
5a. stateFloorOrZero silently swallows all errors — P2 (acceptable, documented).
At state.go:200-208, any error from ReadStateContext is silently converted to return 0. This is documented as "best-effort" and the comment explains that other floor sources cover the cases. The function is only used as one of four floor sources in seedCounter, so a zero return degrades safety margin but does not cause incorrect behavior.
5b. readStateDeadlined creates a new background context — P2 (acceptable).
At mode.go:556-559, readStateDeadlined uses context.Background() as the parent, not the caller's context. This is deliberate (documented in the authorityTimeout comment at mode.go:149-155): the caller may be inside a msgSem slot with no deadline, and an unbounded wait would stall fan-out. The 300ms timeout is the bound.
5c. Activate does not validate floor > 0 — P2 (minor).
At state.go:131-193, Activate validates floor > observedMax but does not reject floor <= 0. A zero or negative floor would be stored in cutover_floor and used by stateFloorOrZero as a seed floor source. Since seedCounter (seq.go:1133) only adds seedSafetyMargin when floor > 0, a non-positive floor would effectively be ignored. Not a bug, but the operator tool should validate this.
5d. Inconclusive authority read caches the negative answer — CLEAR (correct).
At mode.go:497-506, when the authority is unreadable and no prior positive belief exists, a negative belief is cached. This prevents a DB outage from causing one failed read per allocation. The TTL is negativeBeliefTTL (5s), so recovery is automatic.
6. Data Flow Trace
DB row -> runtime decision:
- Operator runs
botevent-seq activate-> callsActivate(ctx, floor, observedMax)-> DB row updated:mode=1, epoch=N, cutover_floor=floor. - Operator writes Redis mirror:
SET botEventSeq:mode incr:N(done by the tool, not byActivate). - Next allocation on any replica:
allocate()callsprobeAllocatorState()-> reads mirrorincr:Nand counter existence via Lua. resolveMode(ctx, "incr:N")->parseMirrorextracts epoch N -> no prior belief or negative belief -> callsrefreshAuthority.refreshAuthoritycallsreadStateDeadlined-> DB returnsmode=1, epoch=N->assertExpectedMode(true)passes -> installsbelief{activated: true, epoch: N}.- Back in
allocate:rebuildMirroris true if mirror was absent/wrong ->invalidateSeeded()->reseed()->client.Set(ModeKey, "incr:N", 0). allocateFromCounter->runGate-> Lua checksGET ModeKey == "incr:N"andEXISTS SeqKey-> INCRs counter -> returns ID.afterIssue->checkRegression->recordIssued->persistHighWater.
Stale data paths:
- If mirror is lost (RDB rollback): gate returns -1 ->
gateClosed-> forcedrefreshAuthority-> re-reads DB -> rebuilds mirror. - If DB regresses (manual UPDATE/restore): positive belief is terminal, so existing processes keep allocating. A cold process reads DB, finds legacy, but
legacyDelegatecheckscounterExists(from probe) and refuses if the counter is present. - If both mirror and counter are lost (Redis flush + DB regression): cold process has no evidence. Only
OCTO_BOTEVENT_EXPECTED_MODE=incrcloses this residual window.
7. Mode Transitions
Valid transitions:
legacy -> incr: ViaActivate(). Epoch bumped. Floor validated. Irreversible at the process level (positive belief is terminal).incr -> incr(re-activation):Activate()returnsflipped=falsewith existing epoch (state.go:165). Idempotent.incr -> legacy: Only by manual DB manipulation (UPDATE/restore). The system detects and resists this: existing processes keep allocating (positive belief), cold processes refuse if counter exists (legacyDelegate).
Can the system get stuck?
No. The system cannot get stuck in an invalid state:
- A positive belief cannot be downgraded, so an activated process never returns to legacy.
- A negative belief expires after 5s, so a newly activated system converges within one TTL.
- The repair cooldown (1s) prevents a stuck unauthorized mirror from causing a read storm, and it expires quickly.
maxAllocAttempts = 3bounds mutual recursion betweenafterIssueandgateClosed.
The one "stuck" scenario is a Redis that keeps losing state (FLUSHDB loop), which hits the attempt limit and returns an error. This is the correct behavior — the caller sees a failure rather than an infinite loop.
8. Logic Bugs and Findings
Finding 1 — P2: Activate epoch overflow not guarded.
state.go:176: locked.Epoch+1 with no overflow check. Epoch is uint64, so overflow requires 2^64 activations — not practically reachable. No action needed.
Finding 2 — P2: parseMirror accepts epoch 0 as valid.
mode.go:355-361: parseMirror("incr:0") returns (true, 0, true). A mirror with epoch 0 would match a belief with epoch 0, which is what Activate produces when locked.Epoch was 0 (initial state). This is correct behavior — the first activation produces epoch 1, but if the initial row has epoch 0 and Activate bumps to 1, the mirror would be incr:1. A hand-written incr:0 would only match if the authority actually had epoch 0 and was activated, which would require the initial row to have mode=1, epoch=0 — an inconsistent state that Activate would not produce (it returns flipped=false for an already-activated row). Not a bug, but worth noting that epoch 0 is a valid mirror value.
Finding 3 — P2: assertExpectedMode error messages could leak deployment details.
mode.go:305-318: Error messages include the env var name and expected values. In a public-facing error, this reveals the deployment guard mechanism. Low risk — these errors are returned to the caller (internal allocation path), not directly to end users.
Finding 4 — P2 (borderline P1, but pre-existing design choice): Residual window when both Redis and DB evidence are lost.
As documented extensively in mode.go:68-76 and seq.go:743-746, if both the Redis counter key and the DB authority row are lost simultaneously (correlated Redis flush + DB restore), a cold process has no evidence of prior activation and delegates to GenSeq. The OCTO_BOTEVENT_EXPECTED_MODE=incr guard closes this, but only after it is armed. This is a documented residual, not a new defect. The design explicitly accepts this window during the flip itself.
Finding 5 — P2: refreshAuthority treats ErrStateMissing the same as "authority says legacy".
mode.go:457-462: Both err == nil (with !st.Activated()) and errors.Is(err, ErrStateMissing) fall into the same branch. This is correct for pre-migration deploys (the table doesn't exist yet = legacy), but it means a dropped table post-activation would also read as legacy. The positive-belief-is-terminal rule protects running processes, and legacyDelegate's counterExists check protects cold processes. The residual is the same as Finding 4.
Finding 6 — P2: install function always returns nil error.
mode.go:516-525: The install function signature returns (modeResolution, error) but never returns a non-nil error. This is dead API surface. The callers (refreshAuthority) check the error, which is always nil, so no harm — but the signature suggests an error path that does not exist.
Finding 7 — P2: belief.confirmed field is set but never read.
mode.go:102: The confirmed field distinguishes "authority said legacy" from "authority was unreadable", and the comment says "the flag exists so the log says which one happened." However, a search of the codebase shows confirmed is set in refreshAuthority (lines 455, 490) but never read by any function. It appears to be vestigial — the logging distinction was either removed or never implemented. Not a bug, but dead code.
Finding 8 — P2: noteMirrorRepairFailed copies the belief struct, including repairFailedFor from a prior failure.
mode.go:547-551: updated := *cur copies the entire belief, including any existing repairFailedFor and repairFailedAt. Then it overwrites repairFailedFor and repairFailedAt. This is correct behavior — the copy-on-write pattern replaces the old cooldown with the new one. No bug.
Summary
Both files are exceptionally well-engineered. The design has clearly been through multiple review iterations (referenced as P1-1 through P1-4, P1-A through P1-C throughout the comments). The asymmetric belief caching, epoch-stamped mirror values, and multi-layered evidence system (DB authority + Redis mirror + counter existence + process-local lastIssued) form a robust activation gate.
No P0 or P1 findings. The system correctly handles:
- Partial activation failures (self-healing via mirror rebuild)
- Redis data loss (RDB rollback detection + durable high-water marks)
- DB regression (positive belief terminal + counter existence check)
- Concurrent access (atomic pointers, mutex-guarded refresh, CAS for high-water marks)
- Stale mirrors (epoch comparison in Lua gate script)
The findings are all P2-level: dead code (confirmed field), minor API surface (install always returns nil error), and documented residual risks that require correlated multi-system failure to trigger.
[Octo-Q] verdict: APPROVE
The two files under review (mode.go and state.go) contain no blocking defects. The activation gate design is sound, concurrency handling is correct, and error paths are well-documented. All findings are P2 or below.
lml2468
left a comment
There was a problem hiding this comment.
PR Review (re-review) — PR #702 (Mininglamp-OSS/octo-server) — APPROVE (re-affirmed; owning a miss)
Re-reviewed at new head 2b4aac06b8c1 (forward from my APPROVEd dacb706b; commits refuse to activate while an unauthorized mirror is present, bound payload.robot_id syntactically before the existence lookup, record the fifth round). go build/go vet (botevent, robot, tools) exit 0 ✅; pure guard + mode/mirror + robot_id suites PASS; DB/Redis suites → CI.
Owning a miss — the negative-belief cache could swallow a real activation (Jerry-Xin / Steve 🔴)
At dacb706b I re-affirmed the mode machine as "verified across prior rounds" and did not re-scrutinise the negative-belief cache. It had a deniedMirror == mirror shortcut: a forged incr:1 denied at epoch 0 was cached by that exact string for the full negativeBeliefTTL (5s), and because Activate bumps epoch 0→1 and the tool then writes the same incr:1, the replica holding the cached denial would keep issuing GenSeq ids for up to 5s while other replicas were already on the counter — two id sources on one queue, the exact loss this PR exists to remove. Subtle (it needs the denied string to equal the activation string), but real, and I read past it. Jerry-Xin and Steve caught it. Owned.
The fix — delete the forged mirror instead of caching the denial (verified)
resolveMode no longer keys a cached denial on the mirror string. The deniedMirror field is gone; a mirror that claims incr now always falls through to a fresh authority read. When the authority denies a claiming mirror, the resolution carries unauthorizedMirror and the caller compare-and-deletes it (dropUnauthorizedMirror → dropMirrorScript deletes ModeKey only if it still holds the exact denied value — so a generation the operator wrote after the read, e.g. incr:2, is preserved). Deleting restores the invariant "absent mirror ⇒ consult the authority," so the next allocation re-reads and picks up a genuine activation immediately — no string-keyed swallow. TestUnauthorizedMirrorIsRemovedNotCached pins it.
The only residual cooldown is tightly scoped and safe: repairCooldown (1s) via repairFailedFor is set only when the compare-and-delete itself fails (noteMirrorRepairFailed, copy-on-write), i.e. Redis is rejecting writes — a state in which the counter's own INCR is failing too, so no allocation is succeeding on the counter to create a split; and it's 1s, not 5s, after which the authority is re-read. noteMirrorRepairFailed is a no-op on an activated/absent belief. The operator tool also now refuses to activate while an unauthorized mirror is present (e724140), closing the flip-time interleaving from the operator side.
yujiawei's remaining items closed
payload.robot_id is now bounded syntactically (plausible-id shape + column-length check) before the existence lookup, so a malformed id is rejected cheaply rather than costing a Redis/DB probe — TestPlausibleRobotID + TestRobotIDMaxLenMatchesTheColumn. The existRobot-error fail-open (don't drop events on a lookup error) from the prior round stays. Score-source guard breadth is explicitly marked deferred to #704 in the brief (not silently dropped).
Everything verified across prior rounds stands: authority-decides mode (mirror carries epoch, TestMirrorAloneCannotActivateTheCounter), positive-belief-terminal (TestNeverDowngrades…), cached-negative-no-per-alloc-DB, malformed-expected-mode fail-closed, the mode-key collision fix, the durable-high-water fail-closed bound (measures from last successful write, refuses past seedSafetyMargin), rollback detection, all-producers-on-NextEventID + repo-wide GenSeq guard, score==event_id, queue-key consolidation.
Verdict
APPROVE (re-affirmed at 2b4aac06) — the epoch-string-reuse swallow I missed at dacb706b is fixed at the root: a denied mirror is compare-and-deleted (preserving any newer generation) rather than cached by string, so a claiming mirror always re-reads the authority and a genuine activation is picked up with no mixed-source window; the only 1s cooldown applies solely while Redis is rejecting the delete (when the counter is unusable anyway), and the activation tool refuses to flip past an unauthorized mirror. payload.robot_id is bounded before the lookup, and every prior-round fix (fail-closed high-water bound, DB-authoritative mode, queue-key consolidation, rollback detection) remains intact — all test-locked. Both gates pass; merge stays behaviour-neutral (state seeds to legacy; a bare mirror can't flip it and is now actively removed). Thanks to Jerry-Xin and Steve — the denied-string == activation-string case was a real swallow I didn't trace.
Suggested decision: Safe to merge (gated, neutral) once CI runs the DB+Redis mode/mirror/high-water-bound/rollback suites green and the octo_bot_event_seq_state migration is applied. At activation: with no unauthorized mirror present, flip via the tool; watch AuthorityReads/MirrorUnauthorized/RollbacksDetected/highWaterWriteFailures. merge: MERGEABLE.
Jerry-Xin
left a comment
There was a problem hiding this comment.
The PR is relevant to octo-server and addresses a real bot-event queue allocator defect. I did not find a merge-blocking correctness issue in the allocator, activation gate, producer migration, or guards.
💬 Non-blocking
🟡 Warning — botevent-seq activate is not actually idempotent when the authority is already active and the mirror exists. activate() calls refuseUnauthorizedMirror() before botevent.Activate() can return flipped=false (tools/botevent-seq/main.go), and refuseUnauthorizedMirror() fatals on any valid mirror claim without checking the DB state (tools/botevent-seq/main.go). That makes the “already activated; reconcile mirror” branch mostly unreachable when the mirror is already correct (tools/botevent-seq/main.go). This is operational friction, not allocator corruption, but it should be fixed with a small state check and a tool test.
🔵 Suggestion — The “merge is behavior-neutral” claim is materially true for Redis-backed queue writers, but pre-activation allocation still performs a Redis probe before delegating to GenSeq (pkg/botevent/seq.go). For inline queries, addInlineQuery() now depends on that path before writing the in-memory event map (modules/robot/api.go). Since bot polling also reads Redis, I do not see this as a blocker, but the rollout language should avoid “exactly as before” if it means “no extra dependency touch.”
✅ Highlights
The core gate design is much stronger than the previous GenSeq shape: DB authority, generation-stamped Redis mirror, exact mirror comparison in the Lua gate, and no GenSeq fallback after activation are all aligned with the failure mode.
Producer coverage looks complete: the robot, group, bot API, botfather cleanup, and bot mention paths route through NextEventID/QueueKey, with source guards for GenSeq reintroduction and unrung queue writers.
I ran targeted checks:
go test ./pkg/botevent -run 'Test(NoGenSeq|GuardWould|Plausible|CounterKey)' -count=1
go test ./tools/botevent-seq -count=1
go test ./modules/robot -run 'TestPlausibleRobotID|TestRobotIDMaxLenMatchesTheColumn' -count=1
go test ./pkg/redis -count=1
git diff --check
modules/bot_api and modules/bot_mention integration tests could not run locally because MySQL on 127.0.0.1:3306 is unavailable; I did not count that as a PR failure.
Superseded: re-posting with repo-relative paths (dismissing to remove accidental local absolute paths from the body).
Jerry-Xin
left a comment
There was a problem hiding this comment.
The PR is relevant to octo-server and addresses a real bot-event queue allocator defect. I did not find a merge-blocking correctness issue in the allocator, activation gate, producer migration, or guards.
💬 Non-blocking
🟡 Warning — botevent-seq activate is not actually idempotent when the authority is already active and the mirror exists. activate() calls refuseUnauthorizedMirror() before botevent.Activate() can return flipped=false (tools/botevent-seq/main.go:269), and refuseUnauthorizedMirror() fatals on any valid mirror claim without checking the DB state (tools/botevent-seq/main.go:323). That makes the "already activated; reconcile mirror" branch mostly unreachable when the mirror is already correct (tools/botevent-seq/main.go:276). This is operational friction, not allocator corruption, but it should be fixed with a small state check and a tool test.
🔵 Suggestion — The "merge is behavior-neutral" claim is materially true for Redis-backed queue writers, but pre-activation allocation still performs a Redis probe before delegating to GenSeq (pkg/botevent/seq.go:596). For inline queries, addInlineQuery() now depends on that path before writing the in-memory event map (modules/robot/api.go:1020). Since bot polling also reads Redis, I do not see this as a blocker, but the rollout language should avoid "exactly as before" if it means "no extra dependency touch."
✅ Highlights
The core gate design is much stronger than the previous GenSeq shape: DB authority, generation-stamped Redis mirror, exact mirror comparison in the Lua gate, and no GenSeq fallback after activation are all aligned with the failure mode.
Producer coverage looks complete: the robot, group, bot API, botfather cleanup, and bot mention paths route through NextEventID/QueueKey, with source guards for GenSeq reintroduction and unrung queue writers.
Round-6 verification of the prior REQUEST_CHANGES items (byte-checked against the delta dacb706..2b4aac0):
- The mixed-source activation window I flagged is genuinely closed. The general negative-belief denial cache (
deniedMirror) is removed entirely; a mirror claiming activation now always forces a fresh authority read (no TTL shortcut), and an authority-denied mirror is deleted via compare-and-delete on the exact value rather than cached.Activatebumping epoch 0→1 and writingincr:1can no longer be swallowed. The only residual cache (repairFailedFor/repairCooldown= 1s) applies solely when the DEL itself failed — a state in which the counter's own INCR would be failing too, so no competing live id source exists.TestUnauthorizedMirrorIsRemovedNotCachedpins exactly this: N allocations against a forged mirror issue one authority read, the key is removed, and a real activation to the same epoch takes effect on the next allocation with no swallow. This flips my previous 🔴 to resolved. - The ZADD score-source guards (
TestEveryBotEventQueueWriterRingsTheDoorbell,TestNoGenSeqForBotEventIDs) are present and match on the key symbol, and the doc/comment deviations are corrected to describe the delete-based mechanism and the #704-owned high-water bound.
I ran targeted checks:
go test ./pkg/botevent -run 'Test(NoGenSeq|GuardWould|Plausible|CounterKey)' -count=1
go test ./tools/botevent-seq -count=1
go test ./modules/robot -run 'TestPlausibleRobotID|TestRobotIDMaxLenMatchesTheColumn' -count=1
go test ./pkg/redis -count=1
git diff --check
modules/bot_api and modules/bot_mention integration tests could not run locally because MySQL on 127.0.0.1:3306 is unavailable; I did not count that as a PR failure.
yujiawei
left a comment
There was a problem hiding this comment.
Code Review — PR #702 (octo-server)
Reviewed at head 2b4aac06b8c1c8711a0e57e2511162041eb92e2f, merge base 40627cc0, 25 files / +5236 −90.
This is the seventh formal round (earlier heads: d34bdfb0, f7829596, 37f45fbd, 2f7e58da,
e90540e2, dacb706b). I read the incremental diff dacb706b..2b4aac06 against the findings that
blocked the last round, then re-read the allocator, the mode machine and the operator tool.
What is different about this round: the integration suite was actually executed. Every previous
round — mine included — reported "DB/Redis suites → CI" and reviewed the allocator by reading. I
brought up MySQL 8.0 + Redis 7 and ran it the way CI does. Full results in §6; the short version is
that pkg/botevent passes 49 tests, 0 skipped, under -race -shuffle=on -count=1, as do
pkg/redis, modules/robot, modules/bot_api and modules/message. That closes the largest blind
spot this PR has carried for six rounds, and it is how the finding below was confirmed rather than
argued.
Real progress this round: the durable-mark bound is out of the PR and onto #704 in one piece with the
arithmetic — that was the right call and it took a three-times-rewritten mechanism out of this
change; the denied-mirror cache is gone; invalidateSeeded clears seeded last; the staleness
shortcut re-checks the mirror claim; payload.robot_id is bounded syntactically; and the score-source
guard is explicitly deferred in the brief
(.octospec/tasks/bot-event-score-monotonic/brief.md:166) and recorded as #704 Gap 4. All four
documentation deviations from the last round are corrected.
The one blocking finding is in the mirror-repair mechanism that was rewritten this round, and the
fix is one condition at a call site that already holds the evidence it needs.
1. Spec compliance
Spec: ❌ — nothing missing and nothing over-built; two deviations where load-bearing prose no
longer describes the code.
Missing — nothing. The score-source guard that was neither built nor moved last round is now
marked DEFERRED in the brief and tracked as #704 Gap 4.
grep -rn '"robotEvent:' --include=*.go . is empty outside tests except the single QueueKeyPrefix
definition (pkg/botevent/seq.go:245).
Over-built — nothing. Every change in dacb706b..2b4aac06 maps to a finding from the last round.
Deviation 1 — "merging is behaviour-neutral … exactly as before" is not true of the I/O shape, and
this is the third round it has been raised. The Summary says "every replica delegates to GenSeq
exactly as before"; COMPREHENSION Q1 says "Until activation nothing changes at runtime"; Rollout step 1
says "every allocation goes through GenSeq with no DB round trip". The DB claim is true and
well tested (TestNotActivatedDoesNotQueryTheAuthorityPerAllocation). But pre-activation every
allocation now performs a Redis EVALSHA (probeAllocatorState, pkg/botevent/seq.go:596 and
:749) on the hottest producer path inside a held msgSem slot, where GenSeq served 999 of every
1000 allocations from a process-local block with no I/O at all. The disclosure does exist — but it is
in Rollout step 3, framed as a consequence of activation ("The I/O shape changed … every
allocation here is a Redis round trip"), when it actually starts at merge. addInlineQuery
(modules/robot/api.go:1020) is the sharpest case: it writes an in-memory map and previously needed no
Redis at all to obtain an id.
This is not a correctness problem — the queue ZADD on the same path already requires Redis, so
availability is unchanged for queue writers — but the merge-safety argument for this PR is
behaviour-neutrality, and the sentence stating it is the one that is wrong. Move the step-3 paragraph
up beside the claim, or qualify it as "same allocator, one added Redis round trip".
Deviation 2 — pkg/botevent/mode.go:174-178 asserts a benignness property the code does not have.
The comment on modeResolution.unauthorizedMirror reads: "The race is benign: if an operator
activates between the authority read and this delete, a legitimate mirror is removed, every replica's
next allocation consults the authority, finds it activated, and rebuilds it. One extra read each, no
mixed source." Both halves are wrong:
- "every replica's next allocation consults the authority" — a replica holding an unexpired negative
belief that sees an absent mirror answersdecideLegacystraight from cache without consulting
the authority (pkg/botevent/mode.go:390-392; an absent mirror does not claim, so the conflict
rule that forces a read never fires). Recovery on those replicas waits fornegativeBeliefTTL, not
for one allocation. - "no mixed source" — see P1-1. When the authority is the party that regressed, deleting the mirror
is not benign in any direction.
Everything else I re-checked matches. The four last-round deviations are corrected:
pkg/botevent/seq.go:936-979 now states plainly that the trail is unbounded and names #704; the
pointer to the deleted noteHighWaterFailure is gone; unpersistedSpan and its docstring went with
the mechanism; Rollout step 6 names three metrics and says which one does not self-heal. TestMain
runs the two real CREATE TABLEs (pkg/botevent/main_test.go:61-66). The 2^50 floor cap, the
CROSSSLOT constraint (pkg/botevent/seq.go:82-87) and the MySQL ≥ 8.0.16 CHECK assumption
(modules/robot/sql/20260805000001_bot_event_seq_state.sql:48-49) are all written down.
2. Code quality
Quality: Changes-Requested
P1-1 — a single cold replica deletes the legitimate activation mirror when the authority has regressed, turning a metadata-only regression into a fleet-wide bot-event outage
This is the mechanism that replaced the denied-mirror cache in 0948ace2, and it is an ordering defect
of the same family as the P1-3 accepted in round 4 ("the global mirror was published before the per-bot
seed"): the evidence that invalidates the delete's premise is computed before the delete and
consulted after it.
allocate has counterExists in hand at pkg/botevent/seq.go:596. It deletes the mirror at
:604-608. Only at :611 does it pass that same counterExists to legacyDelegate, which uses it to
conclude "the authority was activated and has regressed" and refuse (:727-743, with
counterFoundWithoutAuthority.inc() at :734). So the code establishes that the authority is the
unreliable party — after having destroyed the mirror on the authority's word.
Reproduced at this head against real MySQL 8.0 + Redis 7:
authority mode=1 epoch=0, mirror "incr:0", counter present, one id allocated
→ authority regresses to mode=0 (restore of a pre-activation dump, or a manual UPDATE)
→ Redis keeps everything: mirror present, counter present
→ ONE freshly started replica allocates once:
warn "mode mirror claims activation but the authority does not; removing the stale mirror"
error "...already has a counter — ... the authority was activated and has regressed.
Refusing to issue a legacy id below the counter's range." ← correct, fail-closed
GET botEventSeq:mode → redis.Nil ← the LEGITIMATE mirror is gone
→ a still-healthy activated replica's gate:
runGate(client, robotID, MirrorValue(0)) = -1 (gateNotActivated)
-1 sends that replica into gateClosed (pkg/botevent/seq.go:692-702) →
refreshAuthority(..., force: true) → the authority says legacy while prior.activated → the
deliberate refusal at pkg/botevent/mode.go:464-474. Every enqueue for every bot on every activated
replica then fails, and it does not recover until the octo_bot_event_seq_state row is restored.
Contrast with the previous head: the mirror stayed, the cold replica refused itself, and healthy
replicas kept allocating correctly from the counter. So one new pod — a rolling restart, an HPA
scale-up, a crash-loop — now converts a bookkeeping loss with zero delivery impact into a total
bot-event delivery outage. That is not the fail-closed direction; it is discarding the last correct
artifact on the word of the artifact already known to be wrong.
Why the new test cannot see it: TestUnauthorizedMirrorIsRemovedNotCached
(pkg/botevent/seq_review_test.go:184-236) calls client.Del(SeqKey(robotID)) at :194, so
counterExists is false for the entire test. It exercises only forged-mirror-with-no-counter, where
deleting is exactly right. Third round in a row where the test shipped alongside a fix asserts the
neighbouring property.
Minimum fix — one condition, at pkg/botevent/seq.go:604:
// Do not "repair" the mirror on evidence that the *authority* is the regressed party:
// counterExists means some replica allocated from this counter, which can only have
// happened after the authority said incr.
if res.unauthorizedMirror != "" && !counterExists {
dropUnauthorizedMirror(client, res.unauthorizedMirror)
}
Worth one step further, because !counterExists narrows the window rather than closing it (the mirror
is global, a seed is per-bot, so an allocation for a brand-new bot still has no counter to object
with): also suppress the delete when the authority read returned ErrStateMissing. A dropped table or
a missing row is not evidence that anybody forged a mirror, and pkg/botevent/state.go:69-70 already
says readers must distinguish "the migration has not run" from "the authority is wrong". Then a test
whose fixture keeps both the mirror and the counter while the authority regresses — that is the
only shape that catches this class.
P2
botevent-seq -action activateis not idempotent, and its refusal asserts a fact it never
checked. Found independently by another reviewer on this PR; I confirmed it in code.
refuseUnauthorizedMirrorruns attools/botevent-seq/main.go:269, beforebotevent.Activate
can returnflipped=false, and it fatals on any parseable mirror claim (:332-344) without ever
reading the state row. So on an already-activated system with a correct mirror — the normal state
after a successful flip — re-runningactivate -yesdies with "already holdsincr:1while the
authority says legacy. Nothing should have written that — it means this Redis was activated
before, is shared with another environment, or was restored from an activated snapshot. Confirm
which, then DEL the key and rerun." Every clause is false in the common case, and it instructs the
operator to delete the live mirror. The "already activated; reconcile the mirror" branch at
:276-283— the documented repair for a stale or missing mirror — is unreachable whenever a mirror
is present. P2 rather than P1 because it is loud, exits non-zero, changes no state, and DELing the
mirror against a healthy authority self-heals (the forced authority read findsincrand rebuilds
it). Fix: read the state row first and refuse only when the authority really says legacy.
tools/botevent-seqhas no test file at all, and this is the second finding in it across two
rounds.- The retained per-bot counter is an unbounded, user-triggerable growth surface, and it sits
awkwardly beside the reasoning used to justify thepayload.robot_idgate. Raised by an
independent analysis pass; I confirmed the mechanism and narrowed the severity.
modules/botfather/api_user.go:470-479deliberately keepsbotEventSeq:counter:{botID}(no TTL,
noeviction) andseq:botEventHigh:{botID}when a bot is deleted, and the comment argues the trade
well. What it does not say is that the loop create bot → have it receive one message → delete bot
is available to any authenticated user and leaves both artifacts behind permanently, so growth is
bounded by bot-creation rate rather than by live bot count. I am ranking this P2, not higher, for
two reasons the raising pass did not have: the legacyseq:robotEventSeq:{botID}row already
leaked identically before this PR, so only the small Redis key is new; and the artifacts are created
byseedCounter/persistHighWateron first allocation, so a bot that never receives an event
leaves nothing. Still worth a sentence in that comment — the PR bounds an arbitrary client string
on exactly this reasoning two files away, and a reader deserves to see why bot-count scale is
accepted where payload scale was not. A dated sweep that proves the id is unused is the stated exit;
#704 is the natural home. plausibleRobotIDbounds bytes while its justification is characters.
modules/robot/event.go:57(len(candidate) > robotIDMaxLen) counts bytes; the docstring at
:32-34justifies the bound as the width ofrobot.robot_id VARCHAR(40), which MySQL counts in
characters underutf8mb4. An independent pass rated this P1 on the grounds that a real bot with
a multibyte id would have its events silently dropped. I adjudicate it down to P2, with the
evidence: both id-producing paths are ASCII-only — a client-supplied username is restricted to
[a-z0-9_]{1,20}plus_bot(modules/botfather/api_user.go:161-176,const.go:20), and the
auto-generated form is lowercase hex plus_bot(modules/botfather/command.go:954-957). So no
reachable robot_id is multibyte and no real bot's events can be dropped. It remains a nit worth
fixing because the stated reason is not what the code enforces: either say "bytes, deliberately
stricter than the column" or useutf8.RuneCountInString.belief.confirmedis write-only. Set atpkg/botevent/mode.go:459and:491, read nowhere.
Its own docstring (:129-132) says "the flag exists so the log says which one happened" — no log
reads it. Same documentation-versus-code class this PR has been closing each round: log it or delete
it.installnever returns a non-nil error (pkg/botevent/mode.go:522-531) while all three call
sites check one. Dead API surface implying a failure path that does not exist.invalidateSeeded's docstring now overstates why its order matters
(pkg/botevent/seq.go:795-801). The fix is right and I asked for it. But with the span bound gone,
lastPersistedis purely a throttle marker, so the worst outcome of the bad interleaving is one
redundantINSERT— not "a bot treated as seeded whose bookkeeping this process no longer has".- Un-stated id-space burn (carried, still unwritten).
seedCountercomputes
floor = max(queueMax, legacyMax, durableMax, stateFloor) + seedSafetyMargin
(pkg/botevent/seq.go:1100-1118) on each process's first allocation per bot, so every process
restart raises every active bot's counter by ~2000. Harmless for delivery (the cursor is exclusive
and tolerates gaps) and irrelevant forint64, but it compounds with restarts and interacts with the
2^50cap the operator tool enforces. counterExistsisEXISTSand nothing more (pkg/botevent/seq.go:749-770): no provenance, no
TTL, nothing ever removes the key — and P1-1 makes it load-bearing in a second place. Belongs with
the rest of the rollback-recovery design on #704, which does not currently list it.
Raised elsewhere and adjudicated against
- "The pre-activation Redis probe is blocking." No. The queue
ZADDon the same path already
requires Redis, so availability is unchanged for queue writers;addInlineQueryis the only genuinely
new Redis dependency, and it is one call. It is a description problem (Deviation 1), not a
correctness one. - "The
activatenon-idempotency is allocator corruption." No — see P2-1: no state changes, and the
mis-instructedDELself-heals against a healthy authority. - "The byte-length bound silently drops real bots' events (P1)." Not reachable — see P2-3 for the
two ASCII-only id-producing paths.
3. Verdict
CHANGES_REQUESTED — Spec: ❌ and Quality: Changes-Requested; either one blocks.
Being precise about what I am not saying, since three other reviewers have approved this head and I
am the only remaining block. The diagnosis, the design, the activation gate and the producer
consolidation are right, and I am asking for none of them to change. The merged state is genuinely
inert, and this round I verified that by execution rather than by reading. P1-1 is reachable only
after activation, which #704 gates.
What decides it: P1-1 is a confirmed, reproducible, fleet-wide-outage regression introduced by this
head, and it is currently neither fixed nor tracked — #704's Gaps 1–4 say nothing about mirror repair.
Merging as-is loses it. Unlike the durable-mark bound, it is not a design question: counterExists is
already computed four lines above the delete, so this is a one-condition change, not a fourth attempt
at a mechanism. Either land that condition plus a test whose fixture keeps the mirror and the
counter, or add it to #704 as a fifth gap — either flips me to approve, and I do not care which.
4. Suggested split
Fix here:
- Do not delete the mirror when
counterExists— and preferably not onErrStateMissingeither —
plus a test with both artifacts surviving (P1-1). - Read the state row in
refuseUnauthorizedMirrorbefore refusing, and fix the message that asserts
"while the authority says legacy". A first test file fortools/botevent-seqwhile you are there
(P2-1). - Move the Rollout step-3 I/O paragraph up beside the behaviour-neutrality claim, and correct the
"race is benign" comment atmode.go:174-178(Deviations 1 and 2). - Four one-liners: byte-versus-character bound, the write-only
confirmedfield,install's error
return, theinvalidateSeededdocstring (P2-3 … P2-6). - One sentence in
modules/botfather/api_user.go:470-479on why bot-count-scale retained state is
accepted where payload-scale was not (P2-2).
Defer to #704, alongside the durable-mark bound that correctly landed there this round: the id-space
burn note, counterExists provenance, and the reclaiming sweep for retained counters (P2-7, P2-8,
P2-2).
5. Process note — seventh round
Rounds 5 and 6 each found that the previous round's fix to the same mechanism was wrong in a new way.
Round 7 does too — but the mechanism moved. The durable-mark bound was rewritten three times and is now
on #704 in one piece with the arithmetic showing why a local patch cannot fix it. That was the right
call and it took the recurring failure out of this PR. The recurrence has simply relocated to
mirror repair, which has now been rewritten twice (cache the denial → delete the key), and the second
attempt has a new failure mode.
So the recommendation is narrower than last round's, not broader: one more targeted round, not
another redesign. P1-1 is a one-line guard whose evidence is already in scope; P2-1 is a missing
state read in a tool with no tests. If a fix to either lands wrong again, stop patching and escalate to
whoever owns the change: at that point the pattern says this file is not verifiable by review alone,
and the answer is a call on what else moves behind the activation gate — not an eighth round.
Two things for whoever owns the merge decision:
tools/botevent-seqcarries the entire activation procedure and has zero tests, while being the
only component a human runs by hand at the cutover. Two findings in it across two rounds.- The
needs-human-reviewitem is unchanged and still not this PR's job:payload.robot_id
(modules/robot/event.go:206-249) is checked for bot existence, not authorization, while the
sibling DM branch in the same function checks creator and friendship. It stays listed because this PR
touches that line.
6. Coverage and blind spots
Stated so the gaps are not read as clearance.
Executed at this head — MySQL 8.0 + Redis 7 in throwaway containers, per-package database drop and
recreate exactly as .github/workflows/ci.yml:276 does, with OCTO_MASTER_KEY set as CI sets it:
| package | result |
|---|---|
pkg/botevent -race -shuffle=on -count=1 |
PASS — 49 tests, 0 skipped, 7.4 s |
pkg/redis -race |
PASS |
modules/robot -race -shuffle=on |
PASS (needs OCTO_MASTER_KEY; panics without it) |
modules/bot_api -race -shuffle=on |
PASS, 62.9 s |
modules/message -race -shuffle=on |
PASS |
go build ./... / go vet (botevent, robot, tools) |
clean |
The "0 skipped" claim is therefore verified rather than trusted, which matters because a t.Skipf
here is indistinguishable from a pass and that is precisely what an earlier revision shipped.
TestFailedDurableWriteIsThrottledAndDoesNotFailTheEnqueue does assert the residual is still
present — it logged "3000 ids issued past a frozen durable mark at 1; a seed recovering from that
mark alone would land at 2001, below ids already issued" — so it will serve as #704's regression test
as advertised. The octo_bot_event_seq_state migration applied through real sql-migrate as part of the
modules/robot run.
- Not run by me:
modules/groupandmodules/botfather. The author reports pre-existing failures
at both this head and2f7e58da; repository CI is green at this head (Test,Build,Vet,Lint
all SUCCESS), so CI is the authority there. go test ./...in one shot still does not work in this repo, andpkg/botevent'sTestMain
gives it a new shape: it creates the migration-ownedseqtable in the sharedtestschema, so a
later package's migration aborts withError 1050: Table 'seq' already existsinstead of the
pre-existingunknown migration in database. I checked before counting this —ci.yml:255-262
already documentsgo test ./...as broken and drops the database between packages — so it is a
pre-existing repository condition, not a finding against this PR. Worth knowing if you run the
suite locally.- Independent analysis passes: two ran and both returned. One is degraded and says so itself — its
input was truncated before the allocator, so it never sawNextEventID,resolveMode, or the Lua
scripts, and its silence on those is absence, not agreement; what it did reach is credited at
P2-2 and P2-3. The other reviewers on this PR reached the pre-activation-probe point independently
(Deviation 1) and theactivatenon-idempotency (P2-1), which is why both are credited rather than
claimed. Nothing here rests on a pass that failed or timed out; where a pass disagreed on severity I
said so and gave the evidence (P2-3). - Not examined by anyone this round:
tools/genseq-repro/main.go; the consumer-side cursor code
beyond the docstring rewrite atmodules/bot_api/events.go:175-218; and Redis namespace isolation
between environments, which P1-1, P2-1 and P2-8 all depend on and which is a deployment fact, not a
code fact. - What execution cannot cover: the three preconditions
botevent-seqdemands-yesfor (#704
closed, no pre-fix replica remaining, a brief write pause) are operational, and the co-recovery
argument rests on production runningappendonly no/maxmemory 0/noevictionin the same
instance and db as the queue. Both remain human-verified facts — appropriate for the
needs-human-reviewlabel, andpkg/botevent/seq.go:82-87is the right place to have written the
second one down.
…glamp-OSS#697) Review round 7 found the mirror repair added last head is a fleet-wide outage in the one case it was supposed to help, and reproduced it. It is right, and I reproduced it too — the test was written and watched failing before any code changed. `allocate` has `counterExists` in hand at seq.go:596, deletes the mirror at :604, and only at :611 passes that same `counterExists` to `legacyDelegate`, which uses it to conclude "the authority was activated and has regressed" and refuse. So the code destroys the mirror on the authority's word *before* establishing that the authority is the unreliable party. Same family as the P1-3 accepted in round 4: the evidence that invalidates the operation's premise is computed before it and consulted after it. The consequence is not a metadata loss. One freshly started replica — a rolling restart, an HPA scale-up, a crash-loop — deletes the artifact every healthy activated replica's gate compares against. Their gate returns gateNotActivated, they force an authority read, find legacy against an activated belief, and hit the deliberate refusal: every enqueue for every bot fails until the state row is restored. Before this mechanism existed the mirror stayed, the cold replica refused only itself, and the rest of the fleet kept allocating correctly. A bookkeeping regression with zero delivery impact became total delivery loss. The suggested minimum fix was to guard the delete on `!counterExists`. That narrows the window and does not close it: the mirror is global while a counter is per-bot, so an allocation for a bot that has never received an event still has no counter to object with. And since the delete's only benefit was suppressing the authority-read storm, which the denial cooldown also suppresses, the delete is gone entirely rather than conditioned. The allocator now never writes the mirror on the legacy path; only the operator tool does, which is the one place a human can see what they are overwriting. The cost is stated rather than hidden. The denial cooldown is back — 1s, keyed on the exact denied value — so a genuine activation writing byte-identical bytes to a previously denied mirror is not noticed for up to that second. That window cannot be zero without reading the authority per allocation (nothing visible from Redis distinguishes a forged `incr:1` from the real one; only the authority does), and it is only entered when a mirror claiming activation was already sitting against a legacy authority — which `botevent-seq` now refuses to flip on top of. The cooldown is also armed **only when no counter exists**: with one, the authority is the party that regressed, every allocation for that bot is refused anyway, and a suppressed read would only delay noticing the authority came back. Also from round 7: - `belief.confirmed` was write-only while its docstring promised the log said which of the two legacy reasons happened. Deleted; the two distinct warn call sites are that record. - `install` never returned a non-nil error while all three call sites checked one. - `invalidateSeeded`'s docstring overstated the stake: with the span bound on Mininglamp-OSS#704, `lastPersisted` is purely a throttle marker, so the worst interleaving costs one redundant durable write. The order is still right and still cheap. Tests: 46 in pkg/botevent, green with -race -shuffle=on and zero skips under CI=true. TestRegressedAuthorityDoesNotDestroyTheLegitimateMirror is the reproduction, kept. TestUnauthorizedMirrorIsRemovedNotCached is rewritten as TestUnauthorizedMirrorIsLeftAloneAndDoesNotSwallowActivation, asserting both halves: the allocator leaves the key exactly as found, and a genuine activation to the denied value is delayed by the cooldown but never swallowed. Part of Mininglamp-OSS#697. Related to Mininglamp-OSS#704, which gained Gap 5 for mirror repair having no safe automatic form, plus the two unaudited things it leans on.
…add the tool's first tests (Mininglamp-OSS#697) `refuseUnauthorizedMirror` ran before `botevent.Activate` could report an already-activated authority, and it fatalled on *any* parseable mirror claim without ever reading the state row. So re-running `activate -yes` on an already-activated system — a correct mirror, which is the normal state after a successful flip — died with "already holds incr:1 while the authority says legacy. Nothing should have written that … Confirm which, then DEL the key and rerun." Every clause of that is false in the common case, and it instructs the operator to delete the live mirror. It also made the documented "already activated; reconcile the mirror" branch unreachable whenever a mirror was present — which is the repair for the exact state that branch exists for. Found independently by both reviewers. The fix is to read the authority first. With it, the three cases separate cleanly: an authority that agrees is a note and the mirror is reconciled; an authority that says otherwise is the refusal the check was added for; a malformed value is a note either way, because the allocator treats it as absent and this run overwrites it. The decision is split into `judgeMirror` so the matrix is testable without Redis or MySQL, which is the other half of this commit: **tools/botevent-seq had no tests at all**, while carrying the entire activation procedure and being the only component a human runs by hand at the cutover. Two review findings in it across two rounds. The new file covers the seven mirror cases, pins that the refusal is the only message allowed to say "DEL" (that instruction was the concrete harm here), and checks the 2^50 floor cap against float64's exact-integer limit so the other operator-facing bound has a reason on record too. The refusal message also no longer implies the allocator will clean the key up: as of the previous commit it deliberately does not touch it. Part of Mininglamp-OSS#697.
…d per-bot state is accepted (Mininglamp-OSS#697) Two comments that justified themselves with something other than what the code does (Mininglamp-OSS#697 review round 7). `plausibleRobotID` bounds a candidate in **bytes** while its docstring justified the number as the width of `robot.robot_id` — which MySQL counts in *characters* under utf8mb4. So the gate is deliberately the stricter of the two, and that is now what it says. It also records why the difference is unreachable today: both id-producing paths are ASCII-only (a client-chosen username is `[a-z0-9_]{1,20}` plus `_bot`, the generated form is lowercase hex plus `_bot`), and bytes are what the Redis key and the log line are actually made of. If a multibyte id ever becomes producible, the note says to switch to RuneCountInString rather than raise the number. The retained per-bot counter comment argued its trade well but omitted the scale, which matters because Mininglamp-OSS#697 rejects an arbitrary `payload.robot_id` on very similar reasoning two files away. `create bot → have it receive one message → delete bot` is available to any authenticated user, so the leak grows with the bot-creation rate rather than the live bot count. What makes that acceptable here and not there is the unit of abuse: a payload field is free and unbounded per message, while a bot is a rate-limited, audited, per-user-quota object — and the legacy `seq:robotEventSeq:` row already leaked identically before this change, so only the small Redis key is new. A bot that never receives an event leaves nothing behind at all. Part of Mininglamp-OSS#697. The reclaiming sweep is Mininglamp-OSS#704.
Moving the durable-mark bound to Mininglamp-OSS#704 last round was right, and the reviewer agreed — but the same recurrence relocated to mirror repair, which had by then been rewritten twice. This round's record is about that: the reproduction was written and watched failing before any code changed, the third attempt is "do not repair it automatically at all" rather than a third mechanism, and the cost of that (a 1s cooldown window that cannot be zero without reading the authority per allocation) is written down rather than claimed away. Also records the tool's first test file and why it needed one, the four comments that justified themselves with something other than what the code enforces, and one local diagnosis worth not re-deriving: modules/message's IM-dependent E2E test went red consistently and it was the local WuKongIM container's raft timing out, identical at HEAD, green after restarting it. Part of Mininglamp-OSS#697.
2375708
|
Round 7 addressed at The reproduction ( The fix is not the That means the mechanism has now been through cache → delete → neither, and I want to be
If either of you thinks 1s-with-a-supervised-precondition is the wrong side of that trade, P2-1 — fixed by reading the state row first, and the tool has its first test file. You Both deviations — the behaviour-neutrality claim is fixed in the claim, in Summary, P2-3 … P2-6 all done as one-liners, and on the byte-vs-character bound I took your #704 gained Gap 5 — mirror repair has no safe automatic form, both attempts and why each VerificationRan the way you did, per-package DB reset,
One local diagnosis worth recording so it is not re-derived: On your process note: agreed, and this is the round where I followed it exactly — reproduce |
Jerry-Xin
left a comment
There was a problem hiding this comment.
This PR is in scope for octo-server and addresses bot event delivery state, allocation, queue producers, and rollout tooling. I did not find any blocking correctness, security, or architecture issues.
Non-blocking
🟡 Warning: pkg/botevent/mode.go logs “removing the stale mirror”, but the current allocator deliberately no longer deletes or repairs that mirror on the legacy path. The behavior is correct per the surrounding design, but the log message is now misleading during exactly the incident path operators will inspect. I would change it to “ignoring the stale mirror” or “denying the mirror claim”.
Highlights
✅ The relevance gate passes: the PR modifies bot event id allocation and delivery behavior that belongs directly to this repository.
✅ The critical queue writers I checked route ids through botevent.NextEventID, and queue key spelling is consolidated through botevent.QueueKey.
✅ The activation gate design is materially safer than a Redis-only switch: MySQL remains authoritative, the Redis mirror is generation-stamped, and the tool refuses unsafe activation states.
✅ The tests are unusually strong for this risk level: source guards, rollback/mirror cases, residual-loss pinning, and operator-tool tests are all present.
Verification Performed
I ran:
go test ./pkg/botevent ./tools/botevent-seq
go test ./tools/genseq-repro
go test ./modules/robot -run 'TestPlausibleRobotID|TestRobotIDMaxLenMatchesTheColumn'
go test ./modules/bot_api ./modules/group ./modules/botfather -run '^$'
go test ./modules/robot -run '^$'
git diff --check
All passed in this checkout.
Superseded: re-posting with repository-relative paths.
Jerry-Xin
left a comment
There was a problem hiding this comment.
This PR is in scope for octo-server and addresses bot event delivery state, allocation, queue producers, and rollout tooling. I did not find any blocking correctness, security, or architecture issues.
Non-blocking
🟡 Warning: pkg/botevent/mode.go:490 logs “removing the stale mirror”, but the current allocator deliberately no longer deletes or repairs that mirror on the legacy path (the dropUnauthorizedMirror mechanism was removed this head; the path now only records the denial in-memory via noteMirrorUnauthorized). The behavior is correct per the surrounding design, but the log message is now misleading during exactly the incident path operators will inspect. I would change it to “ignoring the stale mirror” or “denying the mirror claim”.
Round 7 CR items (re-verified byte-for-byte against 2375708 vs the prior head 2b4aac0)
✅ Mirror-repair call-condition (P1-1) — resolved. The whole repair mechanism (dropUnauthorizedMirror / dropMirrorScript / noteMirrorRepairFailed) is deleted, with zero remaining references. The allocator no longer writes or deletes the mode mirror on any path; it only records the denial in-memory (noteMirrorUnauthorized), and only when !counterExists. The fleet-wide-outage case (a cold replica deleting the mirror every healthy activated replica's gate compares against) is now structurally impossible.
✅ Merge-safety I/O-shape wording — corrected: the added pre-activation Redis round trip is now stated up front, with addInlineQuery named as the only genuinely new dependency, and pinned by TestNotActivatedDoesNotQueryTheAuthorityPerAllocation (no per-allocation DB read).
✅ Code/doc consistency — the write-only confirmed field and install's never-returned error are removed, and docstrings corrected; the one residual is the stale log string above (non-blocking).
Highlights
✅ The relevance gate passes: the PR modifies bot event id allocation and delivery behavior that belongs directly to this repository.
✅ The critical queue writers route ids through botevent.NextEventID, and queue key spelling is consolidated through botevent.QueueKey.
✅ The activation gate design is materially safer than a Redis-only switch: MySQL remains authoritative, the Redis mirror is generation-stamped, and the tool now reads the authority before judging the mirror (judgeMirror) so a valid re-run of activate is idempotent.
✅ Tests are unusually strong for this risk level: source guards, rollback/mirror cases, residual-loss pinning, and the operator tool's first tests are all present.
Verification Performed
- Byte-isolated the true delta (
git diff 2b4aac06 2375708): 2375708 is 4 commits ahead / 0 behind — genuine new work, not rebase drift. go build ./pkg/botevent/... ./tools/botevent-seq/... ./modules/robot/...— clean.go test ./pkg/botevent ./tools/botevent-seq ./tools/genseq-repro;modules/robot -run 'TestPlausibleRobotID|TestRobotIDMaxLenMatchesTheColumn';git diff --check— all passed in this checkout.
mochashanyao
left a comment
There was a problem hiding this comment.
[Octo-Q · automated review]
Verdict: Approve — no blocking findings; notes below (data-flow traced).
Octo-Q automated review Review: octo-server PR#702 — botevent monotonic ID allocator
Reviewer: Octo-Q (automated review)
Head SHA: 2375708662d5e0b7650742e070e8df25f5ee727d
Diff scope: pr-base...HEAD (three new files: seq.go, mode.go, state.go)
1. Verification Summary
All three files are new additions (no prior version). I read every line of all three files (1987 lines total) and traced data flow through every allocation path, every state transition, and every error branch.
Overall assessment: This is an exceptionally well-engineered change. The documentation is unusually thorough — every design decision is justified inline with references to specific review rounds and production incidents. The code matches its documentation. I found no P1 defects.
2. Data-Flow Tracing
2.1 Allocation hot path (post-activation, seeded)
NextEventID(ctx, robotID)
→ nextEventID(ctx, SeqClient(cfg), robotID)
→ allocate(ctx, client, robotID, 0)
→ [fast path] activeBelief.Load() → b.activated=true, seeded has robotID
→ allocateFromCounter(ctx, client, robotID, b, 0)
→ runGate(client, robotID, b.mirrorValue())
→ Lua gateSource: GET(ModeKey) == expectMirror ? INCR(SeqKey) : sentinel
→ afterIssue(ctx, client, robotID, v, 0)
→ checkRegression(robotID, v) — CAS-safe against lastIssued
→ recordIssued(robotID, v) — monotonic CAS advance
→ persistHighWater(ctx, robotID, v) — best-effort, throttled
Verified: b.mirrorValue() returns "incr:{epoch}" from the belief struct. The Lua script atomically compares the live Redis ModeKey value against this exact string. A mismatch returns -1 (gateNotActivated), not an id. The INCR only fires when the mirror matches AND the counter key EXISTS. No data can be consumed without passing through this atomic gate.
2.2 Allocation cold path (pre-activation or first-use)
allocate() → probeAllocatorState(client, robotID)
→ Lua probeSource: returns {mode_mirror, counter_exists} in one RTT
→ resolveMode(ctx, mirror)
→ activeBelief nil or stale → refreshAuthority(ctx, claims, mirror, false)
→ readStateDeadlined(ctx) → ReadStateContext with 300ms deadline
→ SELECT mode, epoch, cutover_floor FROM octo_bot_event_seq_state WHERE singleton_id=1
→ install belief atomically
→ decideLegacy → legacyDelegate(ctx, robotID, counterExists)
→ refuse if lastIssued has entry (this process already issued counter ids)
→ refuse if counterExists (some process allocated from counter)
→ else → legacyEventID → ctx.GenSeq(...)
Verified: The counterExists flag comes from the same Lua script that reads the mirror — one atomic probe. The lastIssued check is process-local and cannot be stale in the dangerous direction (it only has entries after this process issued a counter id, which requires the gate to have opened, which requires the authority to have said incr).
2.3 Seed floor computation
seedCounter(ctx, client, robotID):
floor = max(queueCeiling, legacyCeiling, highWaterCeiling, stateFloorOrZero)
if floor > 0: floor += seedSafetyMargin (2000)
→ Lua seedSource: SET counter to floor only if current < floor
Upstream sources traced:
queueCeiling:ZRevRangeWithScores(QueueKey, 0, 0)→top[0].Score— the highest score in the live queue. Returns 0 if queue is empty. Correct.legacyCeiling:SELECT min_seq FROM seq WHERE key=?— the GenSeq block boundary. Returns 0 if no row. Correct.highWaterCeiling:SELECT min_seq FROM seq WHERE key=?(high-water key) — the durable mark. Returns 0 if no row. Correct.stateFloorOrZero:ReadStateContext→st.CutoverFloor. Returns 0 on any error (best-effort backstop). Correct, with caveat — see P2-1.
2.4 Activation state transition
Activate(ctx, floor, observedMax):
BEGIN TX
SELECT mode, epoch FROM state WHERE singleton_id=1 FOR UPDATE
→ if already incr: return (false, epoch, nil) — idempotent
→ if floor <= observedMax: return ErrFloorTooLow
→ UPDATE state SET mode=1, epoch=epoch+1, cutover_floor=floor
WHERE singleton_id=1 AND mode=0
→ check affected=1 (CAS guard)
COMMIT
Verified: The FOR UPDATE lock serialises concurrent operators. The AND mode=0 in the UPDATE is a CAS guard — if another operator flipped between the SELECT and UPDATE, affected=0 and the function errors. The returned epoch (locked.Epoch+1) matches what was written. Correct.
3. Findings
P2-1: stateFloorOrZero swallows all errors including non-transient ones
File: pkg/botevent/state.go:199-208
Diff-scope: New code.
Description: stateFloorOrZero returns 0 on any error from ReadStateContext, including permanent errors like a schema mismatch or a corrupt row. The comment says "best-effort" and this is true for transient errors (DB timeout), but a permanent error (e.g., the state row exists but has a NULL cutover_floor column after a botched migration) would silently lose this floor source forever. Since this is the backstop of four floor sources and the other three are independent, the practical risk is low — but a logged warning on non-transient errors would aid diagnosis.
Fix direction: Distinguish ErrStateMissing (expected pre-migration) from other errors, and log the latter at warn level. The seed path already has a deadline so the latency impact is bounded.
P2-2: seedLocks sync.Map grows unboundedly — one mutex per bot, never cleaned
File: pkg/botevent/seq.go:912-918
Diff-scope: New code.
Description: seedMutex(robotID) creates a *sync.Mutex per distinct robotID via seedLocks.LoadOrStore. These entries are never deleted in production code (ResetSeededForTest clears them but that is test-only). In a long-running process serving many bots, this grows without bound. With UUID-hex robot IDs each entry is roughly 100 bytes (mutex + map overhead), so 100K distinct bots consume roughly 10MB. Not catastrophic, but unlike seeded / lastIssued / lastPersisted (which invalidateSeeded clears on mirror rebuild), seedLocks has no eviction path at all.
Fix direction: Acceptable as-is for the current deployment scale. If bot count grows, consider either (a) including seedLocks in invalidateSeeded (safe — the next reseed re-creates the mutex via LoadOrStore), or (b) using a sharded lock pool (e.g., 256 mutexes keyed by hash(robotID) % 256) to cap memory.
P2-3: afterIssue regression recovery does not distinguish gateCounterMissing from a true regression
File: pkg/botevent/seq.go:855-862
Diff-scope: New code.
Description: In afterIssue, after regression detection and re-seed, the code runs runGate again and checks:
if retried == gateNotActivated {
return gateClosed(ctx, client, robotID, attempt+1)
}
if retried <= prev {
return 0, fmt.Errorf("...re-seeding only reached %d...", retried)
}
If the gate returns gateCounterMissing (-2) — meaning the mirror matches but the counter was just deleted again — the value -2 falls through to retried <= prev (since prev >= 0), producing the misleading error "re-seeding only reached -2." The refusal itself is correct (safe behaviour), but the error message would confuse an incident responder.
Fix direction: Add an explicit case gateCounterMissing check before the <= prev comparison, returning a more descriptive error like "counter disappeared again during regression recovery."
P2-4: persistHighWater trails without bound while DB writes fail (documented residual)
File: pkg/botevent/seq.go:940-991
Diff-scope: New code.
Description: While durable writes are failing, lastPersisted advances on failure (re-arming the throttle) but the actual MySQL mark does not. The gap between issued ids and the durable mark grows without bound. A Redis rollback during a prolonged DB outage would then seed below live cursors. The code documents this explicitly and defers it to #704, which must be closed before activation.
Verdict: Known residual, gated by #704. Not a blocker for merge. The dmwork_bot_event_seq_high_water_write_failure_total metric provides the alert signal.
P2-5: highWaterCeiling and legacyCeiling discard the row count from dbr Load
File: pkg/botevent/seq.go:1124-1134, seq.go:1142-1153
Diff-scope: New code.
Description: Both functions discard the count returned by LoadContext:
if _, err := ctx.DB().Select("min_seq").From(...).Where(...).LoadContext(deadline, &mark); err != nil {
return 0, ...
}
return mark, nil
When the row does not exist, dbr returns count=0, err=nil and mark stays at its zero value (0). This is the correct behaviour for "no ceiling recorded" — but the discarded count means a future reader cannot tell whether 0 means "no row" or "row exists with min_seq=0." In practice these are equivalent (both mean "start from scratch"), so this is a readability concern, not a correctness one.
Fix direction: Nit. Optionally capture count and explicitly return 0 when count==0 for clarity.
Nit-1: parseMirror accepts leading/trailing whitespace via TrimSpace
File: pkg/botevent/mode.go:297
Diff-scope: New code.
Description: parseMirror calls strings.TrimSpace(v) before parsing. This means a hand-written SET botEventSeq:mode " incr:5 " would be accepted as epoch 5. While this is defensive parsing rather than a vulnerability, it creates an asymmetry: formatMirror produces "incr:5" (no spaces), so a mirror value with spaces would never match a validated belief's mirrorValue(), causing the gate to close and forcing an authority read on every allocation until the key is corrected. Not a defect — the gate closes safely — but the TrimSpace is doing no useful work since the only writer (formatMirror / MirrorValue) never produces padded output.
Fix direction: Harmless as-is. If strictness is preferred, remove the TrimSpace and let padded values fall through as "not a claim."
Nit-2: invalidateSeeded clearing order is lastPersisted then seeded, but the comment documents the reverse
File: pkg/botevent/seq.go:800-811
Diff-scope: New code.
Description: The code iterates []*sync.Map{&lastPersisted, &seeded}, clearing lastPersisted first, then seeded. The comment above correctly explains why seeded should be cleared last ("a marker should not outlive the state it stands for"). The code matches the comment's intent. However, the comment also says "review P1-2" requested this order, and a reader might wonder why lastIssued is not in the list — it is deliberately excluded because clearing it would disable regression detection. Worth a one-line note in the comment.
Fix direction: Add a sentence: "lastIssued is deliberately not cleared here: it is the rollback detector's memory, and clearing it would blind the next allocation to a counter regression."
4. Security-Sensitive Checklist (C1-C6)
C1 — Dual-path parity (activate/deactivate symmetry):
The Activate function flips mode 0→1 with FOR UPDATE + CAS. There is no Deactivate function in this PR. The state row's mode is only ever set to StateModeIncr by Activate, and the initial seed is StateModeLegacy (0). The operator tool (in tools/botevent-seq/, outside review scope) presumably handles deactivation. N/A for this PR's three files.
C2 — Control-flow ordering / nested reuse:
The allocate → allocateFromCounter → afterIssue → gateClosed → allocate cycle is mutually recursive but bounded by maxAllocAttempts=3. I traced the attempt counter through every path: allocate(0) → allocateFromCounter(0) → gate-closed → gateClosed(1) → allocate(1) → ... → gateClosed(3) → error. Bounded correctly. Clear.
C3 — Authorization boundaries:
The allocator keys on robotID which the documentation states "must be the identity resolved from the authenticated context, never a request-body value." The allocator itself does not enforce this — it is a leaf package that trusts its callers. The Activate function is not exposed via any HTTP endpoint in these files; it is called by the operator tool. N/A for these files; caller responsibility.
C4 — Authorization lifecycle / state cascade:
The Activate function validates floor > observedMax before flipping, preventing activation with a floor below live cursors. The CAS guard (AND mode=0) prevents double-activation. The epoch is bumped on each activation, so a deactivate-then-reactivate produces a new epoch that won't match stale mirrors. Clear.
C5 — Build/note through is not sufficient:
Not applicable — these are pure Go files with no build artefacts, browser extensions, or packaging concerns. N/A.
C6 — Governance/policy/document self-consistency:
Not applicable — no governance documents, SECURITY.md changes, or policy files in these three files. N/A.
5. Concurrency Analysis
| Shared state | Type | Protection | Verdict |
|---|---|---|---|
activeBelief |
atomic.Pointer[belief] |
Immutable structs, copy-on-write | Correct |
seeded |
sync.Map |
Concurrent-safe, cleared by invalidateSeeded |
Correct |
lastIssued |
sync.Map |
CAS loop in recordIssued (monotonic advance only) |
Correct |
lastPersisted |
sync.Map |
CAS loop in storeMonotonic (monotonic advance only) |
Correct |
seedLocks |
sync.Map |
LoadOrStore for per-bot mutex creation |
Correct |
beliefMu |
sync.Mutex |
Guards authority reads, held during DB call (300ms max) | Correct |
authorityReads |
atomic.Int64 |
Atomic increment | Correct |
expectedMode |
atomic.Pointer[expectedModeGuard] |
Parsed once in init(), test hook swaps atomically |
Correct |
seedClientOnce |
sync.Once |
Singleton client creation | Correct |
| Lua scripts | Redis-side | Atomic by Redis single-threaded execution | Correct |
No data races identified. The sync.Map.CompareAndSwap usage (Go 1.20+) is compatible with the module's go 1.25 declaration.
6. SQL Injection / Raw Query Safety
All SQL queries use parameterised binds:
state.go:105:SELECT ... FROM state WHERE singleton_id=?— bind:stateSingletonID(constant1)state.go:155:SELECT ... FOR UPDATE WHERE singleton_id=?— bind: constantstate.go:173:UPDATE state SET mode=?, epoch=?, cutover_floor=? WHERE singleton_id=? AND mode=?— all bindsseq.go:979:INSERT INTO seq ... ON DUPLICATE KEY UPDATE min_seq = GREATEST(min_seq, VALUES(min_seq))— binds:HighWaterSeqKey(robotID),markseq.go:1130:SELECT min_seq FROM seq WHERE key=?— bind:HighWaterSeqKey(robotID)seq.go:1148:SELECT min_seq FROM seq WHERE key=?— bind: constructed key
Table names (stateTable, "seq") and column names are all string constants. No string interpolation of user-controlled values into SQL. No SQL injection risk.
7. Cross-Round Blocker Re-Review (R6)
N/A — this is the first review pass on this PR.
8. Additional Observations
- I/O shape change is honestly documented. The comment at
seq.go:398-403explicitly states that every allocation is now a Redis round trip versus GenSeq's 999/1000 cache-hit rate. This is a real performance regression that the design requires (a block allocator would reintroduce #697). Load testing before activation is correctly recommended. - The GenSeq chokepoint guard (
genseq_guard_test.go) is a strong structural safeguard — it statically asserts thatGenSeqfor bot event IDs is called from exactly one site (legacyEventID). Any future developer adding a second call site will trip this test. - The
expectedModeenv guard correctly fails closed on malformed values (OCTO_BOTEVENT_EXPECTED_MODE=inrcrefuses rather than silently disabling). This is the same pattern as #627 and is well-tested.
Verdict
No P0 or P1 findings. All identified issues are P2 (improvements to error messages, memory hygiene, readability) or Nit. The code correctly implements its documented contracts:
- Monotonicity: enforced by Redis INCR + regression detection + seed floor.
- Uniqueness: enforced by Redis INCR atomicity + co-located counter and queue.
- Activation safety: enforced by FOR UPDATE + CAS in MySQL, atomic Lua gate, and the belief terminal-property invariant.
- Error handling: errors propagate on the critical path; only documented best-effort paths (high-water persist, state floor backstop) swallow errors, and both have alerting metrics.
[Octo-Q] verdict: APPROVE — no blocking defects found. The P2 findings above are optional improvements.
yujiawei
left a comment
There was a problem hiding this comment.
Code Review — PR #702 (octo-server)
Reviewed at head 2375708662d5e0b7650742e070e8df25f5ee727d, merge-base 40627cc0.
This is the 8th review round on this PR; the delta since the last reviewed head
(2b4aac06) is 4 commits — af86decb, 29c6787a, 12aee7c7, 23757086.
1. Spec compliance
Spec: ✅
Checked against .octospec/tasks/bot-event-score-monotonic/brief.md (Goal /
Load-bearing / Out of scope / Acceptance) and the round-7 findings this head claims
to close.
Missing (漏建): none. The two items absent from the diff are both agreed
narrowings, not gaps:
- the score-source guard is marked
DEFERREDin the brief (line 166) and recorded as
#704 Gap 4; - rollback detection and the durable-mark bound are on #704, which rounds 5–7 all
agreed to.
Extra (超建): one, non-blocking. .gitignore:96-102 adds ignore entries for four
other operator-tool binaries (msgextra-version, card-action-dlq, i18nmarkers,
genseq-repro) — strictly outside #697. This is the declined P2-1 nit; three lines,
defensive against a mistake this PR already made once, and a reason is given. Noted,
not held against the gate.
Deviation (偏离): one, P2. pkg/botevent/mode.go:178 states:
"Nothing in the allocator writes this key now; only the operator tool does"
The allocator does write it — pkg/botevent/seq.go:639, the mirror-rebuild branch:
if rebuildMirror {
if err := client.Set(ModeKey, b.mirrorValue(), 0).Err(); err != nil {
Both the PR description and the brief's round-6 record keep the correct qualifier
("never writes the mirror on the legacy path"); only this comment dropped it.
Detail under Quality/P2-1. This is a comment-vs-code mismatch, not a code-vs-spec
one, so it does not fail this gate — but note it is the sixth consecutive round in
which a documentation claim overstated the code, and this round every other claim I
spot-checked was accurate (the added Redis round trip is now disclosed in Summary
beside the sentence it qualifies; invalidateSeeded's docstring now states the real
smaller stake; the EXPECTED_MODE refusal text no longer implies it is an override;
persistHighWater now says plainly that the mark trails without bound).
Verified claims:
- Queue-key consolidation is complete:
grep -rn '"robotEvent:' modules/ pkg/ tools/
outside_test.goreturns exactly one hit,pkg/botevent/seq.go:244
(QueueKeyPrefix).rb.robotEventPrefixandbot_api's constant are both gone. - The three named tests exist and assert what the description says:
TestNotActivatedDoesNotQueryTheAuthorityPerAllocation(seq_test.go:711),
TestCounterKeyCannotCollideWithModeKey(:753),
TestKnownResidualZaddReorderingCanStillSkip(:905). payload.robot_idreally is client-only — no server-side path sets that message
payload field, so the new existence check cannot break a server-generated flow.- Round-7 P1-1 is genuinely fixed:
dropMirrorSource/dropUnauthorizedMirrorare
deleted outright rather than guarded, and
TestRegressedAuthorityDoesNotDestroyTheLegitimateMirrorasserts the mirror
survives a regressed authority. - Round-7 P2-1 is fixed:
refuseUnauthorizedMirrorreads the authority before judging
the mirror (tools/botevent-seq/main.go:382-392), andjudgeMirroris split out as
a pure function with the tool's first test file.
2. Code quality
Quality: Approved
No P0. No P1 reachable in the merged state. Findings below are P2, plus one that is
unreachable by merging and must be closed before activation.
P2-1 — the allocator still writes the mode mirror, and one comment says it doesn't
pkg/botevent/mode.go:178 vs pkg/botevent/seq.go:616-642
rebuildMirror := mirror != b.mirrorValue()
...
if rebuildMirror {
if err := client.Set(ModeKey, b.mirrorValue(), 0).Err(); err != nil { // :639
Two things:
-
The comment is wrong and it is load-bearing for a reader's model of who owns
this key. Restore the "on the legacy path" qualifier the description and the brief
both keep. -
The write is unconditional, so a process holding epoch
Nwill overwrite a
mirror at epochN+1without ever re-reading the authority. If that happened, every
replica validated againstN+1getsgateNotActivated, forces a refresh, and the
mirror flaps — each flap callinginvalidateSeeded()(seq.go:626), which drops
every bot's seed marker and triggers a fleet-wide re-seed (3 DB reads + a
ZREVRANGE + a Lua call per active bot).I do not think this is currently reachable, and that is why it is P2 rather than P1:
the only writer of a newer epoch isbotevent-seq, andActivatereturns early
oncemode == StateModeIncr(state.go:164-166), so epoch never advances past 1
without an unsupported manualUPDATE ... SET mode=0— which the tool itself
documents as having no online form (tools/botevent-seq/main.go:297). A
compare-and-set that refuses to lower the epoch would make the invariant structural
instead of depending on that argument holding.
P2-2 — the one read-storm the denial cooldown does not cover (blocker for activation, not for merge)
pkg/botevent/mode.go:500-511
The round-7 rationale for removing the mirror-delete is that "the delete's only benefit
was suppressing the read storm, and the cooldown suppresses that too" (mode.go:176-179).
The cooldown is armed in exactly one place — via noteMirrorUnauthorized at
seq.go:608 — which requires the authority read to have succeeded and said legacy.
The inconclusive branch does neither:
default:
authorityUnreadable.inc()
if prior != nil && prior.activated { ... }
if mirrorClaimsIncr {
return modeResolution{}, fmt.Errorf("botevent: the mode mirror claims activation but the "+
"authority is unreadable (%w); refusing to allocate ...", err) // no install(), no cooldown
}
So with a mirror claiming incr and an unreachable authority, nothing is cached and
nothing is throttled. resolveMode (:397-406) will not short-circuit — the negative
TTL is skipped because the mirror claims activation, and deniedMirror is empty — so
every allocation calls refreshAuthority, which holds beliefMu across
readStateDeadlined (:427-428, :460). Each one costs up to authorityTimeout
(300ms), serialized, inside a held msgSem slot.
Concrete failure: an activated fleet, mirror incr:1 present and correct, one replica
restarts during a MySQL incident. That replica has no belief, so every bot-event
allocation blocks ~300ms behind one global mutex. 100 msgSem slots serialize to ~30s
and stay held — which stalls message fan-out for every bot in the process, not just
bot events. That is precisely the hazard aedde27a and the "Latency bound" section
exist to prevent, and it is the same class as P1-C and round-7 P1-1.
Why this is not a merge blocker. In the merged, pre-activation state there is no
botEventSeq:mode key, so mirrorClaimsIncr is false, the negative belief is installed
and cached for negativeBeliefTTL, and a MySQL outage fails enqueues exactly as it
already does (GenSeq is DB-backed too). Reaching this branch pre-activation needs a
forged mirror and an unreachable MySQL — a double fault in a state where enqueues are
failing regardless. It is reachable only after activation, which puts it in the same
category as the two exposures rounds 5–7 agreed to move to #704.
Ask: add this to #704 as a named gap before -action activate is run anywhere. The
cheap form is to install a belief (or arm a short refusal cooldown) in the inconclusive
branch so the refusal is fast rather than re-read per allocation; the refusal itself is
correct — only its cost is wrong.
P2-3 — permanent per-value state is bounded by shape, not by cardinality
modules/robot/event.go:57-74, modules/botfather/api_user.go:472-493
plausibleRobotID bounds length and rejects whitespace/control characters, but any
distinct 1–40-byte ASCII string still passes, and on the existRobot-error
fall-through (event.go:239-243) it is adopted. Each adopted value can become a
botEventSeq:counter:{id} key with no TTL under noeviction plus a seq row that is
never reclaimed.
I checked how reachable that actually is, and it is narrower than it looks:
existRobot only errors on a Redis error or a DB error, and in either case the
allocator's own I/O fails too — a Redis error kills probeAllocatorState; a DB error
kills legacyCeiling / highWaterCeiling (pre-activation, GenSeq itself). So the
enqueue is refused and no permanent state is created. Creating state needs a failure
selective enough that rb.db.exist fails while the allocator's queries succeed.
Negative results are also not cached, so there is no robot:exist:* pollution.
This is net hardening against main, which had no check at all, so it is not something
to hold the merge on. Worth pairing with #704's reclamation sweep — the sweep is what
actually bounds this, and the deleteUserBot comment already says so.
P2-4 — orphaned doc comment
tools/botevent-seq/main.go:302-308
writeMirror's docstring now sits immediately above type mirrorVerdict int, so godoc
attributes it to mirrorVerdict, and writeMirror (:403) has no comment at all.
Introduced by 29c6787a. Move the three lines down to :403.
P2-5 — the counter itself has no float64 ceiling
pkg/botevent/seq.go:1035-1038
botevent-seq refuses a cutover floor above 2^50 (maxSafeFloor), but runGate
accepts any positive id, and nothing stops the live counter from eventually issuing ids
where float64(seq) stops distinguishing adjacent int64s — which would recreate the
exact pagination skip and multi-member ack this change removes. Arithmetically
unreachable (one bot would need ~9×10^15 events), so a nit; noting it because the guard
exists on the floor and not on the value.
3. Overall verdict
APPROVE
Spec ✅ and Quality Approved. The merged state is inert — legacyEventID is still the
only allocator until an operator flips the authority row, the GenSeq guard fails if that
delegation is ever deleted as dead code, and the one behavioural delta on merge (a Redis
EVALSHA per pre-activation allocation, with addInlineQuery as the only genuinely new
dependency) is now disclosed in Summary rather than buried further down.
Merging is explicitly not activation clearance. #704 must close first, and P2-2
above should be added to it.
4. Recommendations
- P2-1: restore "on the legacy path" in
mode.go:178; optionally make
seq.go:639a compare-and-set that cannot lower the epoch. - P2-2: add the
authority-unreadable + mirror-claims-incrread storm to #704 as
a named gap. Smallest fix:install()the inconclusive belief (or arm a short
refusal cooldown) before returning the refusal, so a restarted replica fails fast
instead of paying 300ms under a global mutex per allocation. - P2-4: move
writeMirror's docstring back ontowriteMirror. - Stop iterating on this PR. Eight rounds is well past the point where further
rounds cost more than they find: this round's delta is 4 commits, the one blocking
finding from round 7 was fixed test-first, and everything above is P2. The remaining
risk is not in the diff, it is in the activation procedure — so the productive move
is to land this and give #704 a single owner with the activation checklist, rather
than open a ninth round on comment wording. Flagging this for the maintainer as a
process call, not a code one.
5. For a human to verify (this PR is labelled needs-human-review)
- Production Redis configuration — highest priority. The whole co-recovery
durability argument (seq.go:38-51), the "counter cannot be evicted" argument
(:89-91) and the RDB-rollback safety margin all rest onappendonly no,
maxmemory 0/noeviction, andsave 3600 1 300 100 60 10000, plus the counter
living in the same instance and db as the queue. None of that is checkable from
the repository, and if any of it is untrue the design's safety argument changes shape
rather than degrading gracefully. Confirm before activation. payload.robot_idis checked for existence, not authorization — pre-existing,
correctly routed to an owner in an earlier round and not changed here. Any
authenticated sender can still name any existing bot in a message payload and have
an event enqueued onto that bot's queue. Independent of #697; still open.- The migration
Downguard depends on MySQL ≥ 8.0.16 actually enforcingCHECK
(modules/robot/sql/20260805000001_bot_event_seq_state.sql:48-52). The stated
premise is that the project is pinned to MySQL 8.0; on an older server theDown
would silently drop the authority row whilemode=1. Verified by comment only. modules/groupandmodules/botfatherare reported as failing in the author's
environment and also on the previous head. I did not reproduce either; CI is the
authority.
6. Additional findings (outside the requested scope)
- Two consumers of one queue with different cursor semantics.
modules/bot_api/events.go:272readsMin: fmt.Sprintf("(%d", eventID)— exclusive.
modules/robot/api.go:1085readsMin: fmt.Sprintf("%d", eventID)— inclusive.
Pre-existing and untouched, but worth naming here because this PR substantially
expands theeventPagedocstring (events.go:174-219) to assert "the exclusive
cursor" as a property of the queue. It is not a property of the queue; it is a
property of one of its two readers. The two readers also merge different sources
(getEventsResultinmodules/robotfolds ininlineQueryEventsMap), so the
docstring's reasoning is correct forbot_apiand does not transfer. addInlineQuerywrites to a process-local map while sharing a fleet-wide cursor.
An inline-query event allocated on replica A lives only in A's
inlineQueryEventsMap; if the bot then long-polls replica B and receives a queue
event with a higher id, its cursor advances past the inline event, which A will then
filter out forever. This is real, but it is not introduced or worsened here —
before this PRaddInlineQueryalready called
ctx.GenSeq(common.RobotEventSeqKey + robotID), i.e. the same sequence as the queue.
The PR description documents an intermediate revision that gave inline queries their
own key (which would have been much worse) and reverted it. Recording it as a
pre-existing architectural issue worth its own issue, not as a finding against this
diff.- Guard blind spot, cosmetic.
chokepoint_guard_test.go:42and:147still match
onrobotEventPrefix, a field this PR deletes. Harmless — it keeps the guard able to
recognise a reintroduced spelling — but it now matches nothing in the tree.
7. Review method and coverage limits
What I verified locally, in a clean worktree at the head SHA:
go build ./... clean; go vet clean on pkg/botevent, tools/botevent-seq,
modules/robot; gofmt -l clean on every file this PR touches; go test ./tools/botevent-seq/... ok; all source guards pass (chokepoint, GenSeq,
key-collision, and both "would this guard actually fail" tests).
What I could not verify. No MySQL or Redis in this environment, so TestMain
skipped ~40 integration tests — I confirmed the skip path fires with the documented
stderr note, which also confirms the CI=true hard-failure mechanism is wired the way
the description claims. Everything carrying the safety argument (seeding above all
three ceilings, activation with no cache window, rollback detection,
TestExclusiveCursorIsLosslessWithMonotonicIDs,
TestKnownResidualZaddReorderingCanStillSkip) is therefore unverified by me — I
read those test bodies and confirmed they assert what they claim, but CI is the
authority on whether they pass. Also unassessed: production Redis/MySQL configuration
(item 5.1), and the two packages the author reports as environmentally broken.
I ran two independent advisory passes alongside my own read. Both returned, and both
returned degraded, so I am naming that rather than treating their silence as
agreement:
- One pass reviewed the change via patch fetches rather than whole files and ran with
reasoning disabled; it returned nothing at all on the belief-cache interleavings or
the tool's activation ordering, which were two of the six areas it was asked about.
It independently reached P2-1, which is why that finding is stated with the most
confidence here. - The other pass truncated at its token limit mid-finding and saw only the first 2000
lines of a 5687-line diff, so most ofseq.go,mode.go,state.goand the tool
were never in its window. Two of its four findings were attribution errors against
the pre-image — a guard regex it read as too narrow (the alternation's first branch is
a bare substring that matches every spelling, and
TestGenSeqGuardWouldCatchAReintroduction:130-131pins exactly the shapes it worried
about), and anif/else ifcontrol-flow claim that is identical in the pre-image — and
I am not carrying either forward.
Neither pass covered state.go's Activate CAS, the migration's Down trick, or the
modules/group / modules/botfather producers; that gap is mine and I read all three
directly.
|
Follow-up to my approval above — non-blocking, no re-review needed, the approval stands. One additional P2 surfaced from a second pass over the regression-recovery path, plus a correction to a plausible-looking fix so it doesn't get applied as written. P2-6 —
|
Summary
Bot event ids — the sorted-set scores of
robotEvent:{robotID}— came fromocto-lib
GenSeq, a per-process HiLo block allocator. The queue is read withan exclusive cursor (
ZRANGEBYSCORE key (cursor +inf) and acked by score(
ZRemRangeByScore(id, id)), both of which require strictly monotonic, uniquescores. With three replicas they are neither, and events are permanently lost.
This PR replaces the allocator with a per-bot Redis
INCRcounter behind anactivation gate whose state is authoritative in MySQL. Merging keeps the same
allocator: until an operator activates, every replica delegates to
GenSeq.The one thing that is not the same on merge, stated here rather than further down
because it is the merge-safety claim (review rounds 5–7 all raised it): a
pre-activation allocation now costs one Redis round trip before delegating —
probeAllocatorState, which reads the mode mirror and whether this bot's counterexists.
GenSeqserved 999 of every 1000 allocations from a process-local block withno I/O at all. There is no DB round trip (pinned by
TestNotActivatedDoesNotQueryTheAuthorityPerAllocation), and for queue writers thereis no new dependency either — the
ZADDon the same path already requires Redis.addInlineQueryis the sharpest case and worth naming: it writes an in-memory map andpreviously needed no Redis at all to obtain an id.
Scope, stated up front: this removes the collision and cross-restart
inversion classes of loss. It does not make the exclusive cursor lossless.
Allocation and publication are two operations, so a producer that allocates
Nandstalls while another publishes
N+1still losesNonce the consumer's cursor passesit — and the doorbell makes that more likely, since the wake is triggered by the very
ZADD that creates the inversion. That residual is unchanged here, is now pinned by a
test asserting it still happens (
TestKnownResidualZaddReorderingCanStillSkip),and needs a re-delivery window below the cursor, which
modules/bot_api/events.go:186-199itself names as the other half of the fix. Anearlier revision of this description called the result lossless; that was wrong.
Related Issue
Part of #697 — this PR does not close the issue. It is behaviour-neutral on merge
(the allocator stays on the legacy path until an operator activates it), so closing
requires deploy + activation + the three checks in that issue's acceptance section.
Do not use a closing keyword when merging. Related to #698, #700, and
Mininglamp-OSS/octo-lib#114 for the upstream half of the root cause.
Merging is not activation clearance. #704 carries two rollback-detection findings
from the fourth review round that are deliberately not fixed here, and it must be closed
before
botevent-seq -action activateis run anywhere. That ordering is safe becausethis revision makes the merged state genuinely inert (see Round 4 below); it would not
have been safe against the previous head, where a stray Redis key could activate the
counter on its own. The tool's
-yesrefusal names #704 so the gate is not only in thisdescription.
#697's acceptance criteria were narrowed to match what this PR actually delivers —
collisions and block-driven inversion — with the allocate-then-publish reordering
window split into a follow-up. The rationale is recorded on that issue; the short
version is that the two failures differ in kind (systemic and self-worsening versus
single-event and non-accumulating), and closing the reordering window first requires
resolving an atomicity-boundary conflict with
modules/bot_mention, which commits anidempotency claim atomically with the queue write and therefore needs the event id
before publishing — the opposite of assigning it inside the publish. I prototyped
the atomic version far enough to confirm that conflict, then reverted it rather than
grow this PR into a cross-module design change.
Linked Spec
.octospec/tasks/bot-event-score-monotonic/brief.md(included, with a"实现记录" section recording where implementation and review corrected the
original brief).
The defect, measured
One bot queue, 644 events: 19 ordering inversions, every one on a 1000-block
boundary, max time regression 6.7 days. Across 1948 queues, 2624 colliding
scores in three of them, still accumulating (one queue's duplicate count
went 170 → 358 within an hour, five hours after the last restart — so the
addOrUpdateSeqwrite-back races on the extend path too, not just on coldstart). Every duplicated score holds exactly two members and every duplicate
range starts at
…X001, i.e.min_seq + 1.tools/genseq-reproreproduces all three loss paths against the realconfig.Context.GenSeqand the real read/ack shapes:cold-start reads both precede either write-back issue the same 12 ids.
(cursorexcludes the second member forever. No time inversion needed;getEventsuses limit 20..100, so on a queue with >1000 duplicates this isroutine.
cursor and no amount of polling reaches them.
ZRemRangeByScore(id, id)deletes every membersharing the score, so acking a delivered event destroys an undelivered one.
Field reports match the mechanism's staging exactly: a card works at first, then
some users in a group cannot act on it, then no newly sent card is interactive at
all. The cursor is monotonic and never rewinds, so once it enters a high block
every id the main writer issues from a lower block is invisible — and each
restart opens yet another block, so it recurs.
Changes
Allocator (
pkg/botevent/seq.go)INCR, in the same instance and db as the queue. Thatco-location is the durability argument, not an accident: production runs
appendonly no, so a crash rewinds the counter — but it rewinds the queue withit, so resuming cannot collide with anything that survived. Recorded as a hard
constraint, because "moving the counter somewhere more durable" would silently
break it.
maxmemory 0/noevictionrules out eviction.max(queue ceiling, legacy seq row) + 2×step.Both sources are needed: the queue covers ids already enqueued, the
seqrowcovers a bot whose queue has drained but whose clients still hold a high cursor.
queue is the defect.
Activation state is authoritative in MySQL
octo_bot_event_seq_state(singleton:mode/epoch/cutover_floor), flippedby a
FOR UPDATEcompare-and-set that validates the floor against the observedmaximum and refuses with
ErrFloorTooLowotherwise. Same shape asocto_message_extra_version_state.Review found that keeping the mode only in Redis was unsafe: production runs
appendonly no, so an RDB rollback drops it, and falling back to legacy then issuesGenSeq ids below everything the counter had handed out — #697 mirrored. The Redis
key is now only a mirror, whose sole purpose is letting the hot path check the
mode inside the same Lua script as the
INCR. A missing mirror makes the allocatorread the DB row and, if activated, rebuild the mirror and re-seed rather than degrade.
FOR SHAREdrain barrier is deliberately not copied from #627: arobotEventwriter is
INCR+ZADDwith no transaction, so there is nothing to hold a lockuntil, and wrapping each allocation in a transaction to borrow the barrier would
reintroduce the per-message DB round trip the allocator exists to avoid. The
consequence is documented rather than hidden: this design cannot drain in-flight
writers at the flip, and relies on the operator confirming no pre-fix replica runs,
plus a brief write pause.
Activation gate — one entry point, asymmetric cache (
pkg/botevent/mode.go)Round 4 found the gate had ordering and trust gaps of its own, all three of them the
same missing thing: no single stateful entry point for "is the counter activated". The
invariant is now one line — the authority decides, the mirror can only shortcut,
never override — and the cache behind it is deliberately asymmetric:
resolves
incrit may refresh the epoch upward but can never decidelegacyagain —not on a DB error, a rolled-back row, or a dropped table. The downgrade branch is
gone, not guarded.
claiming
incragainst a cachedlegacyis a conflict, and a conflict forces afresh authority read. This is what preserves the no-divergence-window property the
previous revision achieved by reading the DB per allocation: propagation of the flip
never waits for a TTL, because the first allocation that sees the new mirror re-reads
the authority. The TTL only bounds the case where the operator's mirror write failed.
legacy, loudly(
dmwork_bot_event_seq_mirror_unauthorized_total). It does not split the fleet,because every replica reads the same row and reaches the same conclusion —
consistency comes from the authority, not from the mirror. That sentence is what the
previous revision was missing.
The gate still reads the uncached mirror inside the same Lua script as the
INCR,so the brief's D2 reasoning is preserved, not overturned: what D2 forbids is deciding
the gate from a cache, and only the authority behind the mirror is cached.
The mirror value carries the generation (
incr:{epoch}) and the gate compares againstthe exact string this process validated, so a hand-written
SET botEventSeq:mode incrcannot open the gate even for one allocation — it carries no generation, so all it can
do is force an authority read. This is the mechanism
epoch's column comment claimedand did not have.
Ordering: seed, then publish the mirror. Writing it first opens the gate for every
bot in every replica while only one has been raised to its floor. And a lost mirror now
invalidates every seeded marker, not just the bot being allocated for — a lost mode
key is evidence Redis lost data, so every counter this process believes it seeded is
suspect. The mirror is global; a seed is per-bot.
tools/botevent-seq(preflight/activate) cannot verify the threepreconditions that matter, so it demands
-yesand now names all three: #704 closed,no pre-fix replica remaining, and the brief write pause. No online deactivate: going
back would put legacy ids below consumer cursors, the same loss mirrored.
Producers
botevent.NextEventID, enforced by a source guard.botevent.QueueKeynow. Two earlierrevisions of this description got this wrong in opposite directions — first claiming
it was done, then correctly reporting four of five.
rb.robotEventPrefixandmodules/bot_api's own constant are both deleted, so there is one spelling and thechokepoint guard can see all of it.
payload.robot_idis validated againstexistRobotlike the three sibling branchesbeside it. It is the only branch whose value comes verbatim from a client's message
payload, and the allocator turns an unknown value into a permanent Redis counter
key (no TTL,
noeviction) plus aseqrow that is never reclaimed — where GenSeqcost only a TTL'd queue key and a row. This closes one of the three items review asked
a human to verify; it was answerable from the code.
addInlineQueryshares the same source. Its events are merged with the queueby
getEventsResult, sorted byEventIDand filtered by one shared cursor, soa separate sequence would push the cursor into the millions and permanently
filter every ordinary event behind it.
Guards
Two source guards, both proven able to fail: every queue
ZADDmust go throughthe allocator, and no bot event id may come from
GenSeqoutside the singleallowlisted delegation. The second guard additionally fails if that delegation
disappears — deleting it as dead code would make a deploy switch allocators on
its own. A docstring claiming a single chokepoint was already wrong once
(PR #685); these are tests instead.
Round 7 findings fixed here
One blocking finding, and the reviewer reproduced it against real MySQL + Redis. I reproduced
it too — the test was written and watched failing before any code changed, which is the
discipline the previous three rounds were missing.
allocatehascounterExistsatseq.go:596, deletes the mirror at:604, and only at:611uses that same value to conclude "the authority regressed". So it destroys the mirror on the authority's word before establishing the authority is the unreliable party. One cold replica then takes every healthy activated replica's gate down!counterExistsguard — that narrows the window without closing it (the mirror is global, a counter is per-bot). The delete's only benefit was suppressing the read storm, which the cooldown also does, so it is gone rather than conditionedactivateis not idempotent and its refusal asserts a fact it never checked: it fatalled on any mirror claim without reading the state row, so re-running after a successful flip died telling the operator toDELthe live mirroraddInlineQuerynamed as the only genuinely new dependencymode.go's "the race is benign" was wrong in both halvesplausibleRobotIDsays it bounds bytes deliberately (and why utf8mb4 characters differ, and why that is unreachable today); the write-onlyconfirmedfield is deleted;install's never-returned error is gone;invalidateSeeded's docstring states the real, smaller stakeThe cost of the fix, stated rather than hidden. The denial cooldown is back — 1s, keyed on
the exact denied value — so a genuine activation writing byte-identical bytes to a previously
denied mirror is not noticed for up to that second. That window cannot be zero without reading
the authority per allocation, because nothing visible from Redis distinguishes a forged
incr:1from the real one. It is only entered when a mirror claiming activation already sitsagainst a legacy authority, which
botevent-seqnow refuses to flip on top of, and it isarmed only when no counter exists — with one, the authority is the regressed party, every
allocation for that bot is refused anyway, and suppressing the read would only delay noticing
recovery.
Round 6 findings, and one decision that needs naming
Both blocking reviewers found the same thing again: the fix from the previous head was
wrong in a new way. So one item here is a decision rather than a patch.
msgSemslots (~67 producing bots saturate 100)Activatebumps epoch 0 → 1 and the tool writes exactlyincr:1, byte-identical to a forgedincr:1already in Redisbotevent-seqalso refuses to flip while such a key is presentinvalidateSeeded's delete order can leave a bot marked seeded with its companion state wipedseededcleared last; the worst interleaving now leaves a bot unseeded, which the next allocation fixesEXPECTED_MODE=incrwill not unblock acounterExistsrefusal — it is a fail-closed assertion)existRobotfail-open path can adopt an arbitrary client stringrobot.robot_idcolumn width, no whitespace or control characters. No charset allowlist — the column stores whatever it is givenWhy the durable-mark bound leaves this PR
I worked the arithmetic out before patching it a third time, and it says a patch cannot fix
it:
So the first id after any seed is already outside what a recovery from an unchanged mark
could reach: the exposure starts immediately, not after some grace. Closing it requires
advancing the mark at seed time, and a mark recording the seeded value feeds back into
the next seed's floor — every re-seed compounds by a block, which also breaks the no-op
property
TestSeedIsIdempotentAndNeverLowersguards. That is a change to what therecovery floor is, which is the same question #704 already owns for rollback detection.
Both reviewers recommended exactly this, and yujiawei flagged it as needing an owner's call.
Taken:
persistHighWaterkeeps the throttle and the metric, and now says plainly thatthe mark trails without bound while writes fail.
TestFailedDurableWriteIsThrottledAndDoesNotFailTheEnqueueasserts the gap is stillthere, so it becomes the regression test the day #704 closes it. #704 gained the
arithmetic and both failed attempts so the road is not taken a third time.
This is strictly more honest than either previous revision: one asserted a bound it did not
have, the other over-triggered and dropped events. Neither delivered the property, and the
exposure is unreachable before activation — which #704 gates.
Round 5 findings fixed here
Both blocking reviewers found the same defect, and it was mine from the previous head:
the P1-7 bound recorded as fixed did not fail closed. Six items fixed here; the two
rollback-detection findings stay on #704.
M+2999against a margin of 2000, so a restarted process silently resumed atM+2001beneath cursors already atM+2999seedSafetyMargin, checked on every allocation. The failure counter (and its non-atomic read-modify-write) is gonemode.go's central invariant statement was false — a positive belief never re-reads the authority, so a post-activation authority rollback does split the fleet — and #704's scoping argument was quoting itmsgSemslot, with no exit — nothing clears a forged key. Same hazard class as the P1-1 read removed last headDelsites still formatted the key, plus a dead fieldgrep -rn '"robotEvent:' modules/is empty outside testspayload.robot_iddropped the event whenexistRoboterrored, turning a DB blip into silently lost bot events"robotEvent:%s"but not"robotEvent:" + robotID;TestMainprobedSELECT 1/PINGbut not the twoCREATE TABLEs that actually gate the tests; the expected-mode test hook raced with production readspayload.robot_idis checked for existence, not authorization (pre-existing)Two things the tests forced out, worth naming because a read-only review cannot see
them:
recovery waited for ~1000 more refused allocations after the DB came back — unbounded
wall-clock time for a low-traffic bot. Past the bound the probe now uses a time budget.
It has to stay throttled: a failing
INSERTwith a 300ms deadline in a heldmsgSemslot is a throughput cliff for every bot in the process, doomed enqueue or not.
a base of 0, so checking first refuses its very first allocation before trying to
record it.
Round 4 findings fixed here
Both blocking reviewers independently found the same critical defect, and one found a
regression the previous head introduced. Six of the eight P1s are fixed here; two are
#704.
SELECTper allocation inside a heldmsgSemslot — the merge was not behaviour-neutral, and it re-entered the hazardaedde27aboundsmirrorMissingcould return a GenSeq id for a bot this process had already issued counter ids forReadStatedistinguishes missing-table from unreachable;legacyDelegaterefuses iflastIssuedholds the bot; malformedEXPECTED_MODEfails closedINSERTper event) nor bounded the exposure the seed margin is supposed to coverhighWaterFailureLimitconsecutive intervals, then allocations failSCAN robotEvent:*, so a drained-queue bot contributed nothing to the floor that protects it — andscalarSeqturned every DB error into 0MAX(min_seq)per namespace; refuses partially failed evidence as it already refused sampled evidenceseeded.Deleteonly appears in already-detected branches)DB deadlines no longer depend on the DSN: every call on this path uses
LoadContext/ExecContextwith an explicit timeout, so the "does production setreadTimeout?" question stops being this path's only protection.P2s fixed: the migration
Downrefuses whilemode=1(P2-2); the floor is rejectedabove 2^50, where float64 scores stop distinguishing int64 ids and the tool would
recreate the collisions it exists to prevent (P2-3); a
TestMainfails loudly underCI=trueinstead of letting the whole integration suite silently reduce to sourceguards (P2-11 — the same failure mode as the
botevent_testdatabase an earlierrevision used); the GenSeq guard asserts on the key symbol rather than on
GenSeq(...key)on one line, which a two-line refactor walked past (P2-5); the consumerdocstring no longer describes GenSeq as the id source (P2-10);
lastPersisteduses thesame CAS as
recordIssued(P2-9); the bot-teardown decision not to delete the counteris written down (P2-4);
ModeLegacy, the sentinel-matching contract, and theunconditional "seedSafetyMargin covers it" docstrings are corrected (P2-6/P2-7, P1-7);
NextEventIDrejects a padded robotID rather than trimming it into a different key thanits callers use (P2-12).
Not changed: the
.gitignoreentries for the other operator tools (P2-1 nit). They aredefensive against exactly the mistake an earlier revision of this PR made — a 41 MB tool
binary committed by
git add -A— so they stay.Latency bound
The allocator runs inside a
msgSemslot onsaveRobotMessage. 100 held slotsstall fan-out for every bot in the process, which is the hazard that forced the
doorbell off the producer goroutine — except this cannot be asynchronous, since
there is no id to enqueue without it. So it fails fast: 500ms dial / 300ms read,
one retry, ~1s worst case. Giving up fails one enqueue; waiting stalls everyone.
Testing
Run the way CI runs it —
-race -shuffle=on -count=1with a per-package databasereset, matching
.github/workflows/ci.yml:pkg/botevent(46 tests, 0 skipped)tools/botevent-seq(new)pkg/redis(raw-client chokepoint guard)modules/robotmodules/bot_apimodules/messagemodules/groupmodules/botfathermodules/groupandmodules/botfatherdo not pass in my local environment, and they donot pass on the reviewed head
2f7e58daeither — verified in a clean worktree at thatcommit, same signatures: 30 s waits ending in a nil dereference (
group) and a nilinterface conversion (
botfather), which point at a WuKongIM interaction thisenvironment does not satisfy.
modules/robotdid fail until I recreated the testdatabase with
utf8mb4_general_cias CI does; with the right collation it passes.Reporting this rather than listing the two as green: the packages that exercise this
change (
pkg/botevent,pkg/redis,modules/robot,modules/bot_api,modules/message) are green, and CI is the authority on the other two.modules/messageis named by the brief (it owns thecard_actionD4 idempotencyclaim) and was missing from an earlier revision of this description — review caught
that. The migration was verified applying through real sql-migrate (
gorp_migrationsrow present, state table created).
A
TestMainnow fails loudly when MySQL or Redis is missing andCI=true. EveryseqTestCtxcall site used tot.Skipf, so a credential or dependency break would haveleft only the source guards running while
go testexited 0 — indistinguishable frompassing, on the tests that carry the entire safety argument. Locally it still skips.
pkg/boteventruns against a real MySQL and Redis in the standardtestdatabase.An earlier revision used a dedicated one to avoid creating the migration-owned
seqtable in a shared schema; that was worse, because CI never creates that database, so
every integration test here silently Skipped — indistinguishable from passing. CI
drops and recreates
testbefore every package, which is what makes creating thetable here safe. Covers concurrent
strict monotonicity, seeding above both ceilings, seed idempotence and
never-lowering, fail-closed on a seed failure induced while
INCRstays usable,pre-activation delegation, activation taking effect with no cache window, and
and draining a quiesced queue through the exclusive cursor at page size 1 (which
proves uniqueness, not the absence of the reordering residual above).
modules/robot,modules/group,modules/bot_apifull packages pass, each on afreshly recreated test database.
go build ./...,go vet,gofmt,make i18n-extract-check,make i18n-lint,git diff --checkall clean.Rollout
process-local; both gaps are only reachable after activation, so merging is safe and
activating is not.
once per process per interval and every allocation goes through
GenSeqwith no DBround trip. One added Redis round trip per allocation — see the note in Summary; it
is not free, and it is not a new dependency for queue writers.
the gate's whole safety precondition — activating while a legacy replica still
issues from the bottom of a block is the loss this PR fixes, self-inflicted.
there; activation adds the counter
INCRon top, so every allocation is a Redis roundtrip. Neither batching into blocks nor caching the mirror the gate compares against is
available — both reintroduce the defect.
botevent-seq -action activate -yes(it refuses sampled evidence and validatesthe floor against all three sources), then re-run
preflight: the duplicate countmust stop growing. It will not drop — existing duplicates are left
alone deliberately, since an ack deletes every member sharing a score and there
is no record of which of a pair was ever delivered.
OCTO_BOTEVENT_EXPECTED_MODE=incrsoa lost mirror and an unreadable authority fail closed instead of degrading.
Setting it before the flip fails every enqueue closed, so it is last. A malformed
value now fails closed rather than reading as unset, so a typo cannot quietly disarm
it.
dmwork_bot_event_seq_mirror_unauthorized_total— a replica saw a mode mirror theauthority did not confirm (a forged key, a shared Redis, a restored snapshot). The
allocator removes the contradicted key and stays on legacy, so this is self-healing
and the metric is the only signal.
botevent-seq -action activatealso refuses toflip while such a key is present, so this should be zero before step 4.
dmwork_bot_event_seq_counter_without_authority_total— a replica found a bot'scounter while the authority said not-activated, i.e. the authority regressed under an
activated fleet. This does not self-heal: every enqueue for that bot is refused,
deliberately, because a GenSeq id there lands below the bot's live cursor. Restore
the
octo_bot_event_seq_staterow. SettingOCTO_BOTEVENT_EXPECTED_MODE=incrdoesnot unblock it — that flag is a fail-closed assertion, not an override.
dmwork_bot_event_seq_high_water_write_failure_total— the durable mark is notadvancing. Not fatal to delivery, but it is the exposure botevent: counter-rollback detection is tolerance-bounded and process-local (activation gate for #697) #704 owns; see the note under
Round 6 below.
COMPREHENSION
What does this change actually do to the load-bearing path?
It replaces the id source for every bot event with a strictly monotonic
counter, and puts one Redis round trip on the hottest producer path where
GenSeqmostly used none — including before activation, where it is a proberather than an allocation. Until activation the allocator does not change: the
allocator delegates to
GenSeq, so the deploy is a no-op and the switch is aseparate, reversible-only-forward operator action.
What could break because of it (dependents + failure mode)?
Activating while a pre-fix replica still runs would lose events exactly as
today — mitigated by the gate plus an explicit manual precondition, not by
code, which is why step 2 above is stated as a hard gate. A stray Redis key can
no longer substitute for that gate: the authority is consulted before a positive
mirror is believed, and the mirror carries the generation the authority named. Seeding below a
client's existing cursor would make new events unreachable — mitigated by
seeding from both the queue ceiling and the
seqrow; the residual case is acursor taken from an already-acked high id combined with a regressed
min_seq,which the 2×step margin covers for one regression but not for many, and which
can only be confirmed by observing recovery. A slow Redis now costs the
fan-out path up to ~1s per allocation, bounded deliberately so a degraded
Redis fails enqueues rather than exhausting
msgSem.How do you know it works (specific test/repro/trace)?
tools/genseq-reprodemonstrates the defect end to end — deterministiccollision, both skip paths, ack collateral damage — and its assertions are
mirrored in
pkg/boteventagainst the new allocator where they pass.TestNotActivatedDelegatesToLegacyproves the deploy is neutral;TestActivationTakesEffectWithoutAProcessCacheWindowproves the flip has nodivergence window;
TestSeedClearsTheLegacyCeilingAtActivationproves thefirst activated id clears a reserved-but-unfinished legacy block;
TestExclusiveCursorIsLosslessWithMonotonicIDsdrains a quiesced queue throughthe real read shape at page size 1 — note it consumes only after production
finishes, so it proves the allocator's uniqueness and not the absence of the
reordering residual, which
TestKnownResidualZaddReorderingCanStillSkiprecordsas still present.
TestCounterRollbackIsDetectedAndHealed,TestMissingCounterFailsBeforeIssuingFromOneandTestMirrorRebuiltFromDBAuthoritycover the three Redis-data-loss shapes; theproduction numbers come from read-only
SCAN/ZRANGEmeasurement.Checklist
.octospec/)