Repository navigation
fix(server): detach HTTP-triggered scheduled tasks from the request context - #895
Conversation
…ontext handleScheduledHTTP ran the task on r.Context(), so a scheduler that gave up (Cloud Scheduler attempt_deadline, Logic Apps 120s) and dropped the connection cancelled the task, possibly mid-purchase, losing the finalize/history write. Run the task on context.WithoutCancel bounded by a 15 minute timeout (matching the --task CLI default), and lift the server's 30s WriteTimeout for this route so a long task can still return its result instead of the connection being dropped. Closes LeanerCloud#784 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Note Currently processing new changes in this PR. This may take a few minutes, please wait... ⚙️ Run configuration
📒 Files selected for processing: 6
✨ Finishing Touches
Comment |
cristim
left a comment
There was a problem hiding this comment.
Independent review of f714189: draft: blocks ready
Scope: 2 files, +143/-2 (under the 400-line cap, http.go stays under 500 lines). The core change is sound: context.WithTimeout(context.WithoutCancel(r.Context()), 15m) keeps request values, drops cancellation, and stays bounded. The findings below are what keeps it from ready.
Findings
- actionable, internal/server/http.go:363 (and StartHTTPServer, http.go:172-205): no shutdown path for the detached task. Before this PR a forced
server.Shutdowntimeout closed connections and the request context was cancelled, so a task unwound beforeapp.Close. Now the task context only ends at the 15m timeout. Scenario: SIGTERM mid-sweep, Shutdown waits 30s (shutdownGracePeriod), returns, the process exits or closes the DB under a running task for up to 14.5 more minutes. Fix: derive the task context from an app-lifetime context that StartHTTPServer cancels after the grace period (WithoutCancel of the request, then cancel on that shutdown signal via context.AfterFunc or a merged cancel), or state in the PR why a hard kill is the accepted outcome and add a test for the chosen behavior. - actionable, internal/server/http.go:310: 15m task bound is not aligned with the hosting limits the PR names. Cloud Run
request_timeoutdefaults to 300s, so the platform considers the request finished at 5m while the handler may run 10m longer (the PR body admits CPU throttling / scale-in risk). Either set the bound from the platform limit, or show the Terraform request_timeout / cpu_idle settings for the scheduled services make 15m safe. AlsoscheduledResponseGrace/WriteDeadline of 15m30s can never matter on Cloud Run past 300s. - nit, internal/server/http_scheduled_context_test.go:15-72: the test proves cancellation is dropped and a deadline exists, but not that request-scoped values survive (the PR claims "Request-scoped values are kept"). Add a context.WithValue key to the request context and assert the task sees it; a swap to context.Background() would currently pass.
- nit, PR metadata: no labels. Closing issue #784 carries urgency/this-sprint, triaged, priority/p1, severity/high, impact/all-users, effort/m, type/bug; none are on the PR. Mirror them when it leaves draft.
- nit, scope/duplication:
purchase.detachedTimeout(internal/purchase/manager.go:237) and the new inline expression in #897 (detachedRIExchangeContext, internal/api/ri_exchange_detached.go) are the same one-liner as this call site. Three copies now. #897's body says it is kept local deliberately; agree on one owner before both merge.
Overlap with #897
Both use WithoutCancel + WithTimeout, different layers: #895 wraps the whole HTTP scheduled task (15m); #897 detaches the user-triggered ExecuteExchange handler (4m exec, 30s terminal save) in internal/api. No file overlap (internal/server vs internal/api). The scheduled ri_exchange_reshape task is covered by #895 at the outer level, and by #897 if it routes through the same handler code: the inner 4m budget then nests inside the 15m, which is compatible. The existing purchase path already detaches (executeAndFinalize via detachedTimeout), so #895 mainly protects ensureDB and non-purchase tasks (collect_recommendations, ri_exchange_reshape). Note that, so the PR body's "could abort a sweep mid-purchase" is overstated for purchases.
What I attacked
- Ran both new tests with -race at the SHA: pass. Full
go test ./internal/server/... ./cmd/server/...with AWS_CONFIG_FILE/AWS_SHARED_CREDENTIALS_FILE=/dev/null: 591 pass. - Mutation probes (each reverted): (a)
r.Context()instead of WithoutCancel: RequestCancelDoesNotCancelTask fails ("context canceled"); (b) WithCancel(WithoutCancel(...)), no deadline: same test fails on the deadline assertion; (c) remove theextendScheduledWriteDeadline(w)call: ResponseOutlivesServerWriteTimeout fails (client EOF). No probe covers value propagation (finding 3). - Fail-before/pass-after: the PR's own transcript shows both tests failing against pre-fix code; mutation (a) and (c) reproduce the same failures independently, which is equivalent evidence.
- Goroutine leaks: no new goroutine is spawned; the work stays on the handler goroutine and
defer cancel()runs, so no unbounded goroutine. The bound is the 15m timeout only (finding 1/2). - ResponseController reaches the real conn through the securityHeaders wrapper (second test goes through CreateHTTPServer and passes). ErrNotSupported is swallowed deliberately; other errors are logged.
- Retry overlap: a detached task outlives the scheduler attempt, so the retry runs concurrently; relies on the per-task advisory lock (
already_running), unchanged by this PR.
Gate status (origin/main Review gate)
- Independent review naming SHA: this comment (draft, so not a merge approval).
- Findings resolved: 2 actionable open.
- CI on SHA: no workflow runs reported by
gh pr checks, only CodeRabbit (skipped, draft). Not satisfied. - Local real-path verification evidence on the PR: present in the body (mocks, local macOS, fail/pass output); no real-account verification, correctly labelled as mocks.
- Verdict/SHA/evidence recorded on the PR: this comment.
Blocks ready
- Decide and implement the shutdown behavior for the detached task (finding 1) with a test.
- Reconcile the 15m bound with Cloud Run/Container Apps request limits or document and test the choice (finding 2).
- Add a context-value propagation assertion (finding 3).
- Mirror the issue labels; CI must run and be green on the final SHA; re-review after any new commit.
- Coordinate with #897 on a single detached-context helper (finding 5).
|
Clarification of the review above (f714189), after maintainer calibration. Only these block ready:
Non-blocking: finding 3 (a test that request values survive) is a nit. Finding 5 (one shared detached-context helper with #897 and |
…oud Run timeout Address review of LeanerCloud#895: - Track detached HTTP-triggered scheduled tasks. StartHTTPServer drains for 5/6 of the 30s grace, then cancels tasks still running and waits for them in the remaining time before main closes the database. - Lower the task bound from 15m to 300s, the Cloud Run request_timeout set in terraform/environments/gcp (cpu_idle = true, so no CPU past it), and pin it against the Terraform values with a test. - Assert request-scoped context values survive the detach. - Use ctxutil.DetachedWithTimeout, copied byte-identical from LeanerCloud#897. Refs LeanerCloud#784 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Reply to the independent review of f714189. New head: fb8e792. This is a new commit, not a force-push.
Verification at fb8e792 (local macOS, Go 1.26.9, mocks only, no cloud calls):
Remaining gap: nothing was tested against a real Cloud Scheduler or Logic Apps deployment. The Cloud Run throttling and SIGKILL timing come from the platform docs. |
There was a problem hiding this comment.
Independent delta review of fb8e792 (previous: f714189): draft: blocks ready (CI only)
OLD_SHA is an ancestor of NEW_SHA. Delta is 6 files, +313/-26. Base has not moved (merge-base is origin/main e003025), PR is mergeable.
Previous findings
| # | Finding | Status | Evidence |
|---|---|---|---|
| 1 | Shutdown of detached task (blocking per clarification) | RESOLVED | internal/server/http.go:203-215 drains for 5/6 of the grace period, then app.scheduledTasks.stop(shutdownCtx) cancels in-flight tasks and waits for them before StartHTTPServer returns; hard-kill on Cloud Run (SIGKILL at 10s) is documented at http.go:162-168 and in the PR body. Proof below. |
| 2 | 15m bound vs Cloud Run 300s (blocking per clarification) | RESOLVED | internal/server/scheduled_tasks.go:26 sets 300s, with the reasoning in the comment. cloud_run_request_timeout is 300 in terraform/environments/gcp/variables.tf:226, the three github-*.tfvars and dev.tfvars.example; cpu_idle = var.cpu_throttling at terraform/modules/compute/gcp/cloud-run/main.tf:142. Pinned by a test; with the bound set back to 15m it fails on dev.tfvars.example. |
| 3 | Value-propagation test (nit) | RESOLVED | http_scheduled_context_test.go:29-79 now asserts the request-scoped value; the author's context.Background() probe is a valid kill. |
| 4 | Labels (blocking per clarification) | RESOLVED | PR and #784 both carry: effort/m, impact/all-users, priority/p1, severity/high, triaged, type/bug, urgency/this-sprint. |
| 5 | Shared helper (follow-up) | PARTIALLY RESOLVED, non-blocking | internal/ctxutil/detached.go is used by the scheduled call site; purchase.detachedTimeout still has its own copy (author flags it as a follow-up). Agreed. |
New findings (all NON-BLOCKING)
- nit, internal/server/http.go: file is now 536 lines (origin/main: 510, already over; this PR adds 26). The project guideline is 500. Moving the shutdown block into scheduled_tasks.go would fix it; not a merge condition.
- nit, PR size: 431 lines against the 400 guideline, of which 56 are the ctxutil copy shared with #897. Acceptable given the overlap.
- nit, http.go:203-215 on Cloud Run: SIGKILL comes 10s after SIGTERM, but cancellation only fires at 25s. So on Cloud Run the cancel path never runs for a task that outlasts the drain; the outcome is the hard kill the PR documents. Cancelling earlier (for example when the drain starts) would let tasks unwind inside the 10s. Behavior matches what was accepted, so not blocking.
- nit, TestScheduledTaskTimeoutFitsCloudRunRequestTimeout only covers terraform/environments/gcp. Azure Container Apps limits are reasoned in prose only. Fine, since the 240s Azure ingress limit does not stop the task.
No races, leaks or double-cancel problems found:
start:wg.Add(1)is undermuandstoppedis checked under the same lock, so it cannot racestop'sWait. A task started afterstopgets a context that is already cancelled and a no-opdone.- The watcher goroutine in
startexits onstopChorctx.Done();done()cancels the context, so it cannot outlive the task. Doublecancel()is harmless. stopis idempotent (stoppedguard, channel closed once). If the wait times out, the helper goroutine blocked inWaitends when the tasks return; the process is exiting anyway.Applicationnow holds a mutex and WaitGroup by value;go vet(copylocks) is clean on internal/server and ctxutil.- Shutdown waits for the in-flight scheduled request during the drain, so the request is not truncated before the cancel; the post-cancel response write on a closing connection is harmless.
What I attacked
- Tests at NEW_SHA with -race:
./internal/server/... ./internal/ctxutil/... ./cmd/server/...594 pass (AWS_CONFIG_FILE/AWS_SHARED_CREDENTIALS_FILE=/dev/null).-run 'TestStartHTTPServer|Scheduled' -count=20 -race: 1400 pass, no flakes. - Mutation probes on the shutdown test (each reverted, tree clean afterwards):
- skip the
stopcall: fails ("StartHTTPServer returned while the detached scheduled task was still running"); stopdoes not closestopCh: fails with the same message;stopskipswg.Wait: fails, same message;- drop the drain shortening (Shutdown uses the full grace period): fails;
- set the bound to 15m:
TestScheduledTaskTimeoutFitsCloudRunRequestTimeoutfails.
- skip the
- The shutdown test is not vacuous: without
stop,Shutdowntimes out at 500ms without cancelling the detached context, so theunwoundchannel stays empty and the test fails. Fail-before/pass-after is therefore reproduced by the first two probes, which are equivalent to the old code's behavior.
Gate status (origin/main Review gate)
- Independent review naming SHA: this comment (draft, so not a merge approval).
- Findings resolved: all findings that the clarification marks as blocking are resolved in code; the rest are nits or follow-ups.
- CI on SHA: NOT satisfied. Both runs are
action_required(fork PR, needs maintainer approval): pre-commit 38823111211 and CI - Build & Test 38823111244. Nothing has run yet. - Local real-path verification evidence on the PR: present in the PR reply and body (mocks, local macOS, fail/pass output). No real Cloud Scheduler / Logic Apps run; the PR labels this gap correctly.
- Verdict, SHA and evidence recorded on the PR: this comment.
Blocks ready
- Approve the two
action_requiredworkflow runs and get CI green on fb8e792 (or the final SHA). Any new commit needs a re-review.
|
Gate record for fb8e792 (origin/main CLAUDE.md "Review gate"):
Marking ready (owner-authorized) and merging at fb8e792 with --match-head-commit. |
Summary
handleScheduledHTTPserves the/api/scheduled/{task}route that GCP Cloud Scheduler and Azure Logic Apps call. It ran the task onr.Context(). When the scheduler reaches its attempt deadline (Cloud Schedulerattempt_deadline = "320s", Logic Apps HTTP action 120s), it drops the connection. Go then cancels the request context, and the task is cancelled with it. A task cancelled partway can lose the write that records an external call that already happened, or leave a row stuck inrunning. The purchase execution path itself is already detached (executeAndFinalizeusesdetachedTimeout). The main exposure is thereforeensureDBand the tasks that are not purchases, such ascollect_recommendationsandri_exchange_reshape, plus the money sweeps once #712 schedules them over HTTP.A second problem on the same route:
http.Server.WriteTimeoutis 30s. It does not cancel the handler context (I checked this locally). But a task that runs longer than 30s finishes and then can't write its response, so the scheduler seesEOFand retries.Fix
internal/server/http.go,internal/server/scheduled_tasks.go). Once the method, path and task-name checks pass,ensureDBandHandleScheduledTaskrun onctxutil.DetachedWithTimeout(r.Context(), scheduledTaskHTTPTimeout). This context keeps the request's values, ignores the client disconnect, and has its own timeout.request_timeoutconfigured in Terraform:cloud_run_request_timeout = 300interraform/environments/gcp/github-{dev,staging,prod}.tfvarsanddev.tfvars.example, anddefault = 300interraform/environments/gcp/variables.tf. The module runs withcpu_idle = var.cpu_throttling, which defaults totrueinterraform/modules/compute/gcp/cloud-run/variables.tfand is not overridden by any environment. So once 300s have passed, Cloud Run considers the request finished, throttles CPU and may scale the instance in. A longer bound would promise time the platform doesn't give.TestScheduledTaskTimeoutFitsCloudRunRequestTimeoutchecks the bound against those Terraform values.extendScheduledWriteDeadlineuseshttp.ResponseControllerto raise the write deadline for this route only, to 300s plus 30s. Other routes keep the 30sWriteTimeout.scheduledTaskTrackerrecords in-flight scheduled tasks.StartHTTPServerdrains for 25s, i.e.shutdownGracePeriodminus one sixth.maincloses the database.handleLambdaScheduledEvent, which keeps the invocation context and its Lambda deadline.Shared helper with #897
internal/ctxutil/detached.goanddetached_test.goare copied byte-identical from #897 (fork/fix/issue-790at d4fd03d; blob hashes match), so the two PRs merge cleanly in either order. Movingpurchase.detachedTimeoutonto it is left for a follow-up.Not in this PR: turning off scheduler retries for the money sweeps. Those sweeps are not scheduled over HTTP yet; #712 adds them, and the retry config belongs with those Terraform resources. An overlapping retry is already skipped by the per-task advisory lock (
already_running).Closes #784
Verification
Run locally on macOS (darwin/arm64) with Go 1.26.9, using mocks only. No real cloud calls, purchases or deploys.
Tests in
internal/server/http_scheduled_context_test.go:TestHandleScheduledHTTP_RequestCancelDoesNotCancelTaskcancels the request context while a mock task is running. It checks that the task context is not cancelled, that the request-scoped value survives, that the deadline is at mostscheduledTaskHTTPTimeout, and that the response is 200.TestScheduledHTTP_ResponseOutlivesServerWriteTimeoutuses a realCreateHTTPServerserver withWriteTimeoutset to 100ms and a task that takes 400ms. It checks that the client receives a 200.TestStartHTTPServerCancelsScheduledTaskOnShutdownruns a realStartHTTPServer, sends SIGTERM while a scheduled task is in flight, and checks that the task saw the cancellation beforeStartHTTPServerreturned.TestScheduledTaskTimeoutFitsCloudRunRequestTimeoutchecks that the bound is at most everycloud_run_request_timeoutinterraform/environments/gcp.Fail-before evidence:
start(context.Background(), ...)):Expected request-scoped, got <nil>.Pass-after evidence at fb8e792:
go test -race ./internal/server/ -run '...4 tests above...|TestStartHTTPServer' -count=1 -v: all PASS, no races.go build ./...: okgo vet ./internal/server/... ./internal/ctxutil/... ./cmd/server/...: okAWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null go test ./internal/server/... ./internal/ctxutil/... ./cmd/server/... -count=1: all ok. The env override isolates two existing tests from a broken local~/.aws/config; that is unrelated to this change.golangci-lint run ./internal/server/... ./internal/ctxutil/... ./cmd/server/...(v2.10.1, as pinned in the Makefile): 0 issuesNot covered: nothing was run against a real Cloud Scheduler or Logic Apps calling Cloud Run or Container Apps. The Cloud Run CPU-throttling and SIGKILL timing come from the platform docs and were not observed.
Note:
internal/server/http.gowas 510 lines before this PR and is 536 now, over the 500-line guideline. The new logic lives inscheduled_tasks.go.Labels: the triage labels from #784 have been mirrored by a maintainer (the author can't set labels).
🤖 Generated with Claude Code