feat(go): scheduled bank sync (Go only)
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>
This commit is contained in:
+80
-2
@@ -1,20 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"fuj-management/go/internal/config"
|
||||
"fuj-management/go/internal/io/fio"
|
||||
"fuj-management/go/internal/io/sheets"
|
||||
"fuj-management/go/internal/logging"
|
||||
"fuj-management/go/internal/scheduler"
|
||||
"fuj-management/go/internal/services/banksync"
|
||||
"fuj-management/go/internal/services/membership"
|
||||
"fuj-management/go/internal/web"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
_ "time/tzdata" // the container image has no zone database; SYNC_TZ needs one
|
||||
)
|
||||
|
||||
// Injected at build time via -ldflags "-X main.version=... -X main.commit=... -X main.buildDate=..."
|
||||
@@ -90,8 +95,16 @@ func serverCmd(args []string) {
|
||||
}
|
||||
fioClients := buildFioClients(cfg)
|
||||
|
||||
actions := web.ActionHandlers{
|
||||
BankSync: func(ctx context.Context, out io.Writer) error {
|
||||
syncTimes, syncLoc, err := config.LoadSyncSchedule()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "fuj server: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
// One runner serves both the /sync-bank page and the scheduler, so the two
|
||||
// can never run at the same time.
|
||||
runner := banksync.NewRunner(
|
||||
func(ctx context.Context, out io.Writer) error {
|
||||
from, to := banksync.DefaultWindow(time.Now())
|
||||
|
||||
fmt.Fprintln(out, "=== Sync Fio Transactions ===")
|
||||
@@ -110,6 +123,22 @@ func serverCmd(args []string) {
|
||||
fmt.Fprintf(out, "Inferred %d row(s).\n", n)
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
actions := web.ActionHandlers{
|
||||
BankSync: func(ctx context.Context, out io.Writer) error {
|
||||
return runner.Run(ctx, "manual", out)
|
||||
},
|
||||
SyncInfo: func() string { return syncInfo(runner, syncTimes, syncLoc, time.Now()) },
|
||||
}
|
||||
|
||||
if len(syncTimes) == 0 {
|
||||
logger.Info("scheduled bank sync disabled (SYNC_SCHEDULE=off)")
|
||||
} else {
|
||||
logger.Info("scheduled bank sync enabled", "times", fmt.Sprint(syncTimes), "tz", syncLoc.String())
|
||||
go scheduler.Run(ctx, syncTimes, syncLoc, logger, func(ctx context.Context) {
|
||||
scheduledSync(ctx, logger, runner, sources)
|
||||
})
|
||||
}
|
||||
|
||||
build := web.BuildInfo{Version: version, Commit: commit, BuildDate: buildDate}
|
||||
@@ -120,6 +149,55 @@ func serverCmd(args []string) {
|
||||
}
|
||||
}
|
||||
|
||||
// scheduledSyncTimeout bounds one scheduled run (two bank fetches plus sheet writes).
|
||||
const scheduledSyncTimeout = 10 * time.Minute
|
||||
|
||||
// scheduledSync is the scheduler's job: the same sync → infer → flush-cache
|
||||
// sequence as the /sync-bank page, with the outcome written to the log.
|
||||
func scheduledSync(ctx context.Context, logger *slog.Logger, runner *banksync.Runner, sources membership.Sources) {
|
||||
ctx, cancel := context.WithTimeout(ctx, scheduledSyncTimeout)
|
||||
defer cancel()
|
||||
|
||||
logger.Info("scheduled bank sync starting")
|
||||
var buf bytes.Buffer
|
||||
err := runner.Run(ctx, "scheduled", &buf)
|
||||
if errors.Is(err, banksync.ErrBusy) {
|
||||
logger.Warn("scheduled bank sync skipped: a sync is already running")
|
||||
return
|
||||
}
|
||||
output := strings.Join(strings.Fields(buf.String()), " ")
|
||||
if _, ferr := sources.FlushCache(); ferr != nil {
|
||||
logger.Error("scheduled bank sync: cache flush failed", "err", ferr)
|
||||
}
|
||||
if err != nil {
|
||||
logger.Error("scheduled bank sync failed", "err", err, "output", output)
|
||||
return
|
||||
}
|
||||
logger.Info("scheduled bank sync finished", "output", output)
|
||||
}
|
||||
|
||||
// syncInfo renders the footer line: the last run since the server started
|
||||
// and the next scheduled one.
|
||||
func syncInfo(runner *banksync.Runner, times []scheduler.TimeOfDay, loc *time.Location, now time.Time) string {
|
||||
const layout = "2006-01-02 15:04"
|
||||
var parts []string
|
||||
if st, ok := runner.Last(); ok {
|
||||
result := "ok"
|
||||
if st.Err != nil {
|
||||
result = "FAILED"
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("last sync %s (%s) %s", st.Finished.In(loc).Format(layout), st.Trigger, result))
|
||||
} else {
|
||||
parts = append(parts, "no sync since start")
|
||||
}
|
||||
if next := scheduler.Next(now, times, loc); !next.IsZero() {
|
||||
parts = append(parts, "next "+next.Format(layout))
|
||||
} else {
|
||||
parts = append(parts, "scheduled sync off")
|
||||
}
|
||||
return strings.Join(parts, " | ")
|
||||
}
|
||||
|
||||
func feesCmd(args []string) {
|
||||
fs := flag.NewFlagSet("fees", flag.ExitOnError)
|
||||
fs.Usage = func() {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fuj-management/go/internal/scheduler"
|
||||
"fuj-management/go/internal/services/banksync"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSyncInfo(t *testing.T) {
|
||||
now := time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC)
|
||||
four := []scheduler.TimeOfDay{{Hour: 4}}
|
||||
|
||||
ok := banksync.NewRunner(func(context.Context, io.Writer) error { return nil })
|
||||
if got := syncInfo(ok, four, time.UTC, now); got != "no sync since start | next 2026-10-09 04:00" {
|
||||
t.Errorf("before any run: %q", got)
|
||||
}
|
||||
if err := ok.Run(context.Background(), "scheduled", io.Discard); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := syncInfo(ok, nil, time.UTC, now)
|
||||
if !strings.HasPrefix(got, "last sync ") || !strings.HasSuffix(got, "(scheduled) ok | scheduled sync off") {
|
||||
t.Errorf("after ok run, schedule off: %q", got)
|
||||
}
|
||||
|
||||
bad := banksync.NewRunner(func(context.Context, io.Writer) error { return errors.New("boom") })
|
||||
_ = bad.Run(context.Background(), "manual", io.Discard)
|
||||
if got := syncInfo(bad, four, time.UTC, now); !strings.Contains(got, "(manual) FAILED | next 2026-10-09 04:00") {
|
||||
t.Errorf("after failed run: %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user