{"record":{"id":"458d83735e57aba4","repo":"gastownhall/beads","slug":"db-issuesqlrepository-heartbeatissue-w","errorCode":null,"errorMessage":"db: IssueSQLRepository.HeartbeatIssue: %w","messagePattern":"db: IssueSQLRepository\\.HeartbeatIssue: %w","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"internal/storage/domain/db/issue.go","lineNumber":1248,"sourceCode":"\t}\n\treturn nil\n}\n\n// HeartbeatIssue refreshes the lease on an issue actor holds in_progress,\n// mirroring DoltStore.HeartbeatIssue: wisps are ephemeral and never leased,\n// and the SQL work is the classic issueops.HeartbeatIssueInTx — same clock\n// (time.Now().UTC()), same TTL resolution (issueops.LeaseTTL), and the same\n// only-current-owner classification (storage.ErrAlreadyClaimed /\n// ErrNotClaimable) — so classic `bd reclaim` staleness semantics see proxied\n// heartbeats identically. Deliberately NO Dolt commit: the leases table is\n// dolt_ignored (bd-lrgn1), and the cmd layer commits this transaction with\n// uow.RunTxEphemeral (plain SQL COMMIT, nothing in dolt_log).\nfunc (r *issueSQLRepositoryImpl) HeartbeatIssue(ctx context.Context, id, actor string) error {\n\tif issueops.IsActiveWispInTx(ctx, r.runner, id) {\n\t\treturn fmt.Errorf(\"db: IssueSQLRepository.HeartbeatIssue: %w: %s is ephemeral\", storage.ErrNotClaimable, id)\n\t}\n\tif err := issueops.HeartbeatIssueInTx(ctx, r.runner, id, actor); err != nil {\n\t\treturn fmt.Errorf(\"db: IssueSQLRepository.HeartbeatIssue: %w\", err)\n\t}\n\treturn nil\n}\n\n// WakeExpiredDefers runs the shared lazy defer-wake body against this\n// repository's runner (the same DBTX-shaped seam ReclaimExpiredLeases uses)\n// and reports how many rows woke per table. The issues count decides whether\n// the transaction's owner mints a dolt commit; the wisps count decides\n// whether it must still issue a plain SQL commit — wisp tables are\n// dolt_ignored, so a wisp-only wake mints no version commit, but a caller\n// that treats it as \"nothing happened\" rolls the wisp writes back.\nfunc (r *issueSQLRepositoryImpl) WakeExpiredDefers(ctx context.Context) (issues, wisps int, err error) {\n\tout, err := issueops.WakeExpiredDefersInTx(ctx, r.runner)\n\tif err != nil {\n\t\treturn 0, 0, fmt.Errorf(\"db: IssueSQLRepository.WakeExpiredDefers: %w\", err)\n\t}\n\treturn len(out.Issues), len(out.Wisps), nil\n}","sourceCodeStart":1230,"sourceCodeEnd":1266,"githubUrl":"https://github.com/gastownhall/beads/blob/71377f276968b452ee607177637970a4ff888584/internal/storage/domain/db/issue.go#L1230-L1266","documentation":"Wraps a failure from issueops.HeartbeatIssueInTx when renewing an issue lease. Unlike the ErrNotClaimable case, this is an unexpected failure: the heartbeat UPDATE (touching the lease timestamp for the actor) failed at the SQL or validation level. The wrapper adds repository context; the cause is in the chain.","triggerScenarios":"Calling HeartbeatIssue on a non-wisp ID when: the lease row for (id, actor) is missing (lease expired or never claimed), the UPDATE fails at the driver level, or the transaction runner errors.","commonSituations":"Lease expired and was reclaimed before the heartbeat arrived (stale worker), wrong actor string passed so no matching lease row, connection loss during the write, or DB read-only/permission issue.","solutions":["Check the wrapped cause: if the lease is gone, re-claim the issue instead of heartbeating.","Verify the actor string matches the one used at Claim time.","Retry transient driver errors; heartbeat is idempotent while the lease exists.","Tighten the heartbeat interval so renewals arrive before lease expiry."],"exampleFix":"// before\nif err := repo.HeartbeatIssue(ctx, id, actor); err != nil { return err }\n// after\nif err := repo.HeartbeatIssue(ctx, id, actor); err != nil {\n    return fmt.Errorf(\"heartbeat %s: %w (lease may be expired — re-claim)\", id, err)\n}","handlingStrategy":"retry","validationCode":"// ensure a live claim exists for this actor before renewing\nissue, err := repo.Get(ctx, id)\nif err != nil { return err }\nif issue.Assignee != actor { return fmt.Errorf(\"no active lease for %s\", actor) }","typeGuard":null,"tryCatchPattern":"if err := repo.HeartbeatIssue(ctx, id, actor); err != nil {\n    if isLeaseGone(err) { return reClaimIssue(ctx, id, actor) } // expired: re-claim\n    if isTransientDBErr(err) { return retryHeartbeat(err) }\n    return err\n}","preventionTips":["Heartbeat at intervals well below the lease TTL.","Keep the actor string identical to the Claim call.","On failure, fall back to re-claiming rather than assuming the lease persists.","Monitor heartbeat error rates to catch clock/TTL misconfig."],"tags":["database","lease","heartbeat"],"backgroundTag":"lease-renewal-failed","analyzedSha":"71377f276968b452ee607177637970a4ff888584","analyzedAt":"2026-08-30T18:55:39.744Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}