Skip to content
s1ns3nz0 | Known Unknowns
Go back

Lineth L2 Incident Management: Postmortem (5) - An L1 Deadlock the Engine API Made Permanent

9 min read

Part 5 of a series of postmortems from my Lineth L2 lab: one incident per failure type. Part 4 was observability; this one is a distributed-system failure: two correct components that together could not make progress.

The L2 settles on a local L1 that is as small as an Ethereum chain can be: one execution client (Besu), one beacon node (Teku) and one validator client, with 1-second slots. The beacon node decides the head and, through the Engine API, tells the execution client which block to build on (engine_forkchoiceUpdated with payload attributes); the execution client returns a payload id, and the beacon node later fetches the built block. Format: the Google SRE postmortem template, blameless.

Summary

IncidentINC-2026-10-13-15
StatusResolved 14:25 UTC (mitigated 10:52; settlement backlog drained by 14:25)
SeveritySEV-2: settlement (finalization, withdrawals, message anchoring) stopped; L1 produced no blocks
ImpactNo L1 blocks for 3 h 14 min; settlement lag up to 3,184 L2 blocks; 57 withdrawals waiting > 30 min
TriggerA one-slot re-org on L1 that orphaned a block the execution client had already made canonical
Root causes(1) The beacon node’s head became the parent of the execution client’s head; per the Engine API the execution client must not build on an ancestor of its head, so no block could ever be produced. (2) 1-second slots on one node leave no slack. (3) The L2 coordinator wedged and had no liveness probe
Resolutiondebug_setHead moved the execution client’s head back by one empty, already-orphaned block; the coordinator was restarted
DetectionCritical alert 30 s after the last block; a human 3 h 11 min later

Impact

whathow much
L1no blocks 07:30:57 → 10:45; L1 finality stuck; beacon chain in inactivity leak
L2 finalizationstopped; lag up to 3,184 blocks, drained by 14:25 (6 h 55 min after the last L1 block)
L2 → L1 withdrawals57 waiting > 30 min at detection; all claimed by 13:55
L1 → L2 depositsnone possible (no L1 blocks)
Not affectedL2 block production and user transactions

Error budget.

SLO (28 days)budget remaining beforeafter
settlement-freshness (99 %)−320 %−365 %−45 points: ~3 h of a 6.7 h monthly allowance, as expected
withdrawal-1h (99 % within 1 h)94 %94 %did not move

The withdrawal SLO should have taken the biggest hit; 57 withdrawals waited for hours. Its histogram explains why it did not. Over the two hours after recovery:

le=600 0 | le=1200 0 | le=1800 0 | le=3600 0 | le=7200 0 | le=+Inf 45

All 45 withdrawals took more than 2 hours. The SLI is good = bucket{le="3600"} over total = bucket{le="7200"}, so anything slower than 2 hours falls out of the denominator as well: the worst requests were not counted as bad, they were not counted at all. This is the third SLI blind spot in this series (Part 1: unfinished deposits, Part 2: lag that still moves), and the most direct: the total must be le="+Inf".

Root causes and trigger

Trigger. Block production had been running close to the edge: Slow Block Production warnings, several slots handled in the same second, 1–4 failed block proposals every two minutes in the half hour before. At 07:30:56–58 teku could not get a payload in time, and then re-orged its head back:

07:30:56.207 WARN  - No suitable payloadId for block production at slot 43094, requesting a new one to the EL
07:30:58.431 INFO  - Reorg Event *** New Head: 47b83d…edc (43093), Previous Head: 0512ef…363 (43095), Common Ancestor: 47b83d…

Besu had already imported the block of the orphaned slot (EL block 42491, 0x9ff3f…), so its canonical head stayed one block ahead of teku’s (EL 42490, 0x3bcbb…).

Root cause 1: a deadlock that the protocol makes permanent. From then on, every slot, teku sent forkchoiceUpdated with head = 42490 and asked for a payload. The Engine API specification allows the execution client to skip the update and says it must not start building a payload when the requested head is a valid ancestor of its canonical head. Besu answered VALID, with no payload id:

besu  07:31:30.502 | AbstractEngineForkchoiceUpdated | FCU(VALID) | head: 3bcbb.....29727 | safe: 32f91.....df01a | finalized: 35735.....18cee

and teku failed every proposal:

teku  07:30:58.471 ERROR - Failed to process request to URL http://teku:4000/eth/v3/validator/blocks/43096
java.lang.IllegalStateException: ExecutionPayloadContext is not provided for production of post-merge block at slot 43096

Failed proposals per two minutes (Loki): 1–4 before 07:30, 12 at 07:30, 70 at 07:32, then ~120: every slot for three hours. A new block would have broken the tie, but the only validator could not produce one. Each side was following its rules.

Root cause 2: no slack. One beacon node and one validator on 1-second slots, on a laptop shared with the rest of the lab. Slow proposals were routine; a re-org of this kind was a matter of time.

Root cause 3: the coordinator wedged and nothing restarted it. From ~07:38 the L2 coordinator’s /health and /metrics stopped answering; it stopped cutting batches (the last one ends at a block of 07:36). It was still hung at 10:50, five minutes after L1 recovered, so it was not just waiting for L1. It had readiness and startup probes only: Kubernetes marked it unready 1,150 times and never restarted it. Why it hung is unknown: no thread dump was taken before the restart.

Resolution

  1. L1 (10:45). Confirmed that the head had not moved (eth_blockNumber = 0xa5fb), then pointed Besu’s head back at teku’s (Besu has the DEBUG RPC API enabled in this lab):

    before: {"jsonrpc":"2.0","id":1,"result":"0xa5fb"}
    head confirmed 42491 -> setHead 42490
    {"jsonrpc":"2.0","id":1,"result":"Success"}
    after 20s: {"jsonrpc":"2.0","id":1,"result":"0xa60d"}

    The dropped block was empty and already orphaned on the beacon side, so nothing was lost. Within 20 s L1 had produced 18 blocks; L1 finality caught up a few minutes later.

  2. Coordinator (10:51). Restarted. It resumes from its database:

    10:52:48.588 | main | INFO | app | Resuming conflation from block=9913 inclusive blockTime=2026-10-13T07:36:23Z
  3. Catch-up. The prover then had a backlog of 3,000+ blocks while the L2 kept adding ~17 blocks a minute. Pausing two synthetic-traffic generators (the L2 makes no empty blocks, so fewer transactions means fewer blocks to prove) cut L2 growth to ~7.7 blocks a minute. Closing apps on the Mac to relieve the memory pressure from Part 2 brought proofs back from 360–485 s to ~180–200 s per 80-block batch.

Detection

time (UTC)after the last L1 block
Last L1 block07:30:570
LinethL1ExecutionHeadStalled (critical)07:31:2730 s
LinethL2FinalizationStalled07:354 min
LinethL2ScrapeFailed (coordinator)07:387 min
A human runs the workflow check10:423 h 11 min
L1 producing again10:453 h 14 min

Every alert was right and on time. They went to a log and a console nobody was watching, as in every incident before this one. Fifteen minutes after the recovery, lab alerts were routed to Telegram, and the rest of the catch-up was followed there.

Lessons learned

What went well

What went wrong

Where we got lucky

Action items

#actiontypestatus
1Route lab alerts to a person (Telegram)detectdone
2Coordinator liveness probe on /health (5 min of no answer → restart)mitigatedone (5 min of no answer → restart)
3Runbook for an L1 stall: deadlock check (FCU head = parent of EL head), debug_setHead, then the coordinatorprocessdone
4Before restarting a wedged JVM, take a thread dump (kill -3 1 prints it to the container log)processdone (runbook)
5Latency SLIs: total = le="+Inf", not the largest finite bucketdetectopen
6Longer L1 slots (2–4 s) to remove the trigger; needs a new genesis, so with the next chain resetpreventopen
7Automatic deadlock repair (a watchdog calling debug_setHead)mitigatenot planned: rewinding a chain head automatically is riskier than alert + runbook

Timeline (UTC, 2026-10-13)

timeevent
06:58–07:261–4 failed L1 proposals per 2 min; Slow Block Production warnings
07:30:56No suitable payloadId for block production at slot 43094
07:30:57Last L1 block (42491)
07:30:58teku re-orgs to 43093 (EL 42490); every proposal fails from here (ExecutionPayloadContext is not provided)
07:31:27LinethL1ExecutionHeadStalled fires (unseen)
~07:38Coordinator /metrics and /health stop answering; last batch ends at a block of 07:36
09:36Last proof from the coordinator’s queue; prover idle
10:42Workflow check: L1 head stuck, coordinator not Ready, lag 3,184, 57 withdrawals waiting
10:45debug_setHead(42490) → Success; L1 producing
10:51–10:52Coordinator restarted; Resuming conflation from block=9913
11:00Alerts routed to Telegram
11:38Two traffic generators paused to slow L2 growth
12:40–13:30Mac memory pressure slows proofs to 360–485 s; apps closed, back to ~180–200 s
13:55All withdrawals claimed; lag 955 and falling
14:25Lag 383: caught up. RPC node restarted to drop one transaction stuck in its pool since the generator pause
14:26Generators back (user transfers every 60 s instead of 10, to keep prover headroom); coordinator liveness probe on
14:28Every workflow check passes (11/11)

Takeaways


Share this post:

Previous Post
Lineth Postman: A Stuck Claim That Could Never Be Cancelled
Next Post
Lineth L2 Incident Management: Postmortem (4) - Alive but Dead: 12 Hours Without Grafana