The Go server now runs sync -> infer -> flush-cache by itself at the times in SYNC_SCHEDULE (comma-separated HH:MM, default 04:00, "off" disables), read in SYNC_TZ (default Europe/Prague). Nothing runs at startup; an invalid value stops the server with an error. - internal/scheduler: time-of-day parsing, Next, and the run loop - banksync.Runner: one lock shared by /sync-bank and the scheduler so runs never overlap, plus the status of the last run - footer shows the last sync since start and the next scheduled run - binary embeds time/tzdata because the Alpine image has no zone data Deliberately not implemented in the legacy Python app, which is being phased out; recorded in CLAUDE.md under "Deliberately Go-only features". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6.6 KiB
6.6 KiB
Scheduled bank sync in the Go server (Go only)
Context
Bank synchronization only runs when someone opens /sync-bank; the payments
sheet is as fresh as the last click. The Go server should run the same
sync → infer → flush-cache sequence by itself on a configured schedule,
defaulting to once a day.
Decisions already made:
- Go only. Deliberately not implemented in the legacy Python app, which is being phased out (gunicorn's multi-worker model would also need a cross-process lock). This is recorded as an explicit exception to the dual implementation rule.
- Schedule format: one or more times of day. On by default, once a day.
Behaviour
SYNC_SCHEDULE— comma-separatedHH:MMtimes, default04:00.offdisables the scheduler. An invalid value makesfuj serverexit at startup with a clear error.SYNC_TZ— IANA zone the times are read in, defaultEurope/Prague. The binary embeds the zone database (import _ "time/tzdata"), because the Alpine image has none.- At each scheduled time the server runs exactly what
/sync-bankruns (banksync.DefaultWindow,SyncToSheets,InferPayments, cache flush), with a 10-minute timeout, and logs start, result and row counts viaslog. - Nothing runs at startup; a restart only recomputes the next run time.
- A scheduled run and a manual
/sync-bankcan no longer overlap: both go through one mutex. If a sync is already running, the manual page shows "a sync is already running" and a scheduled run is skipped with a log line. (This also closes today's gap where two overlapping manual runs could append the same rows.) - The footer of every page shows the last sync since the server started: time, trigger (scheduled / manual), ok or failed, and the next scheduled run. Status is in memory only and resets on restart.
Implementation
Branch feat/scheduled-bank-sync off main; MR via tea.
-
Config —
go/internal/config/config.go- Add
SyncSchedule []TimeOfDayandSyncLocation *time.LocationtoConfig. - Add
ParseSyncSchedule(s string) ([]TimeOfDay, error)("off"→ empty, sorted, de-duplicated, rejects anything notHH:MM) and aLoadSyncSchedule() (…, error)readingSYNC_SCHEDULE/SYNC_TZ.Load()keeps its signature;serverCmdcalls the new loader and exits on error. - New
config_test.gofor the parser (first tests in this package).
- Add
-
Scheduler — new package
go/internal/schedulerNext(now time.Time, times []TimeOfDay, loc *time.Location) time.Time— pure; builds candidates withtime.Dateinlocfor today and tomorrow and returns the earliest one strictly afternow(handles DST days).Run(ctx, times, loc, logger, job func(context.Context))— loop: computeNext, wait on a timer orctx.Done(), calljob.- Tests:
Nexttable test (before/after today's time, multiple times, midnight rollover, the two DST switch days in Europe/Prague);Runwith a cancelled context returns.
-
Sync runner with lock and status — new
go/internal/services/banksync/runner.goRunnerwraps the existing sync+infer function (moved out of the closure ingo/cmd/fuj/main.go) with async.Mutex(TryLock) and recordsStatus{Started, Finished, Trigger, Err}.Run(ctx, trigger string, out io.Writer) errorreturnsErrBusywhen a run is in progress.Last() (Status, bool)for display.- Tests with a fake job: status recorded, error recorded, second
concurrent call gets
ErrBusy.
-
Wiring —
go/cmd/fuj/main.go- Add
import _ "time/tzdata". - Build one
Runner;ActionHandlers.BankSynccallsrunner.Run(ctx, "manual", out)(signature unchanged, soTestServeSynckeeps working). - If the schedule is non-empty, start
go scheduler.Run(...)whose job callsrunner.Run(ctx, "scheduled", &buf)with a 10-minute timeout, thensources.FlushCache(), and logs the outcome. Log the schedule and next run at startup, or "scheduled sync disabled".
- Add
-
Display —
go/internal/webActionHandlersgains optionalSyncInfo func() string(last + next run as one line).PageDatagainsSyncInfo string; add a smallh.page(active)helper inhtml_handler.goand use it wherePageData{Active: …, Build: h.build}is built today.templates/partials/footer.tmpl: append the line when non-empty.templates/sync.tmpl: fix the description, which still says "current year" (stale since the window change).- Extend
html_handler_test.go: footer shows the info when provided, nothing extra when not.
-
Docs and notes
CLAUDE.md: documentSYNC_SCHEDULE/SYNC_TZunder the Go config list and the commands/architecture text; under "Dual implementation rule" add a "Deliberately Go-only" list with this feature and the reason.legacy/app.py: one comment abovesync_bank()stating that scheduled sync exists only in Go, on purpose.CHANGELOG.mdentry; copy this plan todocs/plans/2026-10-08-HHMM-scheduled-bank-sync.md.- Add a note to the cleanup task list: T18 (time zone) is addressed for the scheduler only; the "current month" logic still uses the process clock.
No new dependencies. No Dockerfile change. No change to parity fixtures or the JSON API.
Deployment notes (for the MR description)
- On by default at 04:00 Europe/Prague. Set
SYNC_SCHEDULE=offto disable. - A locally running
make web-gowith real credentials will also sync at that time. - With more than one replica each pod would run its own schedule; the deployment must stay at one replica (or disable the schedule on extras).
- Without
FIO_API_TOKEN_*the sync scrapes the transparent-account pages, as today.
Verification
make go-test-allandmake go-lintequivalent (go vet,gofmt -l);make testto confirm Python is untouched.- Start
fuj serverwithSYNC_SCHEDULE=off→ log says disabled, footer shows no schedule. - Start with an invalid value (
SYNC_SCHEDULE=25:00) → exits with an error. - Live check: start with
SYNC_SCHEDULEset two minutes ahead and watch the log for the scheduled run and the footer for the status line. This performs one real sync against the payments sheet (currently one pending transaction would be appended) — the same thing a click on/sync-bankdoes. - While that run is in progress, or with a test job, confirm a second trigger reports "already running".
- Build the Go image and confirm the schedule log line shows the Prague zone inside the container (proves the embedded zone data works).