Skip to content

fix(daemon): heartbeat empty /sync to cloud so brains.last_sync_at advances#198

Merged
Gradata merged 1 commit into
mainfrom
fix/daemon-empty-sync-heartbeat
May 15, 2026
Merged

fix(daemon): heartbeat empty /sync to cloud so brains.last_sync_at advances#198
Gradata merged 1 commit into
mainfrom
fix/daemon-empty-sync-heartbeat

Conversation

@Gradata
Copy link
Copy Markdown
Owner

@Gradata Gradata commented May 15, 2026

Bug

Dashboard 'Sync Now' returned 'Synced 0 events' (success) but 'last sync' stayed at e.g. 15h ago because the daemon short-circuited the empty-payload case and never hit the cloud. brains.last_sync_at therefore stayed stale.

Fix

When no new local events past the watermark, still POST an empty payload to api.gradata.ai/api/v1/sync as a heartbeat. The cloud /sync endpoint already updates brains.last_sync_at to now() in this path. Failures are logged at info level (non-fatal).

Verify

Before:
3b49e9c6 last_sync= 2026-05-15T08:25:36+00:00

After one POST /sync with watermark=329 (no new events):
3b49e9c6 last_sync= 2026-05-15T23:43:58+00:00

Follow-up to #196 and #197.

…at advances

When the dashboard 'Sync Now' button is pressed and there are no new local events past the watermark, the daemon was short-circuiting and returning success WITHOUT calling the cloud. This left brains.last_sync_at stale, so the dashboard kept showing 'last sync Nh ago' even though the button worked.

Fix: still POST an empty payload to api.gradata.ai/api/v1/sync as a heartbeat. The cloud sync endpoint already updates last_sync_at to now() in the no-data path.

Verified end-to-end: cloud brain row advanced from 08:25 UTC to 23:43 UTC after one button press.
Copy link
Copy Markdown

@greptile-apps greptile-apps Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

@Gradata Gradata merged commit b1ac70f into main May 15, 2026
0 of 9 checks passed
@Gradata Gradata deleted the fix/daemon-empty-sync-heartbeat branch May 15, 2026 23:44
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 15, 2026

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5c562d30-1abd-448e-8d26-c75c1d4de4f3

📥 Commits

Reviewing files that changed from the base of the PR and between 8b723d2 and ec65031.

📒 Files selected for processing (1)
  • Gradata/src/gradata/daemon.py

📝 Walkthrough

Summary

  • Heartbeat POST on empty sync: When the daemon's /sync endpoint has no new events past the watermark, it now sends an empty heartbeat POST to the cloud (api.gradata.ai/api/v1/sync) to allow the cloud to update brains.last_sync_at
  • Non-fatal failures: Cloud heartbeat POST failures are logged at info level and do not block the success response to the client
  • Conditional heartbeat: Heartbeat is only attempted if an API key can be resolved (explicit arg, env var, or ~/.gradata/key file); missing key returns early without cloud call
  • Dashboard fix: Resolves the issue where the "Sync Now" button appeared to work (returned 200) but left the dashboard's "last sync Nh ago" timestamp stale due to missing cloud update
  • Verified: Brain row's last_sync_at confirmed advancing from 08:25 UTC to 23:43 UTC after single heartbeat POST with watermark=329

Walkthrough

The /sync POST handler now sends an optional "heartbeat" cloud POST when no new events are found, allowing the dashboard to update brains.last_sync_at. The heartbeat is only attempted if an API key can be resolved; cloud request failures are non-fatal and logged at info level, with the handler continuing to return status: ok.

Changes

Empty sync heartbeat POST

Layer / File(s) Summary
Heartbeat cloud POST on empty sync
Gradata/src/gradata/daemon.py
When the events query returns no rows, the handler optionally resolves the API key, builds an empty corrections/events/lessons/meta_rules payload, and POSTs to the cloud sync URL to advance last_sync_at. Cloud request failures are treated as non-fatal (logged at info) and do not prevent the handler from returning status: ok with pushed: 0 and current timestamp.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~8 minutes

Possibly related PRs

Suggested labels

bug

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/daemon-empty-sync-heartbeat

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 OpenGrep (1.20.0)

OpenGrep fatal error (exit code 2):
┌──────────────┐
│ Opengrep CLI │
└──────────────┘

�[32m✔�[39m �[1mOpengrep OSS�[0m
�[32m✔�[39m Basic security coverage for first-party code vulnerabilities.

�[1m Loading rules from local config...�[0m
[00.22][ERROR]: Error: exception Glob.Lexer.Syntax_error("malformed glob pattern: missing ']'")
Raised at Glob__Lexer.syntax_error in file "libs/glob/Lexer.mll", line 8, characters 2-26
Called from Glob__Lexer.__ocaml_lex_token_rec in file "libs/glob/Lexer.mll", line 29, characters 26-53
Cal


Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot added the bug Something isn't working label May 15, 2026
Gradata pushed a commit that referenced this pull request May 17, 2026
…heartbeat

PR #198 changed empty /sync behavior to POST an empty heartbeat to cloud so brains.last_sync_at advances. The pre-#198 test still asserted mock_post.assert_not_called() — now expects exactly one call.
Gradata added a commit that referenced this pull request May 17, 2026
…194) (#200)

* feat(sync): write-through Brain.correct() -> /api/v1/ingest (day 3 of #194)

Every Brain.correct(draft, final) now enqueues the correction into a local sync_queue table and a background daemon thread drains the queue by POSTing each row to api.gradata.ai/api/v1/ingest.

Removes the dependency on session-end hooks and the cron sync band-aid. See /home/olive/gradata-office-hours-memo.md for the architectural rationale.

## Components
- src/gradata/_sync_queue.py: enqueue/peek/mark_synced/mark_failed CRUD + enqueue_correction helper
- src/gradata/_sync_worker.py: daemon-thread SyncWorker with start/stop(drain)/_tick, handles 2xx + dedup + 422 poison + 429 backoff + 5xx + network
- src/gradata/brain.py: Brain.__init__ starts worker when API key resolvable AND GRADATA_DISABLE_WRITE_THROUGH != 1; Brain.correct enqueues post-local-write; Brain.close drains and stops
- src/gradata/_migrations/__init__.py + onboard.py: sync_queue table + idx_sync_queue_pending (idempotent, no-op if #195 lands first)

## Env vars
- GRADATA_API_KEY (existing): write-through requires it
- GRADATA_DISABLE_WRITE_THROUGH=1: opt-out, fall back to hook+cron path
- GRADATA_CLOUD_INGEST_URL (default https://api.gradata.ai/api/v1/ingest)
- GRADATA_SYNC_TICK_SEC (default 30)

## Failure modes
- Cloud unreachable: row stays pending, retried next tick
- 422 permanent: poison row marked synced + failed so it doesn't block batch
- 429: bail batch, retry next tick
- No API key: worker doesn't start, no enqueue, local correct() unaffected
- _write_through_enqueue exception: caught, local correct() always succeeds

## Tests
- tests/test_sync_queue.py (9): existing CRUD primitives + enqueue_correction
- tests/test_sync_worker.py (9): HTTPServer stub /ingest with all status branches + at-least-once + stop-drain
- tests/test_brain_write_through.py (5): enqueue happy path, disabled, no-key, cloud-unreachable, sabotaged enqueue

38 passed locally. Series: #195 (day 1 SDK queue), Gradata/gradata-cloud#58 merged (day 2 cloud receiver), this PR (day 3 wire-up).

* fix(tests): update test_sync_with_no_events_returns_zero for PR #198 heartbeat

PR #198 changed empty /sync behavior to POST an empty heartbeat to cloud so brains.last_sync_at advances. The pre-#198 test still asserted mock_post.assert_not_called() — now expects exactly one call.

---------

Co-authored-by: data-engineer <data-engineer@gradata.ai>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant