issue 1580: say no batch has run yet until one has, then when it succeeded

The health answered that the last batch judgement succeeded on a fresh database, before any batch ran.
reader_health keeps when a batch last succeeded (migration 0003, backfilled from the last judged entry),
and the health and status say it, or that no batch has run yet.
This commit is contained in:
jochen
2026-10-22 19:37:20 +02:00
parent 92dc8e313c
commit a3626e7920
7 changed files with 133 additions and 26 deletions
@@ -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)))
}
@@ -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;
@@ -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)
}
}
@@ -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
}
@@ -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));
@@ -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
@@ -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