-
Notifications
You must be signed in to change notification settings - Fork 0
Operations
Day-2 stuff: backups, watch renewal, log inspection, troubleshooting.
Everything important is in Postgres. The token ciphertexts in the
account table are useless without your ENCRYPTION_KEY, so keep both:
-
Database: snapshot the
db_dataDocker volume, orpg_dump:docker compose exec db pg_dump -U skulid skulid \ > backup-$(date +%Y%m%d).sql
-
Encryption key: keep
ENCRYPTION_KEYsomewhere offline. If you lose it, the database is recoverable but every Google account must be reconnected. - Session secret: optional to back up — losing it just bounces active logins to the login page.
Google push channels expire after 7 days. The scheduler renews them automatically when there's <24h left, but two situations require manual re-registration:
-
Your
EXTERNAL_URLchanged. The old webhook URL is dead and Google will give up retrying. - You restored from a backup older than 7 days. Channel state in the DB is stale.
Fix: Settings → Re-register all webhooks. This stops every existing channel and creates a new one for every calendar.
Audit in the nav shows the last 200 actions. Each entry has:
| Column | Meaning |
|---|---|
| When | UTC timestamp |
| Kind |
rule, smart_block, task, habit, buffer, or ai
|
| Rule | Sync rule ID (if kind=rule) |
| Block | Smart block ID (if kind=smart_block) |
| Action |
create, update, delete, error, etc. |
| Source | Source Google event ID |
| Target | Target Google event ID |
| Message | Free text (error details, backfill summary, etc.) |
For deeper inspection, the table is audit_log in Postgres.
audit_log is the highest-churn table in the schema — smart-block and
buffer recompute both re-diff on every sync, so a busy calendar
appends rows continuously. Since the UI only ever shows the last 200,
everything older is pure disk.
Rows older than 90 days are pruned automatically, on the same 6-hourly
tick as the AI conversation cleanup. Change the window with
AUDIT_RETENTION_DAYS; set it to 0 to disable pruning and keep everything.
The prune deletes in batches of 5,000 rather than one statement, so the first run against a table that has already grown for a year doesn't hold locks or bloat WAL. In steady state it's a single statement touching almost nothing.
To see how much is there:
docker compose exec db psql -U skulid -d skulid \
-c "select count(*), min(ts), max(ts) from audit_log;"The daemon writes structured JSON logs to stdout:
docker compose logs -f appFilter by interesting things with jq:
docker compose logs -f app \
| jq 'select(.level == "ERROR")'
docker compose logs -f app \
| jq 'select(.msg == "http") | "\(.method) \(.path) \(.status) \(.dur)"' -rBoth services report health to Docker. db uses pg_isready; app probes
its own /healthz:
docker compose psA wedged daemon — deadlocked, connection pool exhausted, stuck goroutine — keeps its process alive, so Docker's restart policy alone would never notice it. The healthcheck is what turns that into a restart.
The probe is skulid -healthcheck, the same binary the container already
runs. The runtime image is distroless: it has no shell, no curl and no
wget, so a conventional CMD-SHELL healthcheck cannot work there. The
binary probing itself avoids shipping a second one just to answer the
question.
To run it by hand:
docker compose exec app /skulid -healthcheck; echo "exit=$?"0 means serving, non-zero means not.
/healthz is a liveness check, not a readiness one. It reports that the
HTTP server is accepting and serving requests, and deliberately says nothing
about Postgres. Gating it on the database would mean a DB blip restart-loops
the app during an outage it did not cause — which lengthens the outage rather
than shortening it. To check the database, query it directly (see
Upgrades).
skulid shows a red banner on every page, and a needs reconnect pill on Accounts, when Google has permanently refused to renew an account's access. The Accounts row spells out the reason. Click Reconnect on that row and complete the consent screen; rules, smart blocks and per-calendar settings all survive, because the account is matched on its Google subject ID and only the stored tokens are replaced.
Reconnect is bound to the account it was started from. If you complete the consent screen as a different Google account, skulid refuses the result and says so, rather than quietly adding a second account row while the broken one stays broken. Use + Connect Google account when you actually mean to add one.
Google revokes a refresh token when:
- The OAuth app is still in Testing publishing status. Tokens die after 7 days, every time. This is the usual cause, and reconnecting only buys another week — fix the publishing status instead, per Getting Started § Publishing status.
- Consent was withdrawn at myaccount.google.com/permissions.
- The account's password changed.
- The token went unused for six months.
-
GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRETchanged, or the OAuth client was deleted in Google Cloud. Here every account breaks at once, and no amount of reconnecting helps until.envmatches the real client.
Transient failures — a network blip, a Google 5xx, a rate limit — do not raise the banner. They are retried on the next tick.
To check the state directly:
SELECT email, needs_reauth, reauth_reason, reauth_detected_at FROM account;Google's Calendar API is quota-limited, and skulid's workload is bursty:
backfill walks history in one pass, the Planner issues one Events.list per
connected calendar per page load, and smart-block and buffer recompute
fire on a 15-second debounce — all multiplied by connected account.
Rate-limited calls are retried automatically with exponential backoff and
jitter, honouring Retry-After when Google sends it, up to 4 attempts. So an
occasional 429 is self-healing and needs no action; you'll see the request
succeed a moment later.
Two deliberate limits on that:
-
A 5xx is only retried for idempotent requests (GET, PUT, DELETE). A
failed
Events.insertis never replayed, because a 502 doesn't tell you whether the event was created — and a duplicate event on a real calendar is worse than a sync that has to be retried later. - Transport-level failures aren't retried at all. A connection that dies mid-write may still have delivered the request; that's not distinguishable from this layer.
If throttling is persistent rather than occasional, the cause is usually backfill over a very large calendar. Reduce Backfill last N days on the rule and run it in smaller passes.
-
Audit log — is the rule actually firing? Look for matching
kind=ruleentries. -
Push channel — is one registered?
SELECT calendar_id, watch_channel_id, watch_expires_at FROM sync_token;Empty channel ID means watches aren't set up. Hit Settings → Re-register all webhooks. -
Sync token — invalidate it manually to force a full resync:
Then click Sync now on a rule that uses that calendar.
UPDATE sync_token SET sync_token = '' WHERE calendar_id = ?;
- Is it enabled?
- Are the working hours sensible? An empty per-weekday list = no working windows that day.
- Time zone valid? Check
docker compose exec app /bin/shdoesn't exist on distroless — instead, look forsmart block recompute failedin logs. - Manual Recompute to force a pass.
You're trying to log in as a Google account that doesn't match the owner recorded by TOFU. Either:
- Sign in with the original owner account, or
- Reset ownership:
DELETE FROM setting WHERE key IN ('owner_email', 'owner_google_sub');Then log in fresh.
Google didn't return a refresh token. This usually means the OAuth
client wasn't created with prompt=consent and access_type=offline,
or the user previously authorized the app and Google decided not to
re-issue. Revoke access at
myaccount.google.com/permissions
and try again.
Either two channels are registered for the same calendar (rare; happens
if a Stop call previously failed), or the loop guard is misfiring.
Check that mirrored events have extendedProperties.private.skulidManaged="1"
in the Google UI — if not, your sealed token might be from a different
deployment that wrote without the loop key.
docker compose up -dThat's the whole thing. The app service is pull_policy: always, so
up re-pulls ghcr.io/ryakel/skulid:latest every time — you do not need
docker compose pull first.
Two things that are not part of upgrading:
-
--builddoes nothing.docker-compose.ymlpulls a published image and has nobuild:stanza, so there is no build context for the flag to act on. Nothing here compiles on your machine. -
git pulldoesn't fetch the new binary. That comes from the registry. Pull only when you want an updateddocker-compose.ymlor.env.example.
Pin a specific release with SKULID_TAG=v0.1.0 in .env if you'd rather
not track :latest.
Migrations run on startup. Breaking changes get a numbered migration file
under migrations/; to see which schema version an instance is actually
on, ask the database rather than the docs:
docker compose exec db psql -U skulid -d skulid \
-c "select max(version_id) from goose_db_version;"To wipe everything and start over:
docker compose down -v # -v removes the db_data volumeReconnect Google accounts after restart.