Skip to content

pm: auto-prune dead winning tickets + stop retrying expired tickets - #3954

Open
Pon-node wants to merge 3 commits into
livepeer:masterfrom
Pon-node:ticket-prune
Open

pm: auto-prune dead winning tickets + stop retrying expired tickets#3954
Pon-node wants to merge 3 commits into
livepeer:masterfrom
Pon-node:ticket-prune

Conversation

@Pon-node

@Pon-node Pon-node commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

What

Three related improvements to the PM winning-ticket redemption path:

  1. -ticketPrune flag — auto-prune dead winning tickets (8a4d8ae1)
    Adds an opt-in cleanup loop that removes unredeemable winning tickets from the ticket store. A ticket is dead if it is unredeemed and outside the redemption validity window (the redemption loop can never select it again) or if its redemption permanently failed. Without this, dead tickets accumulate in the DB indefinitely.

  2. Prune cutoff matches the redemption window exactly (b75a0fcf)
    The prune cutoff is the exact complement of the selection condition (creationRound >= LastInit - ticketValidityPeriod), so a dead ticket is pruned as soon as it leaves the redemption window on the next cleanup tick, rather than a round later. Still-redeemable tickets are never pruned.

  3. Stop retrying tickets that are expired on-chain (86ea742d)
    The redemption loop selects winning tickets purely by the local round window and never pre-checks on-chain expiry. When TicketBroker reverts with ticket is expired, isNonRetryableTicketErr did not match it, so the error was treated as retryable: the loop re-selected and re-attempted the same ticket every block until the local round window closed. The attempts revert at eth_estimateGas (no gas burned, no tx mined) but generate pointless RPC churn. This change marks ticket is expired as non-retryable so the ticket is recorded as redeemed on the first revert and excluded from further selection.

Why

Observed on a live orchestrator: a winning ticket whose redemption window elapsed while the orch wallet was out of gas became permanently unredeemable, then the redeem loop hammered it with an expired-revert every ~15s, and the dead row lingered in the DB. These changes clean up dead tickets automatically and stop the wasteful retry churn.

Testing

  • TDD for the expired-retry change: new case in TestTicketQueueLoop_IsNonRetryableTicketErr_MarkAsRedeemed (red → green).
  • Full pm suite, go vet, and gofmt clean.
  • All three changes deployed to a live mainnet orchestrator and verified end-to-end: prune deletes a dead ticket on schedule; an on-chain-expired ticket is now attempted once then marked done (previously retried indefinitely).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added automatic pruning of expired and permanently failed winning tickets from the ticket queue database via a new command-line flag.
    • Enhanced detection and handling of expired tickets during redemption attempts.
  • Tests

    • Added comprehensive test coverage for ticket pruning functionality.

Paulius and others added 3 commits June 11, 2026 20:02
Dead winning tickets accumulate in the SQLite ticketQueue table and
operators end up deleting the whole database to recover. Prune them
automatically from the LocalSenderMonitor cleanup ticker:

- unredeemed tickets older than ticketValidityPeriod+1 rounds (the
  redemption loop can never select them again)
- tickets marked redeemed with a zero txHash, i.e. permanently failed
  redemptions

Live tickets within the validity window and successfully redeemed
tickets (real txHash) are never touched.

Controlled by a new -ticketPrune bool flag, default true; opt out with
-ticketPrune=false.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The prune cutoff was ticketValidityPeriod+1 rounds behind the last
initialized round, one round more conservative than the redemption
selection window (ticketValidityPeriod rounds). As a result a ticket
that fell out of the redemption window — and could never be selected
again — lingered in the store for a full extra round before being
pruned.

Set the cutoff to ticketValidityPeriod so it matches the redemption
window exactly. The prune condition is now the exact complement of the
selection condition (creationRound >= LastInit-ticketValidityPeriod), so
a dead ticket is removed on the next cleanup tick after it leaves the
window while a still-redeemable ticket is never pruned.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The redemption loop selects winning tickets purely by the local round
window (creationRound >= LastInit - ticketValidityPeriod) and never
pre-checks on-chain expiry. When the TicketBroker reverts with
"ticket is expired", isNonRetryableTicketErr did not match it, so the
error was treated as retryable: the loop re-selected and re-attempted
the same ticket every block until the local round window closed.
The attempts revert at eth_estimateGas (no gas burned, no tx mined)
but generate pointless RPC churn.

Mark "ticket is expired" as non-retryable so the ticket is recorded as
redeemed on the first revert and excluded from further selection,
stopping the retries immediately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 17, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds automatic pruning of dead winning tickets (expired-unredeemed and permanently-failed-redeemed) from the ticket queue database. A new ClearDeadWinningTickets DB method performs the deletion, isNonRetryableTicketErr is extended to cover expired tickets, LocalSenderMonitor gains a pruneDeadTickets loop step, and the feature is toggled via a new ticketPrune CLI flag defaulting to true.

Changes

Dead Ticket Pruning

Layer / File(s) Summary
DB: ClearDeadWinningTickets SQL method and tests
common/db.go, common/db_test.go
DB struct gains a prepared statement that deletes unredeemed tickets older than minCreationRound and redeemed tickets with a zero txHash. InitDB prepares it, Close tears it down, and ClearDeadWinningTickets(minCreationRound int64) (int64, error) executes and returns the affected row count. Tests cover all four ticket states and verify idempotency.
PM queue: expired ticket as non-retryable
pm/queue.go, pm/queue_test.go
isNonRetryableTicketErr now also matches "ticket is expired" so expired tickets are stored as finally redeemed rather than retried. A new test case asserts this behavior; an adjacent test's nonce is shifted to accommodate the insertion.
SenderMonitor: ticketStoreCleaner interface and pruneDeadTickets
pm/sendermonitor.go
Introduces the ticketStoreCleaner interface, adds TicketPrune bool to LocalSenderMonitorConfig, and implements pruneDeadTickets() which type-asserts the ticketStore, computes a minCreationRound cutoff, calls ClearDeadWinningTickets, and logs the outcome. The cleanup loop conditionally calls it on each tick.
CLI flag and config wiring
cmd/livepeer/starter/starter.go, cmd/livepeer/starter/flags.go
Adds TicketPrune *bool to LivepeerConfig with a default of true, registers the ticketPrune CLI boolean flag, and propagates the value into pm.LocalSenderMonitorConfig.TicketPrune during StartLivepeer.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~15 minutes

Poem

🐇 Hop, hop, the queue grows stale,
Dead tickets linger without a tale.
With a prune flag set to true,
Expired crumbs are bid adieu!
The rabbit sweeps the DB clean —
No ghost tickets left to glean. ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.11% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately and concisely summarizes the two main changes: auto-pruning dead winning tickets and stopping retries of expired tickets, matching the primary objectives.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added the go Pull requests that update Go code label Jun 17, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
common/db.go (1)

391-393: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Pre-existing bug: wrong statement closed.

Line 392 closes db.deleteMiniHeader instead of db.deleteUnbondingLock. This is not introduced by the current PR but should be fixed.

🐛 Proposed fix
 	if db.deleteUnbondingLock != nil {
-		db.deleteMiniHeader.Close()
+		db.deleteUnbondingLock.Close()
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@common/db.go` around lines 391 - 393, In the conditional block that checks if
db.deleteUnbondingLock is not nil, the Close method is being called on the wrong
object. Change the call from db.deleteMiniHeader.Close() to
db.deleteUnbondingLock.Close() so that the correct resource is properly closed
when the unbonding lock exists.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@common/db.go`:
- Around line 391-393: In the conditional block that checks if
db.deleteUnbondingLock is not nil, the Close method is being called on the wrong
object. Change the call from db.deleteMiniHeader.Close() to
db.deleteUnbondingLock.Close() so that the correct resource is properly closed
when the unbonding lock exists.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: cd765834-680e-46e9-82f9-9abf902c47f5

📥 Commits

Reviewing files that changed from the base of the PR and between f14b003 and 86ea742.

📒 Files selected for processing (7)
  • cmd/livepeer/starter/flags.go
  • cmd/livepeer/starter/starter.go
  • common/db.go
  • common/db_test.go
  • pm/queue.go
  • pm/queue_test.go
  • pm/sendermonitor.go

@codecov

codecov Bot commented Jun 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 37.50000% with 20 lines in your changes missing coverage. Please review.
✅ Project coverage is 33.27964%. Comparing base (e628b9c) to head (86ea742).
⚠️ Report is 1 commits behind head on master.

Files with missing lines Patch % Lines
pm/sendermonitor.go 0.00000% 13 Missing ⚠️
common/db.go 53.84615% 4 Missing and 2 partials ⚠️
cmd/livepeer/starter/starter.go 66.66667% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@                 Coverage Diff                 @@
##              master       #3954         +/-   ##
===================================================
- Coverage   33.30014%   33.27964%   -0.02050%     
===================================================
  Files            171         171                 
  Lines          42174       42212         +38     
===================================================
+ Hits           14044       14048          +4     
- Misses         27077       27108         +31     
- Partials        1053        1056          +3     
Files with missing lines Coverage Δ
cmd/livepeer/starter/flags.go 87.40741% <100.00000%> (+0.09398%) ⬆️
pm/queue.go 75.00000% <100.00000%> (+0.33333%) ⬆️
cmd/livepeer/starter/starter.go 22.51656% <66.66667%> (+0.08789%) ⬆️
common/db.go 68.49315% <53.84615%> (-0.33347%) ⬇️
pm/sendermonitor.go 84.77366% <0.00000%> (-4.79156%) ⬇️

... and 3 files with indirect coverage changes


Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update f14b003...86ea742. Read the comment docs.

Files with missing lines Coverage Δ
cmd/livepeer/starter/flags.go 87.40741% <100.00000%> (+0.09398%) ⬆️
pm/queue.go 75.00000% <100.00000%> (+0.33333%) ⬆️
cmd/livepeer/starter/starter.go 22.51656% <66.66667%> (+0.08789%) ⬆️
common/db.go 68.49315% <53.84615%> (-0.33347%) ⬇️
pm/sendermonitor.go 84.77366% <0.00000%> (-4.79156%) ⬇️

... and 3 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@j0sh

j0sh commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the PR @Pon-node

This is OK with me if it also works for others running an orchestrator. Maybe @rickstaa can check with some others.

Re: the ticket skipping behavior, it almost seems preferable to embed ticket skipping into the DB retrieval query, rather than waiting for a runtime failure, but that might be a larger change and shouldn't block this PR. Smaller and more incremental changes are preferable for sensitive paths like these.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

go Pull requests that update Go code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants