Keep each account's state in a bucket (issue 1255)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user