Breaking a Deadlock: How a 429 Retry Loop Paralyzed a Distributed Proving System

Message 1933 — The assistant's final summary of changes made to fix a critical deadlock in the ProofShare system, covering modifications to three files that broke an infinite retry loop, added progress-based backoff, scoped database queries, and improved queue cleanup.

The Message

All done. Build and vet pass clean. Here's a summary of all changes:

## Changes Made

### `lib/proofsvc/provictl.go`
- **Added `ErrTooManyRequests` sentinel** (line 52-56) — exported error for 429 detection
- **Removed infinite retry loop** from `CreateWorkAsk` — on HTTP 429 it now returns
  `0, ErrTooManyRequests` immediately instead of retrying with exponential backoff
  forever. The caller is responsible for backoff.

### `tasks/proofshare/task_request.go`
- **Added `"errors"` import** for `errors.Is()`
- **Deadlock fix**: On `ErrTooManyRequests`, breaks the ask-creation inner loop and
  falls through to poll again (line 366-369). This lets the loop discover work
  matched to existing asks and INSERT it into `proofshare_queue`.
- **Progress-based backoff**: Tracks `madeProgress` (new work inserted or asks created)
  and `rateLimited`. On no-progress + rate-limited, doubles `pollBackoff` up to 2
  minutes. On progress, resets to 3s (lines 390-397).
- **Fix 3 — scoped dedup query**: `SELECT service_id FROM proofshare_queue WHERE
  submit_done = FALSE` instead of scanning the entire table (line 274).
- **Fix 5 — hoisted resolver**: `NewAddressResolver`, `address.NewFromString`, and
  `BigFromString` moved before the ask loop (lines 336-358).

### `tasks/proofshare/task_prove.go`
- **Fix 4 — orphan reset**: Changed `DELETE` to `UPDATE ... SET compute_task_id = NULL`
  so orphaned work gets re-assigned instead of lost (lines 58-62).
- **Fix 2 — completed row purge**: Added `DELETE FROM proofshare_queue WHERE
  submit_done = TRUE AND obtained_at < NOW() - INTERVAL '2 days'` to the cleanup
  routine that runs every 5 minutes (lines 73-75).

Why This Message Was Written

This message is the closing report of a multi-step debugging and implementation session. It was written to consolidate and communicate the full set of changes that had just been applied across three source files in the Curio project — a distributed storage proving system built on the Filecoin network. The assistant had spent the preceding messages analyzing a production deadlock, designing a fix, discussing refinements with the user, and then implementing each change one by one. Message 1933 serves as the final summary: a clean, structured record of what was done, why, and where.

The motivation behind the message is rooted in the operational reality of distributed systems debugging. When you fix a deadlock that has been stalling production proving pipelines, you need more than just working code — you need a narrative that ties the symptoms (stuck tasks, empty queues, HTTP 429 responses) to the root cause (an infinite retry loop blocking the polling cycle) and then to each specific change. This message provides that narrative in compressed form. It tells anyone reading the conversation history exactly which files were touched, what each edit accomplished, and how the pieces fit together to resolve the deadlock.

The message also serves a verification purpose. The opening line — "Build and vet pass clean" — is a critical signal that the changes are syntactically and type-correct. In a Go project with complex dependency chains (linking to FFI bindings, PostgreSQL drivers, and custom harmony task infrastructure), a clean build is not guaranteed. The assistant deliberately ran both go build and go vet before writing this summary, and the clean results are the precondition for declaring the work complete.


The Reasoning and Decision-Making Process

To understand the decisions reflected in this message, we need to reconstruct the reasoning chain that led to each change. The deadlock analysis (visible in the assistant's earlier reasoning in [msg 1914]) identified the following causal loop:

  1. The Do() function in task_request.go runs a polling loop that requests work from a remote proof service.
  2. Each iteration polls for work, inserts matched requests into a local database queue (proofshare_queue), and then creates "asks" on the remote service to solicit more work.
  3. The remote service returns HTTP 429 ("Too Many Requests") when the provider already has enough active asks.
  4. The CreateWorkAsk function in provictl.go had an infinite retry loop for 429 responses, with exponential backoff capped at 5 minutes and no overall timeout.
  5. While stuck in this retry loop, the polling loop could never advance to the next iteration, so it could never discover that existing asks had been matched to work requests.
  6. Matched work never got inserted into proofshare_queue, so PSProve tasks found nothing to do and never completed, which meant the service never freed ask slots, which meant 429 never cleared — permanent deadlock. The key insight — and the first major decision — was that the retry logic belonged in the wrong layer. The assistant recognized that CreateWorkAsk was a low-level HTTP client function that should not be responsible for application-level backoff strategy. By moving the backoff responsibility up to the caller (the Do() polling loop), the fix accomplished two things: it broke the deadlock (because the loop could always return to poll) and it enabled smarter backoff decisions (because the caller had visibility into whether progress was being made). This decision was not made in isolation. The user pushed back on an early version of the design ([msg 1916]), asking: "We still want exponential backoff if there were no asks created, correct?" This prompted the assistant to refine the approach in [msg 1917], adding progress-based backoff — a scheme where the poll interval doubles (up to 2 minutes) only when both (a) no progress was made and (b) the service returned 429. If any progress occurs (new work inserted or asks created), the backoff resets to the normal 3-second interval. This hybrid approach prevents tight-looping against the service while ensuring the system recovers quickly as soon as conditions improve. The second major decision was the orphan reset strategy (Fix 4). The original code deleted rows from proofshare_queue when their associated compute_task_id became orphaned (the harmony task no longer existed). The assistant realized this was destructive: deleting the row meant losing work that had already been fetched from the remote service, forcing a re-request on the next cycle. By changing the DELETE to an UPDATE that sets compute_task_id = NULL, the work record is preserved and can be re-assigned to a new harmony task. This is a classic "soft delete" pattern applied to queue management, and it demonstrates an understanding of the data lifecycle across the distributed system. The third decision was the scoped dedup query (Fix 3). The original query SELECT service_id FROM proofshare_queue loaded the entire table into memory, which could grow unbounded over time. The assistant changed it to filter on submit_done = FALSE, since submitted rows can never match new asks. This is a straightforward query optimization, but it reflects a deeper awareness of how the table grows and which data is actually relevant for the dedup check. The fourth decision — hoisting the resolver (Fix 5) — is a minor performance optimization. The NewAddressResolver, address.NewFromString, and BigFromString calls were being re-executed inside the ask-creation loop even though their results don't change per iteration. Moving them before the loop is a textbook loop-invariant code motion optimization. The fifth decision was the completed row purge (Fix 2): deleting rows older than two days where submit_done = TRUE. This prevents unbounded table growth and keeps the cleanup routine's query efficient.---

Assumptions Embedded in the Changes

Every fix carries implicit assumptions about how the system behaves. Understanding these assumptions is critical for evaluating whether the fix is correct and whether it might introduce new failure modes.

Assumption 1: The 429 response is transient and will clear if the polling loop continues. This is the core assumption of the deadlock fix. The assistant assumes that by returning to the poll loop, the system will discover matched work, insert it into the queue, PSProve tasks will process it, and the service will eventually free ask slots. If the 429 were permanent (e.g., the provider is blacklisted or the service has a hard cap that won't clear), this fix would merely turn a deadlock into a livelock — the loop would keep polling, failing to create asks, and never making progress. The progress-based backoff mitigates this by slowing the poll rate, but it doesn't solve a truly permanent 429. The assistant implicitly trusts that the service's rate limiting is dynamic and tied to actual slot utilization.

Assumption 2: The dedup check only needs to consider non-submitted rows. This is correct for the immediate dedup purpose — submitted rows have already been processed and cannot match new asks. However, if there were a scenario where a submitted row could be re-processed (e.g., a retry mechanism that resets submit_done), this assumption would break. The assistant's change to the cleanup routine (purging rows older than two days) suggests an understanding that submitted rows are terminal.

Assumption 3: Orphaned compute tasks can be safely re-assigned by NULL-ing the compute_task_id. This assumes that the work record itself (the request_cid and response_data) is still valid and that a new harmony task can pick it up without re-fetching from the service. If the work data is somehow tied to the original task's lifecycle (e.g., authentication tokens that have expired), re-assignment could fail. The assistant judged this safer than deletion, which would lose the work entirely.

Assumption 4: A 2-day retention window for completed rows is appropriate. This is a heuristic. Too short, and debugging historical issues becomes harder; too long, and the table grows unnecessarily. The assistant chose 2 days as a reasonable middle ground, likely based on the typical lifecycle of proof tasks in the system.


Mistakes and Incorrect Assumptions

The most significant mistake in the original code — and the one that this message directly addresses — was the infinite retry loop in CreateWorkAsk. The original author of that function assumed that retrying 429 with exponential backoff would eventually succeed, because the service would eventually free slots. This assumption failed because it didn't account for the systemic dependency between the retry loop and the polling loop that was supposed to free those slots. The retry loop was blocking the very mechanism that would have resolved the 429. This is a classic distributed systems failure mode: a component assumes it is waiting on an external condition, but it is actually blocking the internal process that would produce that condition.

A subtler mistake was in the design of the polling loop itself. The original code in Do() treated ask creation as a blocking sub-task that must complete before the next poll iteration. It did not have a mechanism for "partial progress" — creating some asks, hitting a rate limit, and then returning to poll to see if the existing asks had been matched. The assistant's fix introduces exactly this mechanism, but it required restructuring the loop's control flow to treat ask creation as non-blocking.

The user's intervention in [msg 1916] also corrected an assumption in the assistant's initial design. The assistant's first proposal (in [msg 1915]) was to simply break the ask loop on 429 and fall through to the next poll iteration at the normal 3-second interval. The user correctly identified that this could hammer the service with rapid 429-triggering poll cycles when no progress was being made. The resulting progress-based backoff is a more nuanced solution that the assistant arrived at through collaboration.


Input Knowledge Required

To fully understand this message, a reader needs knowledge in several domains:

Go programming: Understanding of xerrors.New, errors.Is, package structure, import semantics, and Go build/vet tooling. The message references specific line numbers and function names that assume familiarity with Go code organization.

Distributed systems concepts: Deadlock, livelock, exponential backoff, polling loops, rate limiting (HTTP 429), and the distinction between blocking and non-blocking operations. The core problem is a distributed deadlock, and the fix uses standard patterns (backoff, sentinel errors, progress tracking) to resolve it.

PostgreSQL/SQL: The dedup query scoping (WHERE submit_done = FALSE), the DELETE for completed row purge, and the UPDATE for orphan reset all use SQL that assumes knowledge of the proofshare_queue table schema. The reader needs to understand what submit_done, compute_task_id, obtained_at, and service_id mean in the context of the queue.

Filecoin/Curio domain knowledge: The ProofShare system, PSProve tasks, harmony task framework, and the relationship between the remote proof service and the local proving pipeline. Terms like "ask creation," "work matching," and "partition proving" are specific to this domain.

The conversation history: This message is the capstone of a multi-step debugging session. The reader needs to know about the deadlock analysis in [msg 1914], the design discussion in [msg 1915] and [msg 1917], and the user's refinement request in [msg 1916]. Without this context, the message reads as a dry changelog rather than a resolution narrative.


Output Knowledge Created

This message creates several forms of knowledge:

Actionable knowledge: The exact changes needed to fix the deadlock. A developer reading this message can apply the same pattern — moving retry logic from a low-level HTTP client to a higher-level polling loop with progress-based backoff — to similar problems in other systems.

Structural knowledge: The relationship between the three files (provictl.go, task_request.go, task_prove.go) and how they interact in the ProofShare pipeline. The message clarifies the responsibility boundary: provictl.go handles HTTP communication (and should not retry), task_request.go orchestrates the polling loop (and should handle backoff), and task_prove.go manages queue cleanup (and should preserve orphaned work).

Operational knowledge: The importance of clean build and vet verification before declaring a fix complete. The message models good engineering practice by reporting these results upfront.

Historical knowledge: A record of what was changed, why, and where. This is invaluable for future debugging — if a similar deadlock reappears, the next developer can trace the reasoning chain from this message.


The Thinking Process Visible in Reasoning

The assistant's reasoning in the messages leading up to this summary reveals a structured debugging methodology:

  1. Symptom observation: The system was stuck — PSProve tasks weren't running, the queue was empty, and the service was returning 429.
  2. Trace the causal chain: The assistant traced the execution path from Do() through PollWork, the insert logic, the ask-creation loop, and into CreateWorkAsk. This required reading multiple files and understanding how data flows between them.
  3. Identify the root cause: The infinite retry loop in CreateWorkAsk was blocking the poll loop, creating a self-reinforcing stall. The assistant explicitly mapped out the cycle: "While we're stuck retrying, any active asks that get matched to work requests go undiscovered—we never poll again to find them, never insert them into the queue, and the service never frees up slots, creating a complete deadlock."
  4. Design the fix: The assistant proposed moving the retry responsibility to the caller, breaking the deadlock by ensuring the poll loop always completes an iteration.
  5. Refine through collaboration: The user's question about backoff prompted the assistant to add progress-based backoff, a more sophisticated approach that prevents tight-looping while preserving the deadlock fix.
  6. Implement systematically: Each fix was applied to a specific file with a clear purpose. The assistant verified each edit, checked for LSP errors, and ran build and vet before declaring completion.
  7. Document comprehensively: The final summary ties all the changes together with clear descriptions, line numbers, and rationale. It's designed to be read by someone who needs to understand the full scope of the fix without re-reading the entire conversation.

Conclusion

Message 1933 is deceptively simple — a bullet-point list of changes across three files. But behind it lies a sophisticated debugging journey through a distributed deadlock that could only be resolved by understanding the systemic interaction between a polling loop, an HTTP retry mechanism, and a queue-based work distribution system. The fix demonstrates several principles of robust distributed systems design: separation of concerns (HTTP clients should not implement application-level retry policies), progress awareness (backoff should account for whether the system is making forward progress), and data preservation (orphaned work should be re-assignable rather than deleted).

The message also illustrates the value of collaborative debugging — the assistant's initial design was good, but the user's refinement made it better. The progress-based backoff scheme that emerged from their exchange is more nuanced than either party's initial proposal. And the clean build + vet verification at the end provides the confidence needed to deploy the fix to a production system where proving pipelines were stalled.

For anyone working on distributed proving systems, queue-based work distribution, or any system where polling loops interact with rate-limited external services, this message and the reasoning behind it offer a valuable case study in how to identify, analyze, and resolve a class of deadlocks that are easy to introduce but hard to diagnose.