Repository navigation
fix: deduplicate heartbeat checks by queue task - #1080
AchoArnold wants to merge 7 commits into
Conversation
Match delivered task IDs against each monitor's queue_id before processing. Preserve emulator and local fallback delivery. Restart stopped checks when heartbeat history resumes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 742e0212-0866-4232-89d8-7d194134448a
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 39 |
| Duplication | 18 |
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
[Medium impact] The changes affect the heartbeat-monitoring workflow and its queued checks. This PR needs fixes before merging because database errors and an overlapping restart can stop heartbeat checks. FindingsSummaryThis PR rejects heartbeat checks whose delivered task ID differs from the monitor's saved
Diagram%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Delivered heartbeat check] --> B[Load monitor]
B -->|Read fails| C[Return listener error]
C --> D[Error logged; HTTP returns 204]
B -->|Read succeeds| E{Task ID matches saved ID?}
E -->|No| F[Ignore check]
E -->|Yes| G[Read heartbeat history]
G -->|Missing| H[Clear saved task ID]
G -->|Present| I[Check phone and queue next task]
I --> J[Save next task ID]
J -->|Write fails| K[Next task is rejected]
Reviews (1) · Last reviewed commit: "fix: deduplicate heartbeat checks by que..." · Reviewed by Greptile |
| if err != nil { | ||
| ctxLogger.Error(stacktrace.Propagatef(err, "cannot check if monitor exists with userID [%s] and owner [%s]", params.UserID, params.Owner)) | ||
| return service.scheduleHeartbeatCheck(ctx, time.Now().UTC(), params) | ||
| return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot check if monitor exists with userID [%s] and owner [%s]", params.UserID, params.Owner)) |
There was a problem hiding this comment.
A temporary monitor-load failure now stops the check stream. Monitor returns an error, but Publish only logs listener errors, so DispatchSync still succeeds and /v1/events returns 204. The queue receives a success response without another check being scheduled. Monitoring stays stopped until a later heartbeat or phone update meets RequiresCheck().
Forward this failure to the HTTP response or retain a safe retry path.
There was a problem hiding this comment.
Fixed in 291aaca. Publish now collects listener errors and DispatchSync propagates them for heartbeat checks, so /v1/events returns 500 and Cloud Tasks retries the same task after monitor/history read failures. Other event types retain their existing acknowledgement behavior. Added HTTP failure-then-success coverage and a heartbeat-read failure regression.
There was a problem hiding this comment.
Temporary load failure should recover by scheduling a new check itself and if not it's still fine, the monitor will recover when a new heartbeat is received.
There was a problem hiding this comment.
Self-scheduling on a monitor-load failure is a reasonable recovery path. One caveat: a new heartbeat does not always restart the check immediately—Store only schedules when monitor.RequiresCheck() is true, and that condition waits until UpdatedAt is more than two hours old (api/pkg/entities/heartbeat_monitor.go).
With the current error return and the propagation added in 291aacaa, the same Cloud Task is retried rather than acknowledged without a successor. If the intended fallback is to let monitoring remain stopped until the two-hour threshold or a monitor update, that’s a valid tradeoff, but it won’t necessarily recover on the next heartbeat.
There was a problem hiding this comment.
Updated in 832853b: a temporary monitor-load failure is logged and schedules a successor check itself. Missing monitors still stop, and heartbeat-history read failures are logged and stop without replaying the HTTP event. Heartbeat-based recovery retains the existing two-hour RequiresCheck threshold, so it is best-effort and not necessarily triggered by the very next heartbeat. This limitation is now explicit in the PR description.
| if params.QueueID != monitor.QueueID { | ||
| ctxLogger.Info(fmt.Sprintf("ignoring heartbeat check with queue ID [%s] for monitor [%s] with current queue ID [%s]", params.QueueID, monitor.ID, monitor.QueueID)) | ||
| return nil |
There was a problem hiding this comment.
The new comparison makes a failed UpdateQueueID write stop monitoring. scheduleHeartbeatCheck queues the next task first, then logs and ignores any error saving its ID. If that write fails, the next task arrives with its new ID while the monitor still holds the previous ID. This comparison discards the valid task as stale, leaving no further checks.
Make saving the next task's ID a required, recoverable part of scheduling before relying on it to reject deliveries.
There was a problem hiding this comment.
Fixed in 291aaca. scheduleHeartbeatCheck now returns an error when saving the next queue ID fails. That error reaches the heartbeat consumer as HTTP 500, leaving the original saved ID retryable; retrying the original check can persist a replacement. Added a regression covering a failed save, successful retry, rejection of the orphan task, and continued processing of the valid replacement.
There was a problem hiding this comment.
The check will be resumed eventaully by new hearatbeats instead of complicating with the 500 response, let's fail open heartbeat checks queue updates.
There was a problem hiding this comment.
Understood—the fail-open approach avoids returning HTTP 500 for a queue-ID persistence failure. One caveat: recovery via a later heartbeat is not guaranteed; if the phone stays offline, there may be no heartbeat to re-arm monitoring, and the orphaned task will be ignored because its ID does not match the stored queue ID. If that gap in offline detection is an accepted tradeoff, fail-open is consistent with that recovery model.
There was a problem hiding this comment.
Changed queue-ID saves to fail open in 832853b: persistence failures are logged and do not cause an HTTP 500. An orphaned successor is rejected by the stored-ID guard, and an eligible later heartbeat can establish a replacement stream. Recovery is not guaranteed if the phone remains offline, and the existing two-hour restart threshold still applies. The PR description documents that best-effort tradeoff, and the regression verifies recovery through a later heartbeat rather than HTTP replay.
|
|
||
| heartbeat, err := service.repository.Last(ctx, params.UserID, params.Owner) | ||
| if stacktrace.GetCode(err) == repositories.ErrCodeNotFound { | ||
| if err = service.monitorRepository.UpdateQueueID(ctx, monitor.ID, ""); err != nil { |
There was a problem hiding this comment.
Clearing QueueID here can erase a newer check started by a heartbeat. A check delayed for more than two hours can pass the ID comparison and find no history. Before this clear runs, an incoming heartbeat can see the old UpdatedAt, schedule a replacement, and save its new ID. This unconditional clear then erases that ID, so the replacement is discarded by the new comparison and monitoring stops.
Clear only the task ID this check observed, so an older check cannot cancel its replacement.
There was a problem hiding this comment.
Fixed in 291aaca. Added ClearQueueID with an atomic monitor-ID + observed-queue-ID predicate in both GORM and MongoDB repositories. Monitor passes the incoming check's queue ID, so a newer replacement is not cleared. Added deterministic overlap coverage showing that a heartbeat can restart the monitor while an older check reads missing history, plus a regression for the SQL update predicate.
There was a problem hiding this comment.
The conditional-clear fix was superseded in 83e5783 following the maintainer's review: checks with no heartbeat history now leave queue_id untouched and return without a successor. With no clearing write, an old check cannot erase a replacement task. Timestamp-based restart behavior is preserved, and the overlapping-replacement regression still passes.
Return heartbeat listener errors to queue consumers for retry. Require next-task ID persistence and conditionally clear observed task IDs. Cover HTTP retries, failed writes, and overlapping heartbeat restarts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 742e0212-0866-4232-89d8-7d194134448a
|
|
||
| heartbeat, err := service.repository.Last(ctx, params.UserID, params.Owner) | ||
| if stacktrace.GetCode(err) == repositories.ErrCodeNotFound { | ||
| if err = service.monitorRepository.UpdateQueueID(ctx, monitor.ID, ""); err != nil { |
There was a problem hiding this comment.
Why are we clearing the qeuueID? Why is this needed?
There was a problem hiding this comment.
Clearing is not needed for deduplication itself; it fixes the stopped-monitor restart behavior from the failing test you asked me to fix. When heartbeat history has expired, this check returns without scheduling a successor. Its stored queue ID would then point to a consumed task. Clearing it marks the stream inactive, and RequiresCheck() treats an empty ID as a reason to schedule a check immediately when a new heartbeat arrives. Otherwise a recent UpdatedAt can delay restarting for up to two hours. The clear is conditional on the incoming task ID, so it cannot erase a concurrently scheduled replacement.
There was a problem hiding this comment.
Remove this logic. Maybe fix the test or remove the test if if it's not rerelevant.
WHen heartbeat has expired this check shoudl return without a scucesor that's valid logic.
When a new check arrives, RequiresCheck should become true since it hasb een more than 60 days. THe heartbeats are deleted after 60 days
There was a problem hiding this comment.
Removed in 83e5783. Missing heartbeat history now stops the check without a successor or any queue_id mutation. RequiresCheck is timestamp-based again. The restart test now models a long-inactive monitor (61 days) rather than requiring an immediate restart from a recently updated monitor, and the clearing helpers/tests were removed. Full API tests, build, vet and lint passed.
Carry queue task IDs in a typed context key named queue.task.id. CloudEvents rejects dotted extension names, so keep this metadata internal. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 742e0212-0866-4232-89d8-7d194134448a
Stop checks without clearing task IDs when heartbeat history is absent. Restore timestamp-based RequiresCheck behavior and remove clearing helpers. Model long-inactive monitors in retention restart tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 742e0212-0866-4232-89d8-7d194134448a
Separate monitor lookup and deduplication from heartbeat timing decisions. Calculate heartbeat age once and reuse the missed-heartbeat window. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 742e0212-0866-4232-89d8-7d194134448a
| return h.responseForbidden(c) | ||
| } | ||
|
|
||
| queueID := "" |
There was a problem hiding this comment.
Put this logic to get/set queue ID in a seprate private method.
There was a problem hiding this comment.
Fixed in 832853b: the handler now delegates task-name normalization and typed context assignment to the private withQueueTaskID method. Existing delivery tests cover both a populated task header and a missing header.
| if taskName := c.Get("X-CloudTasks-TaskName"); taskName != "" { | ||
| queueID = fmt.Sprintf("%s/tasks/%s", h.queueConfig.Name, taskName) | ||
| } | ||
| ctx = context.WithValue(ctx, events.ExtensionQueueID, queueID) |
There was a problem hiding this comment.
Why did you use context instead of cloudevents.extension.
There was a problem hiding this comment.
The requested queue.task.id name is not a valid CloudEvent extension name: the Go SDK rejects the dots. This is delivery-task metadata rather than event data, so typed request context preserves that name without invalidating the CloudEvent. HTTP delivery and the local fallback both populate the same key for the listener.
| return listener.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot decode [%s] into [%T]", event.Data(), payload)) | ||
| } | ||
|
|
||
| queueID, _ := ctx.Value(events.ExtensionQueueID).(string) |
There was a problem hiding this comment.
if we're using ctx maybe we should name the value QuqueIdKey or perhpas use something from opentelemetry semconv?
There was a problem hiding this comment.
Renamed it to QueueIDKey in 832853b, retaining the private typed context key and the exact queue.task.id value. OpenTelemetry semantic-convention keys describe telemetry attributes; they do not provide the context-value contract needed to pass the delivery task ID between the handler and listener.
| ), | ||
| ) | ||
| if len(failures) != 0 { | ||
| return dispatcher.tracer.WrapErrorSpan(span, stacktrace.Propagatef(errors.Join(failures...), "cannot publish event [%s] with ID [%s]", event.Type(), event.ID())) |
There was a problem hiding this comment.
Will this cause events to be replayed if one of the handlers fail? Replaying events is not safe since all handlers don't have deduplication logic. Rather use logging in the services to log whhen there's an issue so we can manually fix.
There was a problem hiding this comment.
Removed the heartbeat-specific listener-error propagation and Publish error aggregation in 832853b. Listener failures are logged, and valid events are acknowledged with HTTP 204 rather than replaying every subscriber. The regression now verifies that a failing heartbeat listener and a failing ordinary listener are both acknowledged.
| if err != nil { | ||
| ctxLogger.Error(stacktrace.Propagatef(err, "cannot check if monitor exists with userID [%s] and owner [%s]", params.UserID, params.Owner)) | ||
| return service.scheduleHeartbeatCheck(ctx, time.Now().UTC(), params) | ||
| return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot check if monitor exists with userID [%s] and owner [%s]", params.UserID, params.Owner)) |
There was a problem hiding this comment.
Temporary load failure should recover by scheduling a new check itself and if not it's still fine, the monitor will recover when a new heartbeat is received.
| if params.QueueID != monitor.QueueID { | ||
| ctxLogger.Info(fmt.Sprintf("ignoring heartbeat check with queue ID [%s] for monitor [%s] with current queue ID [%s]", params.QueueID, monitor.ID, monitor.QueueID)) | ||
| return nil |
There was a problem hiding this comment.
The check will be resumed eventaully by new hearatbeats instead of complicating with the 500 response, let's fail open heartbeat checks queue updates.
Extract task context handling and rename the typed queue task key. Log heartbeat lookup and queue-ID save errors with best-effort recovery. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 742e0212-0866-4232-89d8-7d194134448a
There was a problem hiding this comment.
🟡 Changes recommended
Queue ownership advances before offline dispatch succeeds and lacks atomic claiming, allowing failed work to lose retries, overlapping deliveries to send duplicate notifications, and phones to remain incorrectly marked online.
4 open issues
What changed in this PR
This PR adds queue-task identity checks to httpSMS heartbeat monitoring to suppress stale check streams and improve failure handling.
Changes:
- Pass delivered task IDs through typed context metadata, including emulator and local fallback delivery.
- Reject mismatched heartbeat checks and propagate heartbeat-processing failures for HTTP retries.
- Add regression coverage for task identity, redelivery, scheduling failures, and inactive-monitor restarts.
| File | Description |
|---|---|
api/pkg/services/heartbeat_service.go |
Validates task identity and propagates monitoring and scheduling errors. |
api/pkg/services/heartbeat_service_test.go |
Tests scheduling failures, restarts, and concurrent replacement preservation. |
api/pkg/services/event_dispatcher_service.go |
Returns listener errors and supplies fallback task identity. |
api/pkg/services/event_dispatcher_service_test.go |
Tests fallback and emulator task identity. |
api/pkg/services/emulator_push_queue.go |
Generates full task resource names and sends task headers. |
api/pkg/listeners/heartbeat_listener.go |
Passes task identity into heartbeat monitoring. |
api/pkg/listeners/heartbeat_listener_test.go |
Tests stale-task rejection, redelivery, and load failures. |
api/pkg/handlers/events_handler.go |
Reconstructs task resource names from delivery headers. |
api/pkg/handlers/events_handler_test.go |
Tests task metadata and heartbeat-specific retry responses. |
api/pkg/events/listeners.go |
Defines the typed context key for queue-task identity. |
Review effort: Balanced
| return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot check if monitor exists with userID [%s] and owner [%s]", params.UserID, params.Owner)) | ||
| } | ||
|
|
||
| if params.QueueID != monitor.QueueID { |
There was a problem hiding this comment.
The snapshot check does not guarantee exactly-once side effects for overlapping deliveries, and a concurrent restart can lose ownership to an in-flight successor update. This PR implements the requested eventual suppression of stale check streams, not an atomic claim/lease protocol. A stronger ownership protocol needs a separate recovery design; the owner explicitly rejected HTTP replay and requested simpler fail-open behavior in the preceding review. I have documented this concurrency limitation rather than introducing claims or retry semantics beyond that scope.
| time.Now().UTC().Sub(heartbeat.Timestamp) < (heartbeatCheckInterval*5) && monitor.PhoneOnline { | ||
| return service.handleFailedMonitor(ctx, heartbeat.Timestamp, params) | ||
| if heartbeatAge > heartbeatCheckInterval*4 && monitor.PhoneOnline { | ||
| return service.handleFailedMonitor(ctx, heartbeat.Timestamp, params) |
There was a problem hiding this comment.
This review targets 724b209. In 832853b, valid events are acknowledged with HTTP 204 even when a listener fails; an offline dispatch failure is logged, not returned as HTTP 500 for replay. The owner explicitly requested that behavior because replaying every subscriber is unsafe. The possibility of losing an offline action after dispatch failure remains a best-effort limitation; durable outbox/retry handling would require a separate design and is not being added to this task-ID filtering change.
| return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot check if monitor exists with userID [%s] and owner [%s]", params.UserID, params.Owner)) | ||
| } | ||
|
|
||
| if params.QueueID != monitor.QueueID { |
There was a problem hiding this comment.
Fixed in 13c10dc: a missing delivery task ID is now logged and ignored before loading the monitor. This also prevents the monitor-load recovery path from scheduling an anonymous stream. Added regressions for both IDs empty and for a missing delivery ID while the monitor repository is unavailable; full API tests, build, vet and lint pass.
| if err = service.monitorRepository.UpdateQueueID(ctx, params.MonitorID, queueID); err != nil { | ||
| service.logger.Error(stacktrace.Propagatef(err, "cannot update monitor with id [%s] with queue with ID [%s]", params.MonitorID, queueID)) | ||
| return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot update monitor with id [%s] with queue with ID [%s]", params.MonitorID, queueID)) |
There was a problem hiding this comment.
The delayed recovery is intentional under the latest owner review: failed queue-ID saves are logged and acknowledged, RequiresCheck remains timestamp-based, and empty IDs must not trigger immediate restart or clearing. The existing two-hour threshold also applies to incomplete initial scheduling. This best-effort recovery tradeoff is documented in the PR description and discussed in the replies to 4501827635 and 4501830015; I am not restoring the empty-ID restart condition that the owner previously removed.
Stop anonymous checks before monitor lookup or recovery scheduling. Cover empty stored IDs and unavailable monitors in listener regressions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 742e0212-0866-4232-89d8-7d194134448a


Summary
queue_id; log and ignore mismatches before reading heartbeat history, notifying phones, or scheduling another check.X-CloudTasks-TaskNameand the configured queue, and carry it in typed context metadata underqueue.task.id(CloudEvents disallows dotted extension names). Keep emulator and local timeout-fallback delivery compatible.queue_idunchanged when heartbeat history no longer exists; stop without a successor and preserve timestamp-based restarts for inactive monitors.Validation
go test ./... -timeout 120s(API): passed, including the previously failing heartbeat-history restart regression.go vetfor changed packages: passed.gofumpt,goimports, andgolintfor changed files: passed (Go tools invoked directly because the pre-commit shell wrappers do not resolve Windows paths).