Summary
POST /v1/bot/events paginates the authoritative queue with an exclusive lower
bound (ZRANGEBYSCORE key (cursor +inf, modules/bot_api/events.go:251), while the
scores written into robotEvent:{robotID} come from octo-lib GenSeq β a
per-process HiLo block allocator (config/seq.go: seqStep = 1000, blocks cached
in a package-level seqMap, guarded only by a process-local mutex, and min_seq
written back with an unconditional ON DUPLICATE KEY UPDATE).
With more than one replica, several 1000-wide blocks are live simultaneously, so
event_id order and real enqueue order are decoupled. Once a consumer's cursor
advances into a higher block, every event subsequently enqueued from a lower live
block becomes permanently unreachable: the exclusive Min excludes it, and nothing
ever re-delivers below the cursor.
events.go:182-213 already records the adjacent assumption ("uniqueness is assumed
and unverified", PR #685 review P2-3), but scoped the consequence to score
collisions. Ordering inversion alone is sufficient β no collision required β and
that is what we measured in production.
Evidence (production, read-only measurement)
One bot event queue, 644 members:
- 19 adjacent ordering inversions, every one landing exactly on a 1000-block
boundary (β¦X007 β β¦(X+1)001)
- maximum time regression 6.7 days (a higher
event_id carrying a strictly older
event than its predecessor)
- the deployment runs 3 replicas
Collisions are also already happening, not just inversions
The measured queue above has unique scores, so it demonstrates the inversion path in
isolation. A full read-only sweep of 1948 non-trivial queues found 3 with
duplicate scores:
| members |
distinct scores |
duplicates |
| 70565 |
70395 |
170 |
| 25136 |
23932 |
1204 |
| 16588 |
15526 |
1062 |
The duplicate counts clustering near multiples of seqStep is the signature of a whole
block being handed out twice. The mechanism is addOrUpdateSeq:
insert into `seq`(`key`,min_seq,step) values(?,?,?) ON DUPLICATE KEY UPDATE min_seq=VALUES(min_seq)
The written-back min_seq is computed from process-local seq.CurSeq + seqStep
and the update is unconditional, so a replica whose block is behind can move
min_seq backwards. A replica that cold-starts afterwards reads an already-issued
range and re-issues it.
Collisions add a second, worse loss path on top of the skip. ackEvent /
eventAck delete by ZRemRangeByScore(key, id, id) (events.go:160, :324), which
removes every member sharing that score β so acking one delivered event silently
destroys an event that was never delivered to anyone. events.go:182-213 predicted
exactly this and recorded it as assumed-not-happening; it is happening.
Restricted to card_action events, where acted_at is a server-side time.Now()
(modules/message/api_card_action.go:253) and therefore directly comparable:
| event_id (suffix) |
acted_at |
| β¦4007 |
T+0s |
| β¦4012 |
T+560s |
| β¦4013 |
T+590s |
| β¦5009 |
Tβ29s β older event, ~1000 higher score |
| β¦5010 |
T+672s |
Two blocks (β¦4xxx, β¦5xxx) were demonstrably issuing concurrently. A consumer that
polled at any point after β¦5009 landed holds a cursor β₯ β¦5009, so β¦4012 and
β¦4013 can never be delivered to it.
This is not a consumer-side misuse
The server advances the cursor by max observed event_id inside its own long-poll
hold:
for _, r := range raw {
if r.EventID > page.cursor { page.cursor = r.EventID } // events.go:235
}
A single hold that reads a high-block event and then receives a low-block enqueue
skips it server-side, regardless of how the bot manages its cursor.
Impact
Silent, permanent loss of bot events, including card_action. The user-visible
symptom is that clicking an interactive card does nothing β and specifically that it
works for some users on the same card while silently failing for others, depending on
which block their click landed in.
The loss is not self-healing. The D4 idempotency claim keys on
(message_id, action_id, operator_uid) with a TTL of Robot.MessageExpire
(default 7 days) and deliberately excludes inputs, so a user whose event was skipped
receives {"accepted":true,"replay":true} on every retry within that window. The D8
assumption that "a re-tap after timeout heals it" does not hold here: the retry is
absorbed by the dedupe key and the skipped event is never re-enqueued.
Proposed fix
- Strictly monotonic score source for
robotEvent β Redis INCR, or the
transactional per-key DB sequence pattern already built in internal/msgextraseq
for the same class of GenSeq defect on message_extra.version. This is the
actual fix.
- Re-delivery window below the cursor β read from
cursor - margin and have
consumers dedupe on event_id. Cheaper, but only narrows the window instead of
closing it; acceptable only as an interim mitigation.
Either way, the exclusive-cursor contract in events.go should state that it requires
a monotonic score, and the invariant should get a test that fails under concurrent
block allocation rather than a comment saying it is assumed.
Note that the five ZADD producer sites into robotEvent:{robotID} (enumerated in
modules/robot/api.go:224-235) must all move to the new allocator together; a partial
cutover reintroduces exactly the two-live-block condition this issue is about.
Related
Same root-cause family as the message_extra.version work tracked in #627, which
replaced GenSeq with a transactional per-channel sequence β but only for
message_extra writers (pinned_message.version is in that scope via
seqStore.ReserveTx). The robotEvent score was never covered, and activating that
cutover has no effect on this path. The ordering assumption was first written down in
PR #685.
Two further instances of the same defect are covered by neither #627 nor this
issue, and should be triaged separately:
conversation_extra.version β GenSeq(common.SyncConversationExtraKey)
(modules/message/api_conversation.go:195), read back with
Where("uid=? and version>?") (db_conversation_extra.go:29)
reminders.version β GenSeq(common.RemindersKey)
(modules/message/api_reminders.go:172), read back with reminders.version>?
(db_reminders.go:93-102)
Both are exclusive-cursor delta sync over a GenSeq-allocated version, and both use a
single global seq key (no uid/channel suffix), so one non-monotonic allocation is
shared by every user of that feature β a wider blast radius than the per-bot queue this
issue is about. Symptom would be silently missing reminders / conversation-extra state
rather than missing bot events.
Acceptance β narrowed, and what closing this requires
This section narrows the scope of this issue. It is not a claim that the defect
is fixed; it is a statement of which of the failures described above this issue is
accountable for, so that it has a reachable completion condition.
In scope β closing this issue requires all three
- No new colliding scores.
botevent-seq -action preflight duplicate count stops
increasing. It will not drop to zero: existing duplicates are deliberately left
alone, because an ack deletes every member sharing a score and there is no record
of which of a pair was ever delivered.
- No block-driven inversions. The allocator no longer hands out ids from
per-process blocks, so the 19 inversions with a 6.7 day maximum regression measured
here cannot recur.
- Observed effect after activation. Track whether reports of "clicked the card,
nothing happened" go away:
- gone β the residual below does not matter in practice, and closing here is
accurate
- sharply down but not gone β the remainder is the reordering residual, which
gives the follow-up a real priority instead of a guess
- unchanged β the diagnosis in this issue is wrong and it should stay open
Out of scope β tracked separately
Allocation and publication are two operations. A producer can allocate N, stall
on marshalling or a GC pause, while another allocates N+1, publishes, and rings the
doorbell; the consumer wakes on that ZADD, advances its cursor past N, and the first
producer's publish then lands below it. Monotonic ids do not close that window β it
needs the allocation and the ZADD to be one atomic operation, or a re-delivery window
below the cursor, which is what modules/bot_api/events.go:186-199 calls the other
half of the fix.
A follow-up issue will track it. Two things make it a separate piece of work rather
than an omission here:
- It requires resolving a conflict with
modules/bot_mention, which atomically
commits an idempotency claim together with the queue write and needs the event id
before publishing β the opposite of assigning the id inside the publish. That is
a cross-module design decision about which atomicity boundary wins.
- Its severity is different in kind, not just in degree. Block inversion was
systemic and self-worsening: once a cursor entered a high block, every subsequent
event from a replica holding a lower block was invisible, and each restart opened
another block. The reordering window loses one event and does not move the
cursor relative to later ones, so it does not accumulate. The staged symptom
reported here β works at first, then some users, then nothing β is the block
behaviour, and that part goes away.
Honest caveat: the reordering window is not quantified. There are production
numbers for collisions and block inversions; there are none for this. It is also not
purely theoretical β this cluster has known pod GC / CPU throttling that can stall a
producer for hundreds of milliseconds, and the long-poll doorbell wakes consumers in
milliseconds. And it is not self-healing either: the D4 idempotency claim
(7 days, keyed without inputs) turns a user's retry into a replay.
Note on closing
Do not close this from a merge. PR #702 is behaviour-neutral on merge β the
allocator stays on the legacy path until an operator activates it. Close only after
activation plus the three checks above.
Summary
POST /v1/bot/eventspaginates the authoritative queue with an exclusive lowerbound (
ZRANGEBYSCORE key (cursor +inf,modules/bot_api/events.go:251), while thescores written into
robotEvent:{robotID}come from octo-libGenSeqβ aper-process HiLo block allocator (
config/seq.go:seqStep = 1000, blocks cachedin a package-level
seqMap, guarded only by a process-local mutex, andmin_seqwritten back with an unconditional
ON DUPLICATE KEY UPDATE).With more than one replica, several 1000-wide blocks are live simultaneously, so
event_idorder and real enqueue order are decoupled. Once a consumer's cursoradvances into a higher block, every event subsequently enqueued from a lower live
block becomes permanently unreachable: the exclusive
Minexcludes it, and nothingever re-delivers below the cursor.
events.go:182-213already records the adjacent assumption ("uniqueness is assumedand unverified", PR #685 review P2-3), but scoped the consequence to score
collisions. Ordering inversion alone is sufficient β no collision required β and
that is what we measured in production.
Evidence (production, read-only measurement)
One bot event queue, 644 members:
boundary (
β¦X007 β β¦(X+1)001)event_idcarrying a strictly olderevent than its predecessor)
Collisions are also already happening, not just inversions
The measured queue above has unique scores, so it demonstrates the inversion path in
isolation. A full read-only sweep of 1948 non-trivial queues found 3 with
duplicate scores:
The duplicate counts clustering near multiples of
seqStepis the signature of a wholeblock being handed out twice. The mechanism is
addOrUpdateSeq:The written-back
min_seqis computed from process-localseq.CurSeq + seqStepand the update is unconditional, so a replica whose block is behind can move
min_seqbackwards. A replica that cold-starts afterwards reads an already-issuedrange and re-issues it.
Collisions add a second, worse loss path on top of the skip.
ackEvent/eventAckdelete byZRemRangeByScore(key, id, id)(events.go:160,:324), whichremoves every member sharing that score β so acking one delivered event silently
destroys an event that was never delivered to anyone.
events.go:182-213predictedexactly this and recorded it as assumed-not-happening; it is happening.
Restricted to
card_actionevents, whereacted_atis a server-sidetime.Now()(
modules/message/api_card_action.go:253) and therefore directly comparable:Two blocks (
β¦4xxx,β¦5xxx) were demonstrably issuing concurrently. A consumer thatpolled at any point after
β¦5009landed holds a cursor β₯β¦5009, soβ¦4012andβ¦4013can never be delivered to it.This is not a consumer-side misuse
The server advances the cursor by max observed
event_idinside its own long-pollhold:
A single hold that reads a high-block event and then receives a low-block enqueue
skips it server-side, regardless of how the bot manages its cursor.
Impact
Silent, permanent loss of bot events, including
card_action. The user-visiblesymptom is that clicking an interactive card does nothing β and specifically that it
works for some users on the same card while silently failing for others, depending on
which block their click landed in.
The loss is not self-healing. The D4 idempotency claim keys on
(message_id, action_id, operator_uid)with a TTL ofRobot.MessageExpire(default 7 days) and deliberately excludes
inputs, so a user whose event was skippedreceives
{"accepted":true,"replay":true}on every retry within that window. The D8assumption that "a re-tap after timeout heals it" does not hold here: the retry is
absorbed by the dedupe key and the skipped event is never re-enqueued.
Proposed fix
robotEventβ RedisINCR, or thetransactional per-key DB sequence pattern already built in
internal/msgextraseqfor the same class of
GenSeqdefect onmessage_extra.version. This is theactual fix.
cursor - marginand haveconsumers dedupe on
event_id. Cheaper, but only narrows the window instead ofclosing it; acceptable only as an interim mitigation.
Either way, the exclusive-cursor contract in
events.goshould state that it requiresa monotonic score, and the invariant should get a test that fails under concurrent
block allocation rather than a comment saying it is assumed.
Note that the five
ZADDproducer sites intorobotEvent:{robotID}(enumerated inmodules/robot/api.go:224-235) must all move to the new allocator together; a partialcutover reintroduces exactly the two-live-block condition this issue is about.
Related
Same root-cause family as the
message_extra.versionwork tracked in #627, whichreplaced
GenSeqwith a transactional per-channel sequence β but only formessage_extrawriters (pinned_message.versionis in that scope viaseqStore.ReserveTx). TherobotEventscore was never covered, and activating thatcutover has no effect on this path. The ordering assumption was first written down in
PR #685.
Two further instances of the same defect are covered by neither #627 nor this
issue, and should be triaged separately:
conversation_extra.versionβGenSeq(common.SyncConversationExtraKey)(
modules/message/api_conversation.go:195), read back withWhere("uid=? and version>?")(db_conversation_extra.go:29)reminders.versionβGenSeq(common.RemindersKey)(
modules/message/api_reminders.go:172), read back withreminders.version>?(
db_reminders.go:93-102)Both are exclusive-cursor delta sync over a
GenSeq-allocated version, and both use asingle global seq key (no uid/channel suffix), so one non-monotonic allocation is
shared by every user of that feature β a wider blast radius than the per-bot queue this
issue is about. Symptom would be silently missing reminders / conversation-extra state
rather than missing bot events.
Acceptance β narrowed, and what closing this requires
This section narrows the scope of this issue. It is not a claim that the defect
is fixed; it is a statement of which of the failures described above this issue is
accountable for, so that it has a reachable completion condition.
In scope β closing this issue requires all three
botevent-seq -action preflightduplicate count stopsincreasing. It will not drop to zero: existing duplicates are deliberately left
alone, because an ack deletes every member sharing a score and there is no record
of which of a pair was ever delivered.
per-process blocks, so the 19 inversions with a 6.7 day maximum regression measured
here cannot recur.
nothing happened" go away:
accurate
gives the follow-up a real priority instead of a guess
Out of scope β tracked separately
Allocation and publication are two operations. A producer can allocate
N, stallon marshalling or a GC pause, while another allocates
N+1, publishes, and rings thedoorbell; the consumer wakes on that ZADD, advances its cursor past
N, and the firstproducer's publish then lands below it. Monotonic ids do not close that window β it
needs the allocation and the ZADD to be one atomic operation, or a re-delivery window
below the cursor, which is what
modules/bot_api/events.go:186-199calls the otherhalf of the fix.
A follow-up issue will track it. Two things make it a separate piece of work rather
than an omission here:
modules/bot_mention, which atomicallycommits an idempotency claim together with the queue write and needs the event id
before publishing β the opposite of assigning the id inside the publish. That is
a cross-module design decision about which atomicity boundary wins.
systemic and self-worsening: once a cursor entered a high block, every subsequent
event from a replica holding a lower block was invisible, and each restart opened
another block. The reordering window loses one event and does not move the
cursor relative to later ones, so it does not accumulate. The staged symptom
reported here β works at first, then some users, then nothing β is the block
behaviour, and that part goes away.
Honest caveat: the reordering window is not quantified. There are production
numbers for collisions and block inversions; there are none for this. It is also not
purely theoretical β this cluster has known pod GC / CPU throttling that can stall a
producer for hundreds of milliseconds, and the long-poll doorbell wakes consumers in
milliseconds. And it is not self-healing either: the D4 idempotency claim
(7 days, keyed without
inputs) turns a user's retry into areplay.Note on closing
Do not close this from a merge. PR #702 is behaviour-neutral on merge β the
allocator stays on the legacy path until an operator activates it. Close only after
activation plus the three checks above.