diff --git a/go/buscompose/buscompose_test.go b/go/buscompose/buscompose_test.go index 56d0879..4e1d0d2 100644 --- a/go/buscompose/buscompose_test.go +++ b/go/buscompose/buscompose_test.go @@ -659,6 +659,11 @@ func healthChecks(t *testing.T) { t.Errorf("%s writes the assigner's policies", u.Username()) } } + // 1953 S-age (ADR 0565 §10): a log that says an age keeps its entries that long and no longer; one that says none, as + // long as the log (ADR 0297). + if aged, kept := (Log{Module: "mesh-hand-acts", Name: "acts", MaxAgeDays: 400}).Config(), (Log{Module: "m", Name: "l"}).Config(); aged.MaxAge != 400*24*time.Hour || kept.MaxAge != 0 || !aged.DenyDelete { + t.Errorf("an aged log's stream keeps %s, an unaged one %s", aged.MaxAge, kept.MaxAge) + } // The tools account's secret is the module's own broker-, which its manifest's own-secrets names and the // vault keeps by that name (AccountSecret). if w := (WantedAccount{User: "a.nats.nats-tools", Kind: KindProcess, Node: "a", Module: "nats", Process: "nats-tools", diff --git a/go/buscompose/log.go b/go/buscompose/log.go index 5c22001..9f1370f 100644 --- a/go/buscompose/log.go +++ b/go/buscompose/log.go @@ -4,6 +4,7 @@ import ( "fmt" "sort" "strings" + "time" "github.com/nats-io/nats.go/jetstream" ) @@ -37,6 +38,8 @@ type Log struct { Name string // MaxMiB is its cap in MiB; zero is LogDefaultMiB. MaxMiB int + // MaxAgeDays is how long an entry is kept; zero is as long as the log (catalogue.LogDeclaration). + MaxAgeDays int } // LogStreamPrefix starts every log's stream name, which no other stream of the mesh's starts with. @@ -74,7 +77,7 @@ func (l Log) Why() string { } // Config is the stream a log is, field by field as novox/hq ADR 0297 fixes it: a file stream kept by -// limits, with no maximum age and no cap per key, whose message is an entry's value and 4 KiB of +// limits, with no maximum age unless the log says one (ADR 0565 §10) and no cap per key, whose message is an entry's value and 4 KiB of // headers at most, that refuses a new entry when full rather than // drop an old one, and that refuses deleting an entry or purging it. Direct gets are allowed, which // is how the runtime reads it. @@ -85,7 +88,7 @@ func (l Log) Config() jetstream.StreamConfig { Subjects: []string{l.Subject() + ".>"}, Storage: jetstream.FileStorage, Retention: jetstream.LimitsPolicy, - MaxAge: 0, + MaxAge: time.Duration(l.MaxAgeDays) * 24 * time.Hour, MaxMsgs: -1, MaxMsgsPerSubject: -1, MaxBytes: l.MaxBytes(), diff --git a/go/catalogue/logs.go b/go/catalogue/logs.go index 7856b8c..d20aa1c 100644 --- a/go/catalogue/logs.go +++ b/go/catalogue/logs.go @@ -27,8 +27,15 @@ type LogDeclaration struct { // MaxMiB is the log's cap in MiB; zero is LogDefaultMiB. When full, the log refuses new entries // and never drops old ones. MaxMiB int `json:"max-mib,omitempty"` + // MaxAgeDays is how long an entry is kept, in days; zero is as long as the log (novox/hq ADR 0297). The one way an + // entry leaves a log: the server ages it out, never a delete or a purge (novox/hq ADR 0565 §10: the hand-act log + // keeps every act 400 days; issue 1953 S-age). + MaxAgeDays int `json:"max-age-days,omitempty"` } +// LogMostAgeDays is the longest a log may keep an entry by age: past it, a log keeps its entries as long as it is. +const LogMostAgeDays = 3650 + // Cap is the log's cap in MiB, the default where none is said. func (l LogDeclaration) Cap() int { if l.MaxMiB == 0 { @@ -37,20 +44,21 @@ func (l LogDeclaration) Cap() int { return l.MaxMiB } -// UnmarshalJSON reads a log as its bare name, or as {name, max-mib}. +// UnmarshalJSON reads a log as its bare name, or as {name, max-mib, max-age-days}. func (l *LogDeclaration) UnmarshalJSON(raw []byte) error { trimmed := bytes.TrimSpace(raw) if len(trimmed) > 0 && trimmed[0] == '"' { return json.Unmarshal(trimmed, &l.Name) } var full struct { - Name string `json:"name"` - MaxMiB *int `json:"max-mib"` + Name string `json:"name"` + MaxMiB *int `json:"max-mib"` + MaxAgeDays *int `json:"max-age-days"` } dec := json.NewDecoder(bytes.NewReader(trimmed)) dec.DisallowUnknownFields() if err := dec.Decode(&full); err != nil { - return fmt.Errorf("a log is either a name or {name, max-mib}: %w", typedUnknown(err)) + return fmt.Errorf("a log is either a name or {name, max-mib, max-age-days}: %w", typedUnknown(err)) } l.Name = full.Name l.MaxMiB = 0 @@ -61,12 +69,19 @@ func (l *LogDeclaration) UnmarshalJSON(raw []byte) error { } l.MaxMiB = *full.MaxMiB } + l.MaxAgeDays = 0 + if full.MaxAgeDays != nil { + if *full.MaxAgeDays < 1 || *full.MaxAgeDays > LogMostAgeDays { + return fmt.Errorf("log %q: max-age-days is between 1 and %d, not %d", full.Name, LogMostAgeDays, *full.MaxAgeDays) + } + l.MaxAgeDays = *full.MaxAgeDays + } return nil } // MarshalJSON writes back the short form when there is nothing else to say. func (l LogDeclaration) MarshalJSON() ([]byte, error) { - if l.MaxMiB == 0 { + if l.MaxMiB == 0 && l.MaxAgeDays == 0 { return json.Marshal(l.Name) } type plain LogDeclaration diff --git a/go/catalogue/logs_test.go b/go/catalogue/logs_test.go index 76e7ee0..aeea053 100644 --- a/go/catalogue/logs_test.go +++ b/go/catalogue/logs_test.go @@ -24,7 +24,15 @@ func TestAManifestMaySayWhatLogsItKeeps(t *testing.T) { if string(out) != `["changes",{"name":"moves","max-mib":2048}]` { t.Fatalf("written back as %s", out) } - for _, edge := range []string{`{"name":"a","max-mib":1}`, `{"name":"a","max-mib":8192}`} { + // 1953 S-age (ADR 0565 §10): a log may keep its entries for an age, written back with it. + aged, err := ParseManifest([]byte(`{"module":"mesh-hand-acts","version":"1","logs":[{"name":"acts","max-age-days":400}]}`)) + if err != nil || aged.Logs[0].MaxAgeDays != 400 { + t.Fatalf("an aged log read as %+v, %v", aged.Logs, err) + } + if out, _ := json.Marshal(aged.Logs); string(out) != `[{"name":"acts","max-age-days":400}]` { + t.Fatalf("an aged log written back as %s", out) + } + for _, edge := range []string{`{"name":"a","max-mib":1}`, `{"name":"a","max-mib":8192}`, `{"name":"a","max-age-days":3650}`} { if _, err := ParseManifest([]byte(`{"module":"a","version":"1","logs":[` + edge + `]}`)); err != nil { t.Errorf("%s is inside the caps and was refused: %v", edge, err) } @@ -42,7 +50,8 @@ func TestALogIsNamedLocallyAndCapped(t *testing.T) { {`{"module":"a","version":"1","logs":[{"name":"c","max-mib":8193}]}`, `between 1 and 8192 MiB`}, {`{"module":"a","version":"1","logs":[{"name":"c","max-mib":-1}]}`, `between 1 and 8192 MiB`}, {`{"module":"a","version":"1","logs":[{"name":"c","max-mib":0}]}`, `max-mib is between 1 and 8192, not 0`}, - {`{"module":"a","version":"1","logs":[{"name":"c","stream":"LOG_x"}]}`, `{name, max-mib}`}, + {`{"module":"a","version":"1","logs":[{"name":"c","stream":"LOG_x"}]}`, `{name, max-mib, max-age-days}`}, + {`{"module":"a","version":"1","logs":[{"name":"c","max-age-days":0}]}`, `max-age-days is between 1 and 3650, not 0`}, {`{"module":"a.b","version":"1","logs":["c"]}`, `no dot`}, } { _, err := ParseManifest([]byte(c.manifest))