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:
@@ -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)))
|
||||
}
|
||||
|
||||
+5
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user