Skip to content

fix: retry backend init with backoff so indexer connection failures self-heal - #61

Merged
pcfreak30 merged 1 commit into
developfrom
feat/backend-init-retry
Aug 21, 2026
Merged

fix: retry backend init with backoff so indexer connection failures self-heal#61
pcfreak30 merged 1 commit into
developfrom
feat/backend-init-retry

Conversation

@pcfreak30

@pcfreak30 pcfreak30 commented Aug 21, 2026

Copy link
Copy Markdown
Member

Summary

If the indexer connection fails during startup, the backend initialization (siastorage SDK CheckAppAuth / initSDK / OptimalDataSize inside factory.Init) failed permanently, setting backend status to error and never retrying — so the operator had to restart the server to recover. This PR makes async backend init self-heal with exponential backoff.

Changes

  • internal/backend/manager.go
    • Retry transient init failures with exponential backoff (5s base → 5min cap) until success, a terminal error, or context cancel.
    • isTerminalInitError classifies config/precondition errors (no app key set, no access keys, failed to open database) as non-retryable — retrying can't fix them.
    • New attemptInit runs one init attempt and closes the store on failure (no handle leak) without recording failInit; the loop decides retry vs. fail.
    • Context cancellation stops retrying without firing onFailure, so a clean shutdown doesn't reset onboarding state.
    • retryDelay is an injectable Manager field (defaults to retryBackoff) for fast, deterministic tests.
  • internal/backend/manager_test.go
    • Updated async-failure tests to the new retry semantics.
    • Added SelfHeals (2 failures then success via backoff) and RetryBackoffBounds tests.

During retries, status stays starting (surfaced via SSE), so the dashboard shows startup in progress rather than a terminal error.

Testing

  • go build ./...
  • go vet ./...
  • gofmt -l clean on changed files ✅
  • go test ./internal/backend/... ✅ (all pass, incl. new tests)

Pre-existing failures in internal/config and internal/store (permission-simulation tests that can't fail when run as root) are unrelated to this change and verified to fail on develop too.


Summary

This pull request implements automatic retry with exponential backoff for backend initialization failures, allowing the system to self-heal from transient indexer/connection failures at startup without requiring manual intervention.

Key Changes

Retry Mechanism with Backoff

  • Added retry logic: Backend initialization failures are now retried automatically with exponential backoff
  • Backoff schedule: Starts at 5 seconds and doubles with each retry attempt, capped at 5 minutes maximum
  • Self-healing: Transient failures (e.g., unreachable indexer, connection refused) are automatically retried until successful, eliminating the need for manual restarts

Error Classification

  • Terminal errors: Certain configuration/precondition errors are identified as non-retryable (e.g., "no app key set", "no access keys", "failed to open database", "backend init cancelled")
  • Retryable errors: All other errors (network issues, connection failures, etc.) are treated as transient and retried automatically

Lifecycle Improvements

  • Context cancellation handling: Cleanly stops retry attempts on shutdown without incorrectly triggering failure callbacks
  • Status preservation: Status remains "starting" during retry attempts rather than transitioning to an error state
  • Improved state management: Refactored initialization into a reusable attemptInit function that handles store lifecycle and cleanup consistently

Test Coverage

  • Added tests verifying self-healing behavior with retryable failures
  • Updated existing tests to reflect new error classification logic
  • Added tests for backoff bounds validation (base delay, exponential growth, and maximum cap)

@kody-ai

This comment has been minimized.

Comment thread internal/backend/manager.go
Comment thread internal/backend/manager.go Outdated

@kody-ai kody-ai 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.

@pcfreak30
pcfreak30 force-pushed the feat/backend-init-retry branch from 9ac8c11 to b13c585 Compare August 21, 2026 03:22
@kody-ai

This comment has been minimized.

Comment thread internal/backend/manager.go
Comment thread internal/backend/manager.go
@pcfreak30
pcfreak30 force-pushed the feat/backend-init-retry branch from b13c585 to c8fee3f Compare August 21, 2026 03:28
@kody-ai

This comment has been minimized.

Comment thread internal/backend/manager.go
Comment thread internal/backend/manager.go Outdated

@kody-ai kody-ai 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.

@pcfreak30
pcfreak30 force-pushed the feat/backend-init-retry branch from c8fee3f to a807030 Compare August 21, 2026 03:34
@kody-ai

This comment has been minimized.

Comment thread internal/backend/manager.go
Comment thread internal/backend/manager.go
@pcfreak30
pcfreak30 force-pushed the feat/backend-init-retry branch from a807030 to eae3a86 Compare August 21, 2026 03:40
@kody-ai

This comment has been minimized.

Comment thread internal/backend/manager.go

@kody-ai kody-ai 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.

@pcfreak30
pcfreak30 force-pushed the feat/backend-init-retry branch from eae3a86 to d0b407a Compare August 21, 2026 03:55
@pcfreak30

Copy link
Copy Markdown
Member Author

Thanks @kody. Addressed the stale-onFailure finding in d0b407a.

The terminal-error and retry-budget-exhausted branches in runAsyncInit now check m.stillStarting() (reading m.starting under m.mu) before calling failInit(..., onFailure), matching the guard the panic branch already had. If a concurrent Restart() already brought the backend to Running (clearing starting), the loop exits without firing onFailure, so onboarding access keys / ResetToAppKeySet() are never wiped by a stale failure.

Added regression test TestManager_InitFromConfigAsync_StaleTerminal_DoesNotFireOnFailure: first attempt fails transiently and parks in backoff (gated via retryDelay), a concurrent swapBackend brings the backend to Running, then the loop's next terminal attempt must not fire onFailure.

Note: this same push (d0b407a) also includes the earlier isTerminalInitError fix treating SQLite lock/busy as transient (from eae3a86). Full backend suite passes with -race; build and vet clean.

@kody start-review

@kody-ai

This comment has been minimized.

Comment thread internal/backend/manager.go
Comment thread internal/backend/manager.go
@pcfreak30
pcfreak30 force-pushed the feat/backend-init-retry branch from d0b407a to 200842d Compare August 21, 2026 03:59
@kody-ai

This comment has been minimized.

Comment thread internal/backend/manager.go
@pcfreak30
pcfreak30 force-pushed the feat/backend-init-retry branch from 200842d to 24b22e1 Compare August 21, 2026 04:04
@kody-ai

This comment has been minimized.

@kody-ai

kody-ai Bot commented Aug 21, 2026

Copy link
Copy Markdown

kody code-review Kody Rules medium

The new code (lines 593-655 in the diff) does not introduce new GORM queries, but the retry logic around the database operations (OpenDatabase, initCore) could benefit from explicit context timeout handling. However, the specific rule requires GORM queries needing .WithContext(ctx) — this PR doesn't add any GORM queries in the changed lines, and the existing GORM queries are in other files outside this diff. Therefore, no violation of rule 1 is introduced by this PR.

Kody rule violation: Disallow GORM queries without timeout

Comment thread internal/backend/manager.go
Comment thread internal/backend/manager.go

@kody-ai kody-ai 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.

@pcfreak30
pcfreak30 force-pushed the feat/backend-init-retry branch from 2e5c206 to eced05d Compare August 21, 2026 04:47
@kody-ai

This comment has been minimized.

Comment thread internal/backend/manager.go
@pcfreak30
pcfreak30 force-pushed the feat/backend-init-retry branch from eced05d to 56e72c7 Compare August 21, 2026 04:58
@kody-ai

This comment has been minimized.

Comment thread internal/backend/manager.go
@pcfreak30
pcfreak30 force-pushed the feat/backend-init-retry branch from 56e72c7 to b55aac5 Compare August 21, 2026 05:29
@kody-ai

This comment has been minimized.

Comment thread internal/backend/manager.go
Comment thread internal/backend/manager.go
@pcfreak30
pcfreak30 force-pushed the feat/backend-init-retry branch from b55aac5 to a12b2df Compare August 21, 2026 05:44
@kody-ai

This comment has been minimized.

@kody-ai

kody-ai Bot commented Aug 21, 2026

Copy link
Copy Markdown

kody code-review Kody Rules medium

The retry loop in runAsyncInit passes initCtx (a context.WithCancel from main.go with no timeout) through attemptInit/initCore to factory.Init, so GORM database operations lack a timeout and can run indefinitely. Derive a per-attempt timed context with context.WithTimeout or context.WithDeadline before calling attemptInit/factory.Init, since GORM queries require a timeout unless inside a db.RetryableComponentLock call.

Kody rule violation: Disallow GORM queries without timeout

Comment thread internal/backend/manager.go
Comment thread internal/backend/manager.go
Comment thread internal/backend/manager_test.go

@kody-ai kody-ai 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.

…elf-heal

Retry async backend init with exponential backoff (5s base, 5min cap) so a
transient indexer connection failure self-heals without a manual restart.
Bound the loop with maxInitRetries (12) so persistent non-terminal failures
are surfaced via onFailure instead of retrying forever, and so Cleanup's
initWg.Wait() always returns on shutdown. Hold restartMu only around each
init attempt (not across the backoff sleep) to avoid starving concurrent
Restart/Init. In InitAfterOnboardingAsync, close the transferred store and
reopen it from disk on every retry instead of reusing a closed handle.
Cancel the async-init context on shutdown in cmd/s3-server.
@pcfreak30
pcfreak30 force-pushed the feat/backend-init-retry branch from a12b2df to edd699c Compare August 21, 2026 05:56
@kody-ai

kody-ai Bot commented Aug 21, 2026

Copy link
Copy Markdown

Kody Review Complete

Great news! 🎉
No issues were found that match your current review configurations.

Keep up the excellent work! 🚀

Kody Guide: Usage and Configuration
Interacting with Kody
  • Request a Review: Ask Kody to review your PR manually by adding a comment with the @kody start-review command at the root of your PR.

  • Validate Business Logic: Ask Kody to validate your code against business rules by adding a comment with the @kody -v business-logic command.

  • Provide Feedback: Help Kody learn and improve by reacting to its comments with a 👍 for helpful suggestions or a 👎 if improvements are needed.

Current Kody Configuration
Review Options

The following review options are enabled or disabled:

Options Enabled
Bug
Performance
Security
Business Logic

Access your configuration settings here.

@kody-ai

kody-ai Bot commented Aug 21, 2026

Copy link
Copy Markdown

kody code-review Kody Rules medium

New GORM database operations in internal/backend/manager.go — validateAccessKeysExist and ListAccessKeys within the syncInit/attemptInit retry paths — lack .WithContext(ctx) timeouts, allowing queries to run indefinitely when the store reopens. Wrap all new GORM calls with .WithContext(ctx) to enforce timeout boundaries, except inside db.RetryableComponentLock.

Kody rule violation: Disallow GORM queries without timeout

@github-actions

Copy link
Copy Markdown

Docker image for testing

Pull the image for this PR:

docker pull ghcr.io/lumeweb/s3-server:sha-ab005c8

Or use in docker-compose:

services:
  s3-server:
    image: ghcr.io/lumeweb/s3-server:sha-ab005c8

The image is rebuilt on every push to this PR's branch.

@pcfreak30
pcfreak30 merged commit f746143 into develop Aug 21, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant