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>
135 lines
6.6 KiB
Markdown
135 lines
6.6 KiB
Markdown
# 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-separated `HH:MM` times, default `04:00`.
|
|
`off` disables the scheduler. An invalid value makes `fuj server` exit at
|
|
startup with a clear error.
|
|
- `SYNC_TZ` — IANA zone the times are read in, default `Europe/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-bank` runs
|
|
(`banksync.DefaultWindow`, `SyncToSheets`, `InferPayments`, cache flush),
|
|
with a 10-minute timeout, and logs start, result and row counts via `slog`.
|
|
- Nothing runs at startup; a restart only recomputes the next run time.
|
|
- A scheduled run and a manual `/sync-bank` can 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`.
|
|
|
|
1. **Config** — `go/internal/config/config.go`
|
|
- Add `SyncSchedule []TimeOfDay` and `SyncLocation *time.Location` to
|
|
`Config`.
|
|
- Add `ParseSyncSchedule(s string) ([]TimeOfDay, error)` (`"off"` → empty,
|
|
sorted, de-duplicated, rejects anything not `HH:MM`) and a
|
|
`LoadSyncSchedule() (…, error)` reading `SYNC_SCHEDULE` / `SYNC_TZ`.
|
|
`Load()` keeps its signature; `serverCmd` calls the new loader and exits
|
|
on error.
|
|
- New `config_test.go` for the parser (first tests in this package).
|
|
|
|
2. **Scheduler** — new package `go/internal/scheduler`
|
|
- `Next(now time.Time, times []TimeOfDay, loc *time.Location) time.Time` —
|
|
pure; builds candidates with `time.Date` in `loc` for today and tomorrow
|
|
and returns the earliest one strictly after `now` (handles DST days).
|
|
- `Run(ctx, times, loc, logger, job func(context.Context))` — loop:
|
|
compute `Next`, wait on a timer or `ctx.Done()`, call `job`.
|
|
- Tests: `Next` table test (before/after today's time, multiple times,
|
|
midnight rollover, the two DST switch days in Europe/Prague); `Run` with
|
|
a cancelled context returns.
|
|
|
|
3. **Sync runner with lock and status** — new
|
|
`go/internal/services/banksync/runner.go`
|
|
- `Runner` wraps the existing sync+infer function (moved out of the
|
|
closure in `go/cmd/fuj/main.go`) with a `sync.Mutex` (`TryLock`) and
|
|
records `Status{Started, Finished, Trigger, Err}`.
|
|
- `Run(ctx, trigger string, out io.Writer) error` returns `ErrBusy` when a
|
|
run is in progress. `Last() (Status, bool)` for display.
|
|
- Tests with a fake job: status recorded, error recorded, second
|
|
concurrent call gets `ErrBusy`.
|
|
|
|
4. **Wiring** — `go/cmd/fuj/main.go`
|
|
- Add `import _ "time/tzdata"`.
|
|
- Build one `Runner`; `ActionHandlers.BankSync` calls
|
|
`runner.Run(ctx, "manual", out)` (signature unchanged, so
|
|
`TestServeSync` keeps working).
|
|
- If the schedule is non-empty, start `go scheduler.Run(...)` whose job
|
|
calls `runner.Run(ctx, "scheduled", &buf)` with a 10-minute timeout, then
|
|
`sources.FlushCache()`, and logs the outcome. Log the schedule and next
|
|
run at startup, or "scheduled sync disabled".
|
|
|
|
5. **Display** — `go/internal/web`
|
|
- `ActionHandlers` gains optional `SyncInfo func() string` (last + next run
|
|
as one line). `PageData` gains `SyncInfo string`; add a small
|
|
`h.page(active)` helper in `html_handler.go` and use it where
|
|
`PageData{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.
|
|
|
|
6. **Docs and notes**
|
|
- `CLAUDE.md`: document `SYNC_SCHEDULE` / `SYNC_TZ` under 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 above `sync_bank()` stating that scheduled
|
|
sync exists only in Go, on purpose.
|
|
- `CHANGELOG.md` entry; copy this plan to
|
|
`docs/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=off` to disable.
|
|
- A locally running `make web-go` with 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
|
|
|
|
1. `make go-test-all` and `make go-lint` equivalent (`go vet`, `gofmt -l`);
|
|
`make test` to confirm Python is untouched.
|
|
2. Start `fuj server` with `SYNC_SCHEDULE=off` → log says disabled, footer
|
|
shows no schedule.
|
|
3. Start with an invalid value (`SYNC_SCHEDULE=25:00`) → exits with an error.
|
|
4. Live check: start with `SYNC_SCHEDULE` set 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-bank`
|
|
does.
|
|
5. While that run is in progress, or with a test job, confirm a second
|
|
trigger reports "already running".
|
|
6. Build the Go image and confirm the schedule log line shows the Prague
|
|
zone inside the container (proves the embedded zone data works).
|