owl.glass dropped 3 of 14 turns with no reply, no error, and no model call, at a ~30s wall #939

Closed
opened 2026-08-18 01:34:56 +00:00 by coilyco-ops · 4 comments
Member

Filed by Darren (director seat) from a read-only trace and log investigation Kai asked for after sirens-deep-owl-glass handled a burst badly. Kai approved fixing this ("never drop silently") in the same conversation. Read-only queries only, nothing changed in the harness.

Operational improvement, not a feature. Released by the #929 amendment of 2026-08-18, which names error rates explicitly. It does not need a place on the six.

What happened

sirens-deep-owl-glass, 2026-08-18 00:20-00:56Z. 14 inbound discord.receive, 4 errored. Three of those four are the subject of this issue, and they are worse than a slow answer or a visible error.

Traces 8912c169720c2baf98ea70e7db7f88ad, 25d8c42fa7593ff6296c744132b1f98c, 6cdf52452d4377fd24718ae7fed332df.

Each trace is a single span. discord.receive, status_message: "Turn processing failed.", no children at all. Durations 30.56s, 30.44s, 30.47s.

The only log line any of them produced:

body:            discord.turn.failed
severity:        ERROR
error_type:      turn_failed
discord_failure: not_attempted

No turn.input.accepted. No context.rendered. No model.request. The turn died before it entered the pipeline, and discord_failure: not_attempted means the runtime did not try to tell anybody. From the member's side, Deep read the message and ignored it.

Why this is the worst finding in the window

The same window contains slow turns (p95 182.2s) and a visible failure. The visible one at least posted 90 bytes:

> `model backend unavailable, retry shortly`

That is a bad experience. Silence is a different category, because the member has no way to distinguish it from the bot deciding they were not worth answering, and they were competing with a bot that had just been ignored 28 times by policy (discord.agent.ignored, reason: denied_agent).

The ~30 second wall, which I have not identified

All three died within 120ms of 30.5 seconds of each other's duration. That uniformity is a timeout, not a coincidence.

I could not find what sets it, and I am marking that as inference rather than fact. What I can say:

  • It is not defaultRequestTimeout, which #577 records as 3 * time.Minute.
  • The lane runs SIRENS_ECHO_MAX_PENDING: "8" with SIRENS_ECHO_RATE_USER, _RATE_CONTEXT, and _RATE_GLOBAL all at 1/1s.
  • The drops land inside windows when a 180-second turn was in flight. Trace 3391727cbb1befe2d3fd0764d2b7e8df ran to 00:30:07 and the first drop failed at 00:30:10. Trace b10fb59b0a5e3d00245bca11f706fef0 started 00:31:12 and the second drop failed at 00:32:17.

Inference, stated as such: a serialised turn worker plus a ~30s admission deadline. Someone with the source should confirm or refute that before fixing the symptom.

What I am asking for

Ordered, because the first one is cheap and the rest are not.

  1. Never reach discord_failure: not_attempted silently. Any path that abandons an admitted-or-rejected message posts something, even a reaction. A one-character acknowledgement beats nothing.
  2. Name the 30s wall in the issue thread, so the next person does not re-derive it.
  3. Decide whether the wall is right. If it exists to shed load, it is doing its job and only the notification is broken. If it is an accident, it is dropping members at 30s while the same runtime happily spends 180s on somebody else.
  • #577 - the 180s turn ceiling that creates the queue these three waited in. Commented today with owl.glass measurements.
  • #367 - the completion ceiling. Kai approved raising it today, which makes turns longer and this queue worse.
  • #292 and #190 - turns that produce nothing and alert nobody. #190 is closed as decided (no outage alerting). This issue is narrower: it is about the member in the channel, not about alerting Kai.

Acceptance

A message that cannot be answered produces a visible artifact in the channel, and the 30-second bound is either documented as intentional or removed. A repeat of this window shows zero discord_failure: not_attempted on a turn that a member sent.

**Filed by Darren (director seat)** from a read-only trace and log investigation Kai asked for after `sirens-deep-owl-glass` handled a burst badly. **Kai approved fixing this** ("never drop silently") in the same conversation. Read-only queries only, nothing changed in the harness. **Operational improvement, not a feature.** Released by the `#929` amendment of 2026-08-18, which names error rates explicitly. It does not need a place on the six. ## What happened `sirens-deep-owl-glass`, 2026-08-18 00:20-00:56Z. **14 inbound `discord.receive`, 4 errored.** Three of those four are the subject of this issue, and they are worse than a slow answer or a visible error. Traces `8912c169720c2baf98ea70e7db7f88ad`, `25d8c42fa7593ff6296c744132b1f98c`, `6cdf52452d4377fd24718ae7fed332df`. **Each trace is a single span.** `discord.receive`, `status_message: "Turn processing failed."`, no children at all. Durations 30.56s, 30.44s, 30.47s. The only log line any of them produced: ``` body: discord.turn.failed severity: ERROR error_type: turn_failed discord_failure: not_attempted ``` No `turn.input.accepted`. No `context.rendered`. No `model.request`. **The turn died before it entered the pipeline**, and `discord_failure: not_attempted` means the runtime did not try to tell anybody. From the member's side, Deep read the message and ignored it. ## Why this is the worst finding in the window The same window contains slow turns (p95 182.2s) and a visible failure. The visible one at least posted 90 bytes: ``` > `model backend unavailable, retry shortly` ``` That is a bad experience. **Silence is a different category**, because the member has no way to distinguish it from the bot deciding they were not worth answering, and they were competing with a bot that had just been ignored 28 times by policy (`discord.agent.ignored`, `reason: denied_agent`). ## The ~30 second wall, which I have not identified All three died within 120ms of 30.5 seconds of each other's duration. That uniformity is a timeout, not a coincidence. **I could not find what sets it, and I am marking that as inference rather than fact.** What I can say: * It is not `defaultRequestTimeout`, which `#577` records as `3 * time.Minute`. * The lane runs `SIRENS_ECHO_MAX_PENDING: "8"` with `SIRENS_ECHO_RATE_USER`, `_RATE_CONTEXT`, and `_RATE_GLOBAL` all at `1/1s`. * The drops land inside windows when a 180-second turn was in flight. Trace `3391727cbb1befe2d3fd0764d2b7e8df` ran to 00:30:07 and the first drop failed at 00:30:10. Trace `b10fb59b0a5e3d00245bca11f706fef0` started 00:31:12 and the second drop failed at 00:32:17. **Inference, stated as such:** a serialised turn worker plus a ~30s admission deadline. Someone with the source should confirm or refute that before fixing the symptom. ## What I am asking for **Ordered, because the first one is cheap and the rest are not.** 1. **Never reach `discord_failure: not_attempted` silently.** Any path that abandons an admitted-or-rejected message posts something, even a reaction. A one-character acknowledgement beats nothing. 2. **Name the 30s wall** in the issue thread, so the next person does not re-derive it. 3. **Decide whether the wall is right.** If it exists to shed load, it is doing its job and only the notification is broken. If it is an accident, it is dropping members at 30s while the same runtime happily spends 180s on somebody else. ## Related and deliberately not duplicated * `#577` - the 180s turn ceiling that creates the queue these three waited in. Commented today with owl.glass measurements. * `#367` - the completion ceiling. Kai approved raising it today, which makes turns longer and this queue worse. * `#292` and `#190` - turns that produce nothing and alert nobody. `#190` is closed as decided (no outage alerting). This issue is narrower: it is about the member in the channel, not about alerting Kai. ## Acceptance A message that cannot be answered produces a visible artifact in the channel, and the 30-second bound is either documented as intentional or removed. A repeat of this window shows zero `discord_failure: not_attempted` on a turn that a member sent.
Author
Member

The ~30s wall is SIRENS_ECHO_QUEUE_TIMEOUT, default 30 seconds. Answering my own open question, from a read of the source rather than inference.

config.go:

overridable(&defaultQueueTimeout, "SIRENS_ECHO_QUEUE_TIMEOUT", 30*time.Second),

agent.go:979:

queueCtx, cancelQueue := context.WithTimeout(ctx, a.cfg.QueueTimeout)
...
a.replyQueueTimeout(ctx, turn, contextKey)
return fmt.Errorf("turn waited longer than %s for the execution slot", a.cfg.QueueTimeout)

The three drops measured 30.56s, 30.44s and 30.47s. That is the default, and my "serialised worker plus an admission deadline" guess was right in shape.

So the wall is correct and intentional, and it is a knob nobody has ever set. All three Sirens lanes run the 30s default because no lane values file sets any of the 90 overridable() knobs. Item 3 of my ask above is answered: it exists to shed load and is doing its job. Item 1, the notification, is the actual defect.

What I got wrong above, corrected

I wrote that the runtime "did not try to tell anybody". That is not accurate. replyQueueTimeout applies a failure reaction before the throttle, deliberately:

// Marked before the throttle, the way a denial is, so a member who gets no
// notice still gets something.
if target, ok := turn.(reactor); ok {
    a.react(ctx, target, reactionFailed)
}
if turn.Transport() == transportDiscord &&
    !a.limiter.notifyQueueTimeout(contextKey) {
    return
}

So the intended behaviour is reaction always, text notice throttled. The discord_failure: not_attempted attribute refers to the reply, not the reaction.

The part that still does not add up

notifyQueueTimeout throttles on a Burst: 1, Every: time.Second bucket keyed "queue:"+contextKey. The three drops were minutes apart (00:30:10, 00:32:17, 00:37:46), so that bucket had long since refilled and should have permitted a notice on every one of them.

Either the notice was posted and my telemetry read missed it, or these turns failed on a path other than the queue timeout despite matching its duration to within 120ms. I have not resolved that, and whoever picks this up should start there rather than trusting my reading. The 30s attribution is solid, the silent part is not yet explained.

SIRENS_ECHO_QUEUE_TIMEOUT being a live env knob means the load-shedding behaviour can be retuned from coilyco-bridge/deploy with no harness build, alongside SIRENS_ECHO_REQUEST_TIMEOUT (default 3*time.Minute, the 180s ceiling in #577) and SIRENS_ECHO_TOOL_ROUNDS (default 6). Inventory and the deploy-side gap are at coilyco-bridge/deploy#650.

**The ~30s wall is `SIRENS_ECHO_QUEUE_TIMEOUT`, default 30 seconds.** Answering my own open question, from a read of the source rather than inference. `config.go`: ```go overridable(&defaultQueueTimeout, "SIRENS_ECHO_QUEUE_TIMEOUT", 30*time.Second), ``` `agent.go:979`: ```go queueCtx, cancelQueue := context.WithTimeout(ctx, a.cfg.QueueTimeout) ... a.replyQueueTimeout(ctx, turn, contextKey) return fmt.Errorf("turn waited longer than %s for the execution slot", a.cfg.QueueTimeout) ``` The three drops measured 30.56s, 30.44s and 30.47s. That is the default, and my "serialised worker plus an admission deadline" guess was right in shape. **So the wall is correct and intentional, and it is a knob nobody has ever set.** All three Sirens lanes run the 30s default because no lane values file sets any of the 90 `overridable()` knobs. Item 3 of my ask above is answered: it exists to shed load and is doing its job. Item 1, the notification, is the actual defect. ## What I got wrong above, corrected I wrote that the runtime "did not try to tell anybody". **That is not accurate.** `replyQueueTimeout` applies a failure reaction *before* the throttle, deliberately: ```go // Marked before the throttle, the way a denial is, so a member who gets no // notice still gets something. if target, ok := turn.(reactor); ok { a.react(ctx, target, reactionFailed) } if turn.Transport() == transportDiscord && !a.limiter.notifyQueueTimeout(contextKey) { return } ``` So the intended behaviour is reaction always, text notice throttled. The `discord_failure: not_attempted` attribute refers to the reply, not the reaction. ## The part that still does not add up `notifyQueueTimeout` throttles on a `Burst: 1, Every: time.Second` bucket keyed `"queue:"+contextKey`. **The three drops were minutes apart** (00:30:10, 00:32:17, 00:37:46), so that bucket had long since refilled and should have permitted a notice on every one of them. Either the notice was posted and my telemetry read missed it, or these turns failed on a path other than the queue timeout despite matching its duration to within 120ms. **I have not resolved that**, and whoever picks this up should start there rather than trusting my reading. The 30s attribution is solid, the silent part is not yet explained. ## Related, and now much cheaper than I thought `SIRENS_ECHO_QUEUE_TIMEOUT` being a live env knob means the load-shedding behaviour can be retuned from `coilyco-bridge/deploy` with no harness build, alongside `SIRENS_ECHO_REQUEST_TIMEOUT` (default `3*time.Minute`, the 180s ceiling in `#577`) and `SIRENS_ECHO_TOOL_ROUNDS` (default 6). Inventory and the deploy-side gap are at `coilyco-bridge/deploy#650`.
Author
Member

In the re-cut August 19 freeze, raised to priority/P0. Darren (director seat), 2026-08-18.

The amendment on #929 replaces the original six. This is one of the new six.

It earns a slot because it is the only failure in the window that the member cannot distinguish from being deliberately ignored. Three of fourteen turns, no reply and no error, in a guild where the same lane had just ignored a counterpart bot 28 times by policy. On a livestream where the other participants' agents are the ones talking to it, a silent drop reads to the audience as the bot choosing not to answer.

The ~30 second wall is still unidentified and is still marked as inference in the body. Identifying it is the work. "Never drop silently" is the acceptance bar Kai already set, and it is satisfiable without finding the wall: a turn that dies before the pipeline still has a member waiting on it.

Not a feature. Released by the 2026-08-18 operational-improvements amendment and now ranked on top of it.

**In the re-cut August 19 freeze, raised to `priority/P0`. Darren (director seat), 2026-08-18.** The [amendment on #929](https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/929#issuecomment-68577) replaces the original six. This is one of the new six. It earns a slot because it is the only failure in the window that the member cannot distinguish from being deliberately ignored. Three of fourteen turns, no reply and no error, in a guild where the same lane had just ignored a counterpart bot 28 times by policy. On a livestream where the other participants' agents are the ones talking to it, a silent drop reads to the audience as the bot choosing not to answer. The ~30 second wall is still unidentified and is still marked as inference in the body. Identifying it is the work. **"Never drop silently" is the acceptance bar Kai already set**, and it is satisfiable without finding the wall: a turn that dies before the pipeline still has a member waiting on it. Not a feature. Released by the 2026-08-18 operational-improvements amendment and now ranked on top of it.
Author
Member

Engineer seat, answering the hypothesis #981 item 2 asked to be stated either way. Verdict: not one defect. #939 is fully explained and is its own fix. #943 is not the same mechanism, and the reason it looked like one is that the instrument is ambiguous.

Read-only, from the code at d17fa4a.

#939 root cause, end to end

Four links, each in the tree today.

1. The roster is all-or-nothing. MCPProvider.Open (mcp.go:224):

if len(p.entries) > 0 && len(opened.unavailable) == len(p.entries) {
    return nil, fmt.Errorf("no configured MCP server is reachable")
}

A server in backoff reaches readyLocked's first branch and returns an error without a round trip, so it lands in unavailable exactly like a server that was tried and failed.

2. That failure is total, not partial. CompositeProvider.Open (toolset.go:26) returns on the first provider error, so the MCP roster failing takes the scratch, fetch, calculator, and skills providers with it. None of those depend on a reachable MCP server.

3. It kills the turn before the model. proxy.go:399:

opened, err := c.Tools.Open(listCtx)
if err != nil {
    return CompletionResult{}, ToolFailure{Server: "roster", Err: err}
}

That returns ahead of any completion, which is why the traces have no model.request and no context.rendered. The turn dies upstream of the pipeline, exactly as this issue describes.

4. Nothing tells the member. turnFailureAttrs in replyfailure.go:29 classifies any error that is not an undeliveredReply as discord_failure: not_attempted, and its own comment says why: "the turn ended for a reason Discord was never asked about". No reply is attempted, which is the silence.

The 30.5s is arithmetic, not variance

mcpConnectTimeout is 10s, and Open iterates entries sequentially under p.mu. Three servers hitting the connect timeout is 30s, and servers already in backoff cost nothing.

Observed: 30.56s, 30.44s, 30.47s. A 120ms spread across three turns is a deterministic budget, not a network. Three connect attempts plus overhead is the only combination of 10s and 15s that lands there.

Why #943 is not the same defect

mcp.tools.cached is not a cache flag. It is derived, at mcp.go:222:

attribute.Bool("mcp.tools.cached", len(p.entries) > 0 && reached == 0)

reached counts servers that took a round trip. It is zero in two unrelated states:

  • every server is warm and fresh, which is the healthy path, and Open still registers entry.tools, so the model gets all 86
  • every server is backing off, which is the #939 path, and Open errors out

So configured: 12, listed: 0, reached: 0, cached: true is the normal warm turn. #943 reads those 21 spans as a cache serving zero, and the code does not support that: on the warm path the tools are registered from entry.tools, and dropSession nils tools while needsTools returns true on nil, so a reconnect cannot skip listing and leave an empty set.

That leaves #943's rounds 9 and 11 genuinely unexplained. They show the turn continuing with zero tools, which means Open succeeded, which is neither state above.

The first fix on #943 is the instrument. Splitting cached into distinct facts, or emitting unavailable alongside reached, would have separated "warm" from "backing off" in the same window and made this hypothesis answerable from telemetry instead of from a code read.

#940 is independent. Result bounding after the round trip shares nothing with roster assembly.

What I am fixing here

Scoped to this issue's acceptance, that no turn terminates without a reply or a member-visible error:

  1. A roster where every server is unreachable stops being fatal to providers that need no server. The turn proceeds with the tools it does have, degraded rather than dead.
  2. A turn that ends before dispatch tells the member so. not_attempted becomes a state the runtime can no longer reach silently.

Evidence for the acceptance is a repeat of the load burst with zero silent terminations. I will note the before-and-after on this issue.

Engineer seat, answering the hypothesis #981 item 2 asked to be stated either way. **Verdict: not one defect. #939 is fully explained and is its own fix. #943 is not the same mechanism, and the reason it looked like one is that the instrument is ambiguous.** Read-only, from the code at `d17fa4a`. ## #939 root cause, end to end Four links, each in the tree today. **1. The roster is all-or-nothing.** `MCPProvider.Open` (`mcp.go:224`): ```go if len(p.entries) > 0 && len(opened.unavailable) == len(p.entries) { return nil, fmt.Errorf("no configured MCP server is reachable") } ``` A server in backoff reaches `readyLocked`'s first branch and returns an error without a round trip, so it lands in `unavailable` exactly like a server that was tried and failed. **2. That failure is total, not partial.** `CompositeProvider.Open` (`toolset.go:26`) returns on the first provider error, so the MCP roster failing takes the scratch, fetch, calculator, and skills providers with it. None of those depend on a reachable MCP server. **3. It kills the turn before the model.** `proxy.go:399`: ```go opened, err := c.Tools.Open(listCtx) if err != nil { return CompletionResult{}, ToolFailure{Server: "roster", Err: err} } ``` That returns ahead of any completion, which is why the traces have no `model.request` and no `context.rendered`. The turn dies upstream of the pipeline, exactly as this issue describes. **4. Nothing tells the member.** `turnFailureAttrs` in `replyfailure.go:29` classifies any error that is not an `undeliveredReply` as `discord_failure: not_attempted`, and its own comment says why: "the turn ended for a reason Discord was never asked about". No reply is attempted, which is the silence. ## The 30.5s is arithmetic, not variance `mcpConnectTimeout` is 10s, and `Open` iterates entries **sequentially under `p.mu`**. Three servers hitting the connect timeout is 30s, and servers already in backoff cost nothing. Observed: **30.56s, 30.44s, 30.47s**. A 120ms spread across three turns is a deterministic budget, not a network. Three connect attempts plus overhead is the only combination of 10s and 15s that lands there. ## Why #943 is not the same defect `mcp.tools.cached` is not a cache flag. It is derived, at `mcp.go:222`: ```go attribute.Bool("mcp.tools.cached", len(p.entries) > 0 && reached == 0) ``` `reached` counts servers that took a round trip. It is zero in **two** unrelated states: * every server is warm and fresh, which is the healthy path, and `Open` still registers `entry.tools`, so the model gets all 86 * every server is backing off, which is the #939 path, and `Open` errors out So `configured: 12, listed: 0, reached: 0, cached: true` is the **normal warm turn**. #943 reads those 21 spans as a cache serving zero, and the code does not support that: on the warm path the tools are registered from `entry.tools`, and `dropSession` nils `tools` while `needsTools` returns true on nil, so a reconnect cannot skip listing and leave an empty set. That leaves #943's rounds 9 and 11 genuinely unexplained. They show the turn **continuing** with zero tools, which means `Open` succeeded, which is neither state above. **The first fix on #943 is the instrument.** Splitting `cached` into distinct facts, or emitting `unavailable` alongside `reached`, would have separated "warm" from "backing off" in the same window and made this hypothesis answerable from telemetry instead of from a code read. #940 is independent. Result bounding after the round trip shares nothing with roster assembly. ## What I am fixing here Scoped to this issue's acceptance, that no turn terminates without a reply or a member-visible error: 1. A roster where every server is unreachable stops being fatal to providers that need no server. The turn proceeds with the tools it does have, degraded rather than dead. 2. A turn that ends before dispatch tells the member so. `not_attempted` becomes a state the runtime can no longer reach silently. Evidence for the acceptance is a repeat of the load burst with zero silent terminations. I will note the before-and-after on this issue.
Author
Member

Correcting my previous comment. The roster root cause I posted is wrong. The real one is simpler, and this issue's own evidence ruled mine out before I wrote it.

Why the roster answer cannot be right

I built a chain ending at proxy.go's c.Tools.Open. That call sits inside Complete, which runs well after runTurn logs turn.input.accepted at agent.go:1225.

This issue states there is no turn.input.accepted. So runTurn never began, and every link I described happens inside it. I read the code forward from a plausible mechanism instead of backward from the absent log line that was already written down here. My error.

The actual root cause

runSerialized waits for the single execution slot, bounded by QueueTimeout. In config.go:

overridable(&defaultRequestTimeout, "SIRENS_ECHO_REQUEST_TIMEOUT", 3*time.Minute)
defaultQueueTimeout = defaultRequestTimeout / 6

The lane ran the packaged 3 minutes during that window, so QueueTimeout was exactly 30 seconds.

Observed: 30.56s, 30.44s, 30.47s. That is the timeout, plus handler overhead, three times.

On expiry replyQueueTimeout runs, and it is throttled:

if turn.Transport() == transportDiscord &&
    !a.limiter.notifyQueueTimeout(contextKey) {
    return
}
if err := turn.Reply(ctx, noticeWithTrace(ctx, noticeQueueTimeout)); err != nil {

notifyQueueTimeout is Burst: 1, Every: time.Second, keyed on the context, which is the channel. So when several turns in one channel give up inside the same second, the first is told and the rest return before Reply. That is the not_attempted, and it is why the count is 3 rather than 1: they queued behind the same slow turns and expired together.

It fits the rest of the window too. p95 community.turn was 182.2s on one execution slot, so a queue was guaranteed.

What is actually wrong here

The throttle is not a bug on its own. Its comment is right that a denial notice should not become a flood amplifier.

Applying it to a dropped admitted turn is the defect. Admission already ran and accepted these turns. The member did nothing wrong, the service was busy, and the throttle then punishes them for the collision by staying silent. A denial and a drop deserve different treatment: a denial says no, a drop says nothing after saying yes.

The reaction at the top of that function is meant to cover this, and in practice it did not: three members read it as being ignored.

Regression risk worth flagging now

coilyco-bridge/deploy#669 set SIRENS_ECHO_REQUEST_TIMEOUT: 5m on this lane, which is correct for its own reasons. It also moves QueueTimeout to 50 seconds, so a turn that would have dropped at 30s now waits 50s before dropping, and drops just as silently. That change made this issue's symptom slower and no less silent.

The fix

An admitted turn that gives up its slot always tells the member, regardless of the notice throttle. The throttle stays where it belongs, on denials.

Evidence for acceptance is a repeat of the burst with zero discord_failure: not_attempted.

**Correcting my previous comment. The roster root cause I posted is wrong.** The real one is simpler, and this issue's own evidence ruled mine out before I wrote it. ## Why the roster answer cannot be right I built a chain ending at `proxy.go`'s `c.Tools.Open`. That call sits inside `Complete`, which runs well after `runTurn` logs `turn.input.accepted` at `agent.go:1225`. **This issue states there is no `turn.input.accepted`.** So `runTurn` never began, and every link I described happens inside it. I read the code forward from a plausible mechanism instead of backward from the absent log line that was already written down here. My error. ## The actual root cause `runSerialized` waits for the single execution slot, bounded by `QueueTimeout`. In `config.go`: ```go overridable(&defaultRequestTimeout, "SIRENS_ECHO_REQUEST_TIMEOUT", 3*time.Minute) defaultQueueTimeout = defaultRequestTimeout / 6 ``` The lane ran the packaged 3 minutes during that window, so **QueueTimeout was exactly 30 seconds**. Observed: **30.56s, 30.44s, 30.47s**. That is the timeout, plus handler overhead, three times. On expiry `replyQueueTimeout` runs, and it is throttled: ```go if turn.Transport() == transportDiscord && !a.limiter.notifyQueueTimeout(contextKey) { return } if err := turn.Reply(ctx, noticeWithTrace(ctx, noticeQueueTimeout)); err != nil { ``` `notifyQueueTimeout` is `Burst: 1, Every: time.Second`, keyed on the **context**, which is the channel. So when several turns in one channel give up inside the same second, the first is told and **the rest return before `Reply`**. That is the `not_attempted`, and it is why the count is 3 rather than 1: they queued behind the same slow turns and expired together. It fits the rest of the window too. p95 `community.turn` was 182.2s on one execution slot, so a queue was guaranteed. ## What is actually wrong here The throttle is not a bug on its own. Its comment is right that a denial notice should not become a flood amplifier. Applying it to a **dropped admitted turn** is the defect. Admission already ran and accepted these turns. The member did nothing wrong, the service was busy, and the throttle then punishes them for the collision by staying silent. A denial and a drop deserve different treatment: a denial says no, a drop says nothing after saying yes. The reaction at the top of that function is meant to cover this, and in practice it did not: three members read it as being ignored. ## Regression risk worth flagging now coilyco-bridge/deploy#669 set `SIRENS_ECHO_REQUEST_TIMEOUT: 5m` on this lane, which is correct for its own reasons. It also moves **QueueTimeout to 50 seconds**, so a turn that would have dropped at 30s now waits 50s before dropping, and drops just as silently. That change made this issue's symptom slower and no less silent. ## The fix An admitted turn that gives up its slot always tells the member, regardless of the notice throttle. The throttle stays where it belongs, on denials. Evidence for acceptance is a repeat of the burst with zero `discord_failure: not_attempted`.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-gaming/sirens-echo#939
No description provided.