diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/health.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/health.go index 78c97064..693ee0dc 100644 --- a/modules/mesh-mail-reader/cmd/mesh-mail-reader/health.go +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/health.go @@ -24,26 +24,30 @@ const HealthTool = "mail_reader_health" // judgement that succeeds makes it healthy again at once. func RecordJudgement(current ReaderHealth, outcome HealthOutcome, why string, now time.Time) ReaderHealth { if outcome == OutcomeOK { - return ReaderHealth{Healthy: true} + return ReaderHealth{Healthy: true, LastSuccess: now} } since := now if !current.Healthy && !current.Since.IsZero() { since = current.Since } - return ReaderHealth{Healthy: false, Cause: outcome, Why: why, Since: since} + return ReaderHealth{Healthy: false, Cause: outcome, Why: why, Since: since, LastSuccess: current.LastSuccess} } // Answer is the state as mail_reader_health answers it (1519/AC2, AC3): unhealthy names which of the three -// outcomes, the judgement's own words, and since when, in local; healthy otherwise. +// outcomes, the judgement's own words, and since when, in local; healthy names when the last batch judgement +// succeeded, or says no batch has run yet (1580). func (h ReaderHealth) Answer(local *time.Location) health.Answer { + in := func(t time.Time) string { + if local != nil { + return t.In(local).Format("15:04 2006-01-02") + } + return t.Local().Format("15:04 2006-01-02") + } if h.Healthy { - return health.Healthy("the last batch judgement succeeded") + if h.LastSuccess.IsZero() { + return health.Healthy(NoBatchYet) + } + return health.Healthy("the last batch judgement succeeded at " + in(h.LastSuccess)) } - since := h.Since - if local != nil { - since = since.In(local) - } else { - since = since.Local() - } - return health.Unhealthy(fmt.Sprintf("a batch judgement was %s: %s (since %s)", h.Cause, h.Why, since.Format("15:04 2006-01-02"))) + return health.Unhealthy(fmt.Sprintf("a batch judgement was %s: %s (since %s)", h.Cause, h.Why, in(h.Since))) } diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/migrations/0003-reader-health-says-when-a-batch-last-succeeded.sql b/modules/mesh-mail-reader/cmd/mesh-mail-reader/migrations/0003-reader-health-says-when-a-batch-last-succeeded.sql new file mode 100644 index 00000000..61d29b06 --- /dev/null +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/migrations/0003-reader-health-says-when-a-batch-last-succeeded.sql @@ -0,0 +1,5 @@ +-- Issue 1580: the health said "the last batch judgement succeeded" before any batch had run. reader_health +-- now keeps when a batch last succeeded (null while none has); a reader that has already judged mail takes +-- its last judged entry's time as that evidence. +alter table reader_health add column last_success timestamptz; +update reader_health set last_success = (select max(judged) from mail_entry) where healthy; diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/nobatch_test.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/nobatch_test.go new file mode 100644 index 00000000..ea32c127 --- /dev/null +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/nobatch_test.go @@ -0,0 +1,79 @@ +package main + +// Issue 1580: before any batch has run, the reader's health and status say so — never that the last batch +// succeeded; after one has, they say when (evidence over assertion). + +import ( + "context" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/health" +) + +func bundleOver(store Store) *Bundle { + return &Bundle{ + Caller: func(map[string]any) string { return "mesh-mail-web" }, + callersOverride: func() (Callers, error) { return Callers{"mesh-mail-web"}, nil }, + Store: func(context.Context) (Store, error) { return store, nil }, + } +} + +// 1580/AC1: a reader that has run no batch says no batch has run yet, in status and in its health, and +// never that the last batch succeeded. +func TestNoBatchYetIsSaidAsSuch(t *testing.T) { + b := bundleOver(newMemStore()) // a fresh store: PgStore's own answer when reader_health has no row + out, err := b.statusTool(map[string]any{}) + if err != nil { + t.Fatal(err) + } + status := out.(map[string]any) + if status["why"] != "no batch has run yet" { + t.Fatalf("status = %v, want why \"no batch has run yet\"", status) + } + if _, says := status["last-success"]; says { + t.Fatalf("status = %v names a last success, and none has run", status) + } + got, err := b.healthTool(map[string]any{}) + if err != nil { + t.Fatal(err) + } + a := got.(health.Answer) + if strings.Contains(a.Why, "succeeded") || !strings.Contains(a.Why, "no batch has run yet") { + t.Fatalf("mail_reader_health = %+v, want it to say no batch has run yet, never that one succeeded", a) + } +} + +// 1580/AC2: after one batch succeeds, status and health say when; after a failure, unhealthy with the cause +// and since when, as before. +func TestAfterABatchStatusSaysWhenItSucceeded(t *testing.T) { + ctx := context.Background() + store := newMemStore() + seedWaiting(t, store, 1) + waiting, _ := store.Waiting(ctx) + at := time.Date(2026, 10, 22, 9, 30, 0, 0, time.UTC) + if err := RunOneBatch(ctx, store, noopReadText, judgeAllNeither(waiting), waiting, func() time.Time { return at }, noopLogf); err != nil { + t.Fatal(err) + } + b := bundleOver(store) + out, _ := b.statusTool(map[string]any{}) + status := out.(map[string]any) + if status["healthy"] != true || status["last-success"] != "2026-10-22T09:30:00Z" { + t.Fatalf("status = %v, want healthy with last-success 2026-10-22T09:30:00Z", status) + } + h, _ := store.ReaderHealth(ctx) + if a := h.Answer(time.UTC); a.State != health.StateHealthy || !strings.Contains(a.Why, "09:30 2026-10-22") { + t.Fatalf("health = %+v, want healthy naming when the last batch succeeded", a) + } + + failedAt := at.Add(10 * time.Minute) + if err := recordOutcome(ctx, store, OutcomeRefused, "holds no licence", failedAt); err != nil { + t.Fatal(err) + } + out, _ = b.statusTool(map[string]any{}) + status = out.(map[string]any) + if status["healthy"] != false || status["cause"] != "refused" || status["since"] != "2026-10-22T09:40:00Z" { + t.Fatalf("status = %v, want unhealthy, refused, since 09:40", status) + } +} diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/pgstore.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/pgstore.go index de4ed15c..a9fb2e40 100644 --- a/modules/mesh-mail-reader/cmd/mesh-mail-reader/pgstore.go +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/pgstore.go @@ -312,10 +312,10 @@ func (s *PgStore) TakeWaiting(ctx context.Context, seq int64) error { func (s *PgStore) ReaderHealth(ctx context.Context) (ReaderHealth, error) { var h ReaderHealth var cause, why string - var since *time.Time - err := s.pool.QueryRow(ctx, `select healthy, cause, why, since from reader_health where id = 1`).Scan(&h.Healthy, &cause, &why, &since) + var since, lastSuccess *time.Time + err := s.pool.QueryRow(ctx, `select healthy, cause, why, since, last_success from reader_health where id = 1`).Scan(&h.Healthy, &cause, &why, &since, &lastSuccess) if errors.Is(err, pgx.ErrNoRows) { - return ReaderHealth{Healthy: true}, nil + return ReaderHealth{Healthy: true}, nil // 1580: healthy, and no batch has run yet (LastSuccess zero) } if err != nil { return ReaderHealth{}, err @@ -324,6 +324,9 @@ func (s *PgStore) ReaderHealth(ctx context.Context) (ReaderHealth, error) { if since != nil { h.Since = since.UTC() } + if lastSuccess != nil { + h.LastSuccess = lastSuccess.UTC() + } return h, nil } @@ -333,9 +336,10 @@ func (s *PgStore) SetReaderHealth(ctx context.Context, h ReaderHealth) error { t := h.Since since = &t } - _, err := s.pool.Exec(ctx, `insert into reader_health (id, healthy, cause, why, since) values (1, $1, $2, $3, $4) - on conflict (id) do update set healthy = excluded.healthy, cause = excluded.cause, why = excluded.why, since = excluded.since`, - h.Healthy, string(h.Cause), h.Why, since) + _, err := s.pool.Exec(ctx, `insert into reader_health (id, healthy, cause, why, since, last_success) values (1, $1, $2, $3, $4, $5) + on conflict (id) do update set healthy = excluded.healthy, cause = excluded.cause, why = excluded.why, since = excluded.since, + last_success = excluded.last_success`, + h.Healthy, string(h.Cause), h.Why, since, nullDate(h.LastSuccess)) return err } diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/schema.sql b/modules/mesh-mail-reader/cmd/mesh-mail-reader/schema.sql index 8dcf4522..918c0e58 100644 --- a/modules/mesh-mail-reader/cmd/mesh-mail-reader/schema.sql +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/schema.sql @@ -1,4 +1,4 @@ --- The consolidated schema, through migration 0002: what a fresh database is given at once (the mesh's rule: a +-- The consolidated schema, through migration 0003: what a fresh database is given at once (the mesh's rule: a -- numbered migration for the running store, and this for a fresh install). create table mail_group ( id bigserial primary key, @@ -69,11 +69,13 @@ create table waiting_mail ( -- The reader's own health (novox/hq ADR 0459 §12, issue 1519), kept across a restart: one row, singleton by -- its fixed id. cause is "" while healthy, else one of refused, timed out, out of shape; since is the first --- failure's own time, not the latest one. +-- failure's own time, not the latest one. last_success is when a batch last succeeded, null while none has +-- (issue 1580: no batch has run yet is said as such). create table reader_health ( id smallint primary key default 1, healthy boolean not null default true, cause text not null default '', why text not null default '', since timestamptz, + last_success timestamptz, constraint reader_health_singleton check (id = 1)); diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/store.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/store.go index 7afd9565..5615c5e2 100644 --- a/modules/mesh-mail-reader/cmd/mesh-mail-reader/store.go +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/store.go @@ -118,14 +118,20 @@ const ( // ReaderHealth is the kept health state (1519): unhealthy once a judgement is refused, times out or answers out // of shape, naming the cause, the judgement's own words, and since when; healthy again once one succeeds. Kept -// in the module's own database, so a restart answers the same (1519/AC4). +// in the module's own database, so a restart answers the same (1519/AC4). LastSuccess is when a batch's +// judgement last succeeded, zero while none has (issue 1580: healthy with no batch yet is said as such, never +// as a success). type ReaderHealth struct { - Healthy bool - Cause HealthOutcome // "" while healthy - Why string // the judgement's own words - Since time.Time // the first failure's own time; zero while healthy + Healthy bool + Cause HealthOutcome // "" while healthy + Why string // the judgement's own words + Since time.Time // the first failure's own time; zero while healthy + LastSuccess time.Time // zero while no batch has succeeded } +// NoBatchYet is what the health and status say before any batch has succeeded (1580/AC1). +const NoBatchYet = "no batch has run yet" + // Store is everything mesh-mail-reader keeps of its own. A PgStore (pgstore.go) backs it in the running // module; the tests use an in-memory double (kit_test.go). type Store interface { @@ -139,7 +145,8 @@ type Store interface { // TakeWaiting removes a waiting mail once it has been handed to reading (1520). TakeWaiting(ctx context.Context, seq int64) error - // ReaderHealth answers the kept health state (1519/AC4); healthy with a zero Since on a fresh database. + // ReaderHealth answers the kept health state (1519/AC4); healthy with a zero Since and a zero LastSuccess on a + // fresh database (1580: no batch has run yet). ReaderHealth(ctx context.Context) (ReaderHealth, error) // SetReaderHealth replaces the kept health state. SetReaderHealth(ctx context.Context, h ReaderHealth) error diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/tools.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/tools.go index 3137b7ae..e57e7dff 100644 --- a/modules/mesh-mail-reader/cmd/mesh-mail-reader/tools.go +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/tools.go @@ -172,7 +172,7 @@ func (b *Bundle) Tools() []stdio.Tool { Run: b.undoTool}, {Name: StatusTool, Description: "How mail reading stands for the page (novox/hq ADR 0459 §10): healthy or not, the cause " + - "and since when, and reading lessons per 100 read mails for each of the last 8 weeks (a week with " + + "and since when, when a batch last succeeded (or that none has run yet), and reading lessons per 100 read mails for each of the last 8 weeks (a week with " + "no mail read says so). Answered only to a caller the setting callers names. Read-only.", Input: schema(nil, map[string]any{}), Run: b.statusTool}, @@ -509,6 +509,12 @@ func (b *Bundle) statusTool(args map[string]any) (any, error) { weeksOut[i] = week } out := map[string]any{"healthy": h.Healthy, "weeks": weeksOut} + // 1580: when the last batch succeeded, or that none has run yet — never a success no batch made. + if !h.LastSuccess.IsZero() { + out["last-success"] = h.LastSuccess.UTC().Format(time.RFC3339) + } else if h.Healthy { + out["why"] = NoBatchYet + } if !h.Healthy { out["cause"] = string(h.Cause) out["why"] = h.Why