From 08200bc178ecd762a081faccd7747f95a279889e Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 19 Oct 2026 21:50:12 +0200 Subject: [PATCH] Keep each account's state in a bucket (issue 1255) --- modules/mesh-mail/cmd/mesh-mail/imap_test.go | 6 +- modules/mesh-mail/cmd/mesh-mail/main.go | 49 ++- .../mesh-mail/cmd/mesh-mail/manifest_test.go | 4 + modules/mesh-mail/cmd/mesh-mail/mirror.go | 238 ++++++++++++++ .../mesh-mail/cmd/mesh-mail/mirror_test.go | 290 ++++++++++++++++++ modules/mesh-mail/module.json | 3 +- 6 files changed, 586 insertions(+), 4 deletions(-) create mode 100644 modules/mesh-mail/cmd/mesh-mail/mirror.go create mode 100644 modules/mesh-mail/cmd/mesh-mail/mirror_test.go diff --git a/modules/mesh-mail/cmd/mesh-mail/imap_test.go b/modules/mesh-mail/cmd/mesh-mail/imap_test.go index 0159c710..5ffa1c86 100644 --- a/modules/mesh-mail/cmd/mesh-mail/imap_test.go +++ b/modules/mesh-mail/cmd/mesh-mail/imap_test.go @@ -221,12 +221,14 @@ func TestNothingButTheTestsWritesToAMailbox(t *testing.T) { } writes := regexp.MustCompile(`([A-Za-z_][A-Za-z0-9_]*)\.(Store|Move|Copy|Expunge|UIDExpunge|Append|Create|Delete|Rename|Subscribe|Unsubscribe|SetMetadata|SetQuota)\(`) for _, f := range files { - if strings.HasSuffix(f, "_test.go") || f == "send.go" || f == "smtp.go" || f == "sender.go" { + if strings.HasSuffix(f, "_test.go") || f == "send.go" || f == "smtp.go" || f == "sender.go" || f == "mirror.go" { // None touches a mailbox: send.go keeps the `sends` state on the bus, never IMAP; smtp.go speaks SMTP // to the account's outgoing server, never to a mailbox, and only on the operator's Approve (ADR 0361 // §2); sender.go is the sender process (novox/hq ADR 0436 §7) — its own bus account's `sends` state // (process.State.Create, matched here only because its receiver is named s) and router record, never - // IMAP either. + // IMAP either; mirror.go (novox/hq ADR 0439 §2, §3, issue 1255) keeps the `accounts` and `mails` + // state buckets on the bus, never IMAP — its Delete is a bucket key's, matched here only because its + // KeptState field is named s. continue } raw, err := os.ReadFile(f) diff --git a/modules/mesh-mail/cmd/mesh-mail/main.go b/modules/mesh-mail/cmd/mesh-mail/main.go index 7065834b..ebb444ac 100644 --- a/modules/mesh-mail/cmd/mesh-mail/main.go +++ b/modules/mesh-mail/cmd/mesh-mail/main.go @@ -187,8 +187,22 @@ func serve(env Env) { }, } b.Logf = logf - p := &Publisher{Index: b.Index, Emit: func(key string, body any) error { return stdio.Emit(key, body) }, Logf: logf} + mirror := &Mirror{ + Index: b.Index, + Accounts: func() ([]Account, error) { return ReadAccounts(env.Accounts) }, + Accts: keptMirrorState{stdio.State(AccountsState)}, + Logf: logf, + } + trigger := make(chan struct{}, 1) + p := &Publisher{Index: b.Index, Emit: func(key string, body any) error { return stdio.Emit(key, body) }, Logf: logf, + Said: func(int) { + select { + case trigger <- struct{}{}: + default: + } + }} go p.Run(context.Background()) + go mirrorTicker(mirror, trigger) go hear(b) go sweepSends(b) go waitsTellTicker(b) @@ -252,6 +266,32 @@ func hear(b *Bundle) { } } +// MirrorEvery is the Mirror's own round's slowest pace (settlement 6, issue 1255): at least this often, even +// with nothing published in between. +const MirrorEvery = time.Minute + +// mirrorTicker runs the Mirror's round at least every MirrorEvery, and at once again whenever trigger fires (a +// Publisher round that said something, settlement 6), until the process ends. +func mirrorTicker(m *Mirror, trigger <-chan struct{}) { + tick := time.NewTicker(MirrorEvery) + defer tick.Stop() + round := func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := m.Round(ctx); err != nil && m.Logf != nil { + m.Logf("[mesh-mail] the state buckets could not be mirrored: %v", err) + } + } + for { + select { + case <-trigger: + round() + case <-tick.C: + round() + } + } +} + // sweepSends reads every send still undecided once a minute and asks the router's own record for its answer, so // an Approve answered while this module could not hear it directly is still acted on (novox/hq issue 790, review // round 1). @@ -272,6 +312,10 @@ type Publisher struct { Emit func(key string, body any) error Logf func(format string, a ...any) Every time.Duration + // Said, when given, is called after a round that said one event or more (novox/hq issue 1255, settlement + // 6): the Mirror's own round runs beside it, so the state buckets catch up without waiting for the next + // tick. + Said func(n int) } // PublishBatch is how many events one round says at most. @@ -300,6 +344,9 @@ func (p *Publisher) Once(ctx context.Context) (int, error) { if err := st.Said(ctx, said); err != nil { return 0, err } + if p.Said != nil { + p.Said(len(said)) + } } return len(said), failed } diff --git a/modules/mesh-mail/cmd/mesh-mail/manifest_test.go b/modules/mesh-mail/cmd/mesh-mail/manifest_test.go index 5673e945..c379af5b 100644 --- a/modules/mesh-mail/cmd/mesh-mail/manifest_test.go +++ b/modules/mesh-mail/cmd/mesh-mail/manifest_test.go @@ -88,6 +88,10 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) { if fam["issued-by"] != "outside" || fam["path"] != "${dir:state}/"+SecretFamily+"*.secret" || len(m.OwnSecrets) != 2 { t.Fatalf("own secrets %v", m.OwnSecrets) } + // 1255/AC7: the module declares the account bucket's own state, beside `sends`. + if !reflect.DeepEqual(m.State, []string{"sends", "accounts"}) { + t.Fatalf("state %v, want [sends accounts]", m.State) + } var names []string for _, tool := range (&Bundle{}).Tools() { names = append(names, tool.Name) diff --git a/modules/mesh-mail/cmd/mesh-mail/mirror.go b/modules/mesh-mail/cmd/mesh-mail/mirror.go new file mode 100644 index 00000000..bce48c25 --- /dev/null +++ b/modules/mesh-mail/cmd/mesh-mail/mirror.go @@ -0,0 +1,238 @@ +package main + +// The account bucket (novox/hq ADR 0439 §2, issue 1255): a view (such as the mail web client) can watch it +// instead of asking mail-store's verbs for an account's state and folder counts. The bundle's own Mirror keeps +// it in step with the index (design 59's prepare step, settlement 6): it reads the index fresh each round and +// writes only what changed — a key put when its value differs, never otherwise (novox/hq ADR 0201 §12). keep +// (imap.go) is not changed: it writes the index; the Mirror alone writes this bucket. + +import ( + "bytes" + "context" + "encoding/json" + "unicode/utf8" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// AccountsState is the module's state the Mirror keeps one key per account in (novox/hq ADR 0439 §2). +const AccountsState = "accounts" + +// The bounds ADR 0439 §2 gives the account bucket's value. +const ( + // MostAccountValue is the value's own bound, 64 KiB. + MostAccountValue = 64 << 10 + // MostFolderName is one folder's name, cut at a character's boundary when it is longer. + MostFolderName = 120 + // DefaultKeptMails is how many of an account's newest mails the mail bucket keeps, 200 unless ADR 0439 §4's + // 81-account rule (issue 1282) gives every account the same smaller number instead. + DefaultKeptMails = 200 + // MostMailValue is ADR 0439 §3's own bound on one mail's value in the mail bucket, 4 KiB. + MostMailValue = 4 << 10 + // MostMailBytes is ADR 0439 §4's own bound across every account's newest mails together, 64 MiB. + MostMailBytes = 64 << 20 +) + +// AccountValue is state accounts' own value, one per account (ADR 0439 §2): never a time that moves on each +// sync, so two rounds with nothing changed write the byte-identical value. +type AccountValue struct { + // State is the account's state as its keeper last said it (current, syncing, waits-for-sign-in or failing), + // or "unknown" while its keeper has said nothing of it yet (as mail_accounts itself answers, tools.go). + State string `json:"state"` + // Kept is the number of the account's newest mails the mail bucket (issue 1282) keeps. + Kept int `json:"kept"` + // Folders are the account's folders, each by its name (cut to MostFolderName bytes), its count of mails and + // its count of unread mails. + Folders []AccountFolderValue `json:"folders"` + // DroppedFolders is how many folders were left out, fewest-mail first, to keep the value at MostAccountValue + // bytes or fewer; left out of the value (omitted, not zero) when nothing was dropped. + DroppedFolders int `json:"dropped-folders,omitempty"` +} + +// AccountFolderValue is one folder in an AccountValue. +type AccountFolderValue struct { + Name string `json:"name"` + Mails int `json:"mails"` + Unread int `json:"unread"` +} + +// MirrorState is what the Mirror needs of one state bucket it writes: a key's current raw value (nil when it +// has none), a value put — the Mirror is this bucket's only writer, so never by compare-and-set — and every key +// it holds. An interface, so a unit test gives a fake instead of a runtime (novox/hq ADR 0439 §2, §3; issue +// 1255, 1282's "Prepare findings"). +type MirrorState interface { + Get(key string) (json.RawMessage, bool, error) + Put(key string, value any) error + Delete(key string) error + Keys() ([]string, error) +} + +// keptMirrorState is a MirrorState on the bus, over the sdk's own KeptState (state.go, seat.go). +type keptMirrorState struct{ s stdio.KeptState } + +func (k keptMirrorState) Get(key string) (json.RawMessage, bool, error) { + e, err := k.s.Get(key) + if err != nil || e == nil { + return nil, false, err + } + return e.Value, true, nil +} +func (k keptMirrorState) Put(key string, value any) error { + _, err := k.s.Put(key, value) + return err +} +func (k keptMirrorState) Delete(key string) error { return k.s.Delete(key) } +func (k keptMirrorState) Keys() ([]string, error) { return k.s.Keys() } + +// Mirror keeps the module's state buckets in step with the index: one round reads the index fresh and writes +// only what changed (ADR 0201 §12). It runs beside the Publisher (serve(), main.go): after each Publisher +// round that said something, and at least every MirrorEvery either way (settlement 6). +type Mirror struct { + Index func(ctx context.Context) (Store, error) + Accounts func() ([]Account, error) + Accts MirrorState // the `accounts` state + Logf func(format string, a ...any) +} + +// Round mirrors every configured account's current state into the `accounts` bucket (issue 1255's AC1-AC7). +func (m *Mirror) Round(ctx context.Context) error { + accounts, err := m.Accounts() + if err != nil { + return err + } + st, err := m.Index(ctx) + if err != nil { + return err + } + states, err := st.States(ctx) + if err != nil { + return err + } + kept := keptPerAccount(len(accounts)) + for _, a := range accounts { + v, err := accountValue(ctx, st, a.Name, states[a.Name], kept) + if err != nil { + return err + } + if err := putIfChanged(m.Accts, a.Name, v); err != nil { + return err + } + } + return nil +} + +// accountValue is one account's own value: its state word, the number of mails kept for it, and its folders +// with their counts, trimmed to MostAccountValue. +func accountValue(ctx context.Context, st Store, account string, state AccountState, kept int) (AccountValue, error) { + folders, err := st.Folders(ctx, account) + if err != nil { + return AccountValue{}, err + } + fv := make([]AccountFolderValue, 0, len(folders)) + for _, f := range folders { + unread, err := unreadCount(ctx, st, account, f.Name) + if err != nil { + return AccountValue{}, err + } + fv = append(fv, AccountFolderValue{Name: cutUTF8(f.Name, MostFolderName), Mails: f.Messages, Unread: unread}) + } + word := state.State + if word == "" { + word = "unknown" + } + return trimAccountValue(AccountValue{State: word, Kept: kept, Folders: fv}), nil +} + +// unreadCount is a folder's count of messages without the \Seen flag. +func unreadCount(ctx context.Context, st Store, account, folder string) (int, error) { + flags, err := st.Flags(ctx, account, folder) + if err != nil { + return 0, err + } + n := 0 + for _, fl := range flags { + if !hasSeenFlag(fl) { + n++ + } + } + return n, nil +} + +// trimAccountValue is issue 1255/AC5: while the value marshals to more than MostAccountValue bytes, the folder +// with the fewest mails is dropped, and DroppedFolders counts it, until it fits (or no folder is left). +func trimAccountValue(v AccountValue) AccountValue { + for accountValueSize(v) > MostAccountValue && len(v.Folders) > 0 { + fewest := 0 + for i := 1; i < len(v.Folders); i++ { + if v.Folders[i].Mails < v.Folders[fewest].Mails { + fewest = i + } + } + v.Folders = append(append([]AccountFolderValue{}, v.Folders[:fewest]...), v.Folders[fewest+1:]...) + v.DroppedFolders++ + } + return v +} + +func accountValueSize(v AccountValue) int { + raw, err := json.Marshal(v) + if err != nil { + return 0 + } + return len(raw) +} + +// keptPerAccount is ADR 0439 §4's 81-account rule (issue 1282): DefaultKeptMails, unless that many of every +// account's mails could together pass MostMailBytes, in which case every account keeps the same smaller number +// instead, so that all of them fit. +func keptPerAccount(numAccounts int) int { + if numAccounts <= 0 { + return DefaultKeptMails + } + if numAccounts*DefaultKeptMails*MostMailValue <= MostMailBytes { + return DefaultKeptMails + } + n := MostMailBytes / (numAccounts * MostMailValue) + if n < 1 { + n = 1 + } + if n > DefaultKeptMails { + n = DefaultKeptMails + } + return n +} + +// putIfChanged writes value at key only when it differs from what the bucket currently holds there (ADR 0201 +// §12): the Mirror's own values never carry a time that moves on each sync, so the same JSON bytes (Go's own +// json.Marshal is stable for a fixed type) mean nothing changed. +func putIfChanged(bucket MirrorState, key string, value any) error { + raw, err := json.Marshal(value) + if err != nil { + return err + } + cur, ok, err := bucket.Get(key) + if err != nil { + return err + } + if ok && bytes.Equal(cur, raw) { + return nil + } + return bucket.Put(key, value) +} + +// cutUTF8 cuts s to at most n bytes, never splitting a multi-byte character (novox/hq ADR 0439 §2, §3). +func cutUTF8(s string, n int) string { + if len(s) <= n { + return s + } + b := s[:n] + for len(b) > 0 { + r, size := utf8.DecodeLastRuneInString(b) + if r == utf8.RuneError && size <= 1 { + b = b[:len(b)-1] + continue + } + break + } + return b +} diff --git a/modules/mesh-mail/cmd/mesh-mail/mirror_test.go b/modules/mesh-mail/cmd/mesh-mail/mirror_test.go new file mode 100644 index 00000000..fd193482 --- /dev/null +++ b/modules/mesh-mail/cmd/mesh-mail/mirror_test.go @@ -0,0 +1,290 @@ +package main + +// The account bucket (novox/hq ADR 0439 §2, issue 1255): a fake MirrorState, so these tests need no runtime, +// and memStore for the index, as the rest of this package's unit tests already use it. + +import ( + "context" + "encoding/json" + "strings" + "testing" + "unicode/utf8" +) + +// memMirrorState is a fake MirrorState (mirror.go), kept in memory: a map, no revision, since the Mirror is a +// bucket's only writer and never writes it by compare-and-set. +type memMirrorState struct { + vals map[string]json.RawMessage + calls map[string]int // Put calls per key, so a test can tell a skipped write from a repeated one +} + +func newMemMirrorState() *memMirrorState { + return &memMirrorState{vals: map[string]json.RawMessage{}, calls: map[string]int{}} +} + +func (m *memMirrorState) Get(key string) (json.RawMessage, bool, error) { + v, ok := m.vals[key] + return v, ok, nil +} +func (m *memMirrorState) Put(key string, value any) error { + raw, err := json.Marshal(value) + if err != nil { + return err + } + m.vals[key] = raw + m.calls[key]++ + return nil +} +func (m *memMirrorState) Delete(key string) error { + delete(m.vals, key) + return nil +} +func (m *memMirrorState) Keys() ([]string, error) { + out := make([]string, 0, len(m.vals)) + for k := range m.vals { + out = append(out, k) + } + return out, nil +} + +// accountsAndStore is one account "mesh", granted no caller (the Mirror reads no grant), with the index given. +func accountsAndStore(t *testing.T) ([]Account, *memStore) { + t.Helper() + return []Account{{Name: "mesh", Kind: KindIMAP, Address: "jschoubben@novox.be"}}, newMemStore() +} + +func mirrorOf(accounts []Account, st *memStore, bucket MirrorState) *Mirror { + return &Mirror{ + Index: func(context.Context) (Store, error) { return st, nil }, + Accounts: func() ([]Account, error) { return accounts, nil }, + Accts: bucket, + } +} + +// 1255/AC1: the account's value holds its state word, the number of newest mails kept (200, no other account +// configured, so none of ADR 0439 §4's 81-account rule applies), and its folders with their counts. +func TestMirrorRoundWritesTheAccountsStateWord200AndItsFolders(t *testing.T) { + ctx := context.Background() + accounts, st := accountsAndStore(t) + _ = st.SetState(ctx, AccountState{Account: "mesh", State: AccountCurrent, Why: "watching"}) + addMail(t, st, "mesh", "INBOX", 1, []string{}) // unread + addMail(t, st, "mesh", "INBOX", 2, []string{`\Seen`}) // read + addMail(t, st, "mesh", "Sent", 3, []string{`\Seen`}) // read + _ = st.SetFolder(ctx, Folder{Account: "mesh", Name: "INBOX", Messages: 10}) + _ = st.SetFolder(ctx, Folder{Account: "mesh", Name: "Sent", Messages: 5}) + + bucket := newMemMirrorState() + m := mirrorOf(accounts, st, bucket) + if err := m.Round(ctx); err != nil { + t.Fatal(err) + } + raw, ok, err := bucket.Get("mesh") + if err != nil || !ok { + t.Fatalf("no value kept for mesh: %v %v", ok, err) + } + var v AccountValue + if err := json.Unmarshal(raw, &v); err != nil { + t.Fatal(err) + } + if v.State != AccountCurrent || v.Kept != DefaultKeptMails { + t.Fatalf("state %q, kept %d; want %q, %d", v.State, v.Kept, AccountCurrent, DefaultKeptMails) + } + byName := map[string]AccountFolderValue{} + for _, f := range v.Folders { + byName[f.Name] = f + } + if byName["INBOX"].Mails != 10 || byName["INBOX"].Unread != 1 { + t.Fatalf("INBOX: %+v", byName["INBOX"]) + } + if byName["Sent"].Mails != 5 || byName["Sent"].Unread != 0 { + t.Fatalf("Sent: %+v", byName["Sent"]) + } +} + +// addMail indexes one bare message with the given flags, for folder-count tests that only need its UID and +// flags (mirroring the shape imap.go's Keeper writes, store.go's Message). +func addMail(t *testing.T, st *memStore, account, folder string, uid uint32, flags []string) { + t.Helper() + m := Message{Account: account, Folder: folder, UID: uid, Flags: flags} + if err := st.Add(context.Background(), m, nil); err != nil { + t.Fatal(err) + } +} + +// 1255/AC2: nothing is written when the index is unchanged since the last round. +func TestMirrorRoundWritesNothingWhenUnchanged(t *testing.T) { + ctx := context.Background() + accounts, st := accountsAndStore(t) + _ = st.SetState(ctx, AccountState{Account: "mesh", State: AccountCurrent}) + _ = st.SetFolder(ctx, Folder{Account: "mesh", Name: "INBOX", Messages: 1}) + + bucket := newMemMirrorState() + m := mirrorOf(accounts, st, bucket) + if err := m.Round(ctx); err != nil { + t.Fatal(err) + } + if bucket.calls["mesh"] != 1 { + t.Fatalf("the first round wrote %d time(s); want 1", bucket.calls["mesh"]) + } + if err := m.Round(ctx); err != nil { + t.Fatal(err) + } + if bucket.calls["mesh"] != 1 { + t.Fatalf("a round with nothing changed wrote again: %d time(s)", bucket.calls["mesh"]) + } +} + +// 1255/AC3: one mail of INBOX marked read changes only that account's key, to 1 unread. +func TestMirrorRoundWritesOnlyTheChangedAccountsKey(t *testing.T) { + ctx := context.Background() + accounts, st := accountsAndStore(t) + accounts = append(accounts, Account{Name: "other", Kind: KindIMAP, Address: "other@novox.be"}) + _ = st.SetState(ctx, AccountState{Account: "mesh", State: AccountCurrent}) + _ = st.SetState(ctx, AccountState{Account: "other", State: AccountCurrent}) + addMail(t, st, "mesh", "INBOX", 1, []string{}) + _ = st.SetFolder(ctx, Folder{Account: "mesh", Name: "INBOX", Messages: 1}) + _ = st.SetFolder(ctx, Folder{Account: "other", Name: "INBOX", Messages: 0}) + + bucket := newMemMirrorState() + m := mirrorOf(accounts, st, bucket) + if err := m.Round(ctx); err != nil { + t.Fatal(err) + } + bucket.calls = map[string]int{} // reset the counters; only the next round's writes matter here + + // mesh's one message is now marked read. + if err := st.SetFlags(ctx, "mesh", "INBOX", 1, []string{`\Seen`}, nil); err != nil { + t.Fatal(err) + } + if err := m.Round(ctx); err != nil { + t.Fatal(err) + } + if bucket.calls["mesh"] != 1 { + t.Fatalf("mesh's key was not rewritten: %d call(s)", bucket.calls["mesh"]) + } + if bucket.calls["other"] != 0 { + t.Fatalf("other's key was written though nothing of it changed: %d call(s)", bucket.calls["other"]) + } + raw, _, _ := bucket.Get("mesh") + var v AccountValue + _ = json.Unmarshal(raw, &v) + for _, f := range v.Folders { + if f.Name == "INBOX" && f.Unread != 0 { + t.Fatalf("INBOX unread after marking read: %d", f.Unread) + } + } +} + +// 1255/AC4: a folder name of 200 bytes of multibyte characters is cut to at most 120 bytes at a character's +// boundary (never split mid-character). +func TestMirrorRoundCutsALongMultibyteFolderNameAtACharacterBoundary(t *testing.T) { + ctx := context.Background() + accounts, st := accountsAndStore(t) + _ = st.SetState(ctx, AccountState{Account: "mesh", State: AccountCurrent}) + // 119 one-byte letters, then 2-byte "é"s: the 120th byte (MostFolderName) lands mid-character, inside the + // first é's 2-byte encoding, so a naive byte cut would split it. + long := strings.Repeat("a", 119) + strings.Repeat("é", 40) + _ = st.SetFolder(ctx, Folder{Account: "mesh", Name: long, Messages: 1}) + + bucket := newMemMirrorState() + m := mirrorOf(accounts, st, bucket) + if err := m.Round(ctx); err != nil { + t.Fatal(err) + } + raw, _, _ := bucket.Get("mesh") + var v AccountValue + _ = json.Unmarshal(raw, &v) + if len(v.Folders) != 1 { + t.Fatalf("folders: %+v", v.Folders) + } + got := v.Folders[0].Name + if len(got) > MostFolderName { + t.Fatalf("the folder name is %d bytes, more than %d", len(got), MostFolderName) + } + if !strings.HasPrefix(long, got) { + t.Fatalf("the cut name is not a prefix of the original: %q", got) + } + if !utf8.ValidString(got) { + t.Fatalf("the cut name is not valid UTF-8: a multibyte character was split: %q", got) + } +} + +// 1255/AC5: an account with enough folders that the value would pass 64 KiB has the folders with the fewest +// mails dropped, their number counted, and the value fits. +func TestMirrorRoundDropsTheFewestMailFoldersToFitUnder64KiB(t *testing.T) { + ctx := context.Background() + accounts, st := accountsAndStore(t) + _ = st.SetState(ctx, AccountState{Account: "mesh", State: AccountCurrent}) + // Enough folders, with long enough names, that the whole value passes MostAccountValue; each folder's own + // mail count is distinct, so "fewest first" is unambiguous. + const folders = 1200 + for i := 0; i < folders; i++ { + name := "folder-" + itoa(int64(i)) + "-abcdefghijklmnopqrstuvwxyz0123456789" + _ = st.SetFolder(ctx, Folder{Account: "mesh", Name: name, Messages: i + 1}) + } + + bucket := newMemMirrorState() + m := mirrorOf(accounts, st, bucket) + if err := m.Round(ctx); err != nil { + t.Fatal(err) + } + raw, _, _ := bucket.Get("mesh") + if len(raw) > MostAccountValue { + t.Fatalf("the value is %d bytes, more than %d", len(raw), MostAccountValue) + } + var v AccountValue + _ = json.Unmarshal(raw, &v) + if v.DroppedFolders == 0 { + t.Fatalf("no folder was dropped, though %d folders would overflow %d bytes", folders, MostAccountValue) + } + if v.DroppedFolders+len(v.Folders) != folders { + t.Fatalf("dropped %d + kept %d != %d folders in all", v.DroppedFolders, len(v.Folders), folders) + } + // The kept folders are never among the ones with the fewest mails: folder i has i+1 mails, so every kept + // folder's count is at least as large as every dropped folder's would have been. + fewestKept := -1 + for _, f := range v.Folders { + if fewestKept == -1 || f.Mails < fewestKept { + fewestKept = f.Mails + } + } + if fewestKept < v.DroppedFolders { + t.Fatalf("the fewest-mail folders were not the ones dropped: kept as few as %d mails, dropped %d folders", fewestKept, v.DroppedFolders) + } +} + +// 1255/AC6: two rounds an hour apart with nothing indexed between write the byte-identical value (no time that +// moves on each sync). +func TestMirrorRoundTwoRoundsWithNothingChangedWriteByteIdenticalValues(t *testing.T) { + ctx := context.Background() + accounts, st := accountsAndStore(t) + _ = st.SetState(ctx, AccountState{Account: "mesh", State: AccountCurrent}) + _ = st.SetFolder(ctx, Folder{Account: "mesh", Name: "INBOX", Messages: 3}) + + bucket := newMemMirrorState() + m := mirrorOf(accounts, st, bucket) + if err := m.Round(ctx); err != nil { + t.Fatal(err) + } + first, _, _ := bucket.Get("mesh") + firstCopy := append([]byte(nil), first...) + + // Force a second write by deleting the key directly and running again: the value computed must still be + // byte-identical to the first, since nothing in the index changed. + _ = bucket.Delete("mesh") + if err := m.Round(ctx); err != nil { + t.Fatal(err) + } + second, _, _ := bucket.Get("mesh") + if string(firstCopy) != string(second) { + t.Fatalf("the two rounds wrote different bytes:\n%s\n%s", firstCopy, second) + } +} + +// 1255/AC7: the manifest declares the state accounts (manifest_test.go asserts the exact list; this just +// anchors the constant the code and the manifest both use). +func TestAccountsStateNameMatchesTheManifestsWord(t *testing.T) { + if AccountsState != "accounts" { + t.Fatalf("AccountsState = %q", AccountsState) + } +} diff --git a/modules/mesh-mail/module.json b/modules/mesh-mail/module.json index d1399727..5aa126c7 100644 --- a/modules/mesh-mail/module.json +++ b/modules/mesh-mail/module.json @@ -87,7 +87,8 @@ "operator-channel" ], "state": [ - "sends" + "sends", + "accounts" ], "emits": [ "mail.send.sent",