Skip to content

Commit 49818f8

Browse files
authored
refactor(metering): split into subpackages + pluggable backend (#62)
* refactor(metering): split into subpackages (file/stderr/capture) + pluggable backend - metering/{file,stderr,capture}/ subpackages mirror hypervisor/snapshot pattern: interface lives in main metering package, backends are subpackage Recorder types exposing a New ctor. - config.MeteringConfig selects the backend ("file" default, "nop", "stderr"); empty config keeps v0.4.x behavior. - cmd/core/metering.go switches over conf.Metering.Backend instead of hard-coding NewFileRecorder. - stderr/Recorder is a new debug backend; capture/Recorder keeps the Entries/Reset test helpers (package doc flags it as testing-only). - file/Recorder.New no longer takes ctx (unused) and surfaces open errors so the cmd/core factory owns the NopRecorder fallback. * refactor(metering): apply /code review feedback - config.MeteringBackend typed enum + constants (MeteringFile/Nop/Stderr) mirror the HypervisorType pattern; cmd/core switch now uses typed cases. - MeteringConfig.Validate() rejects unknown backends at startup (hooked into config.Config.Validate) instead of warn-and-nop at first emit. - stderr.Recorder.Emit uses Write with appended newline (matches file.Recorder.Emit shape, drops fmt.Fprintln + nolint:errcheck). - Tighten godocs: stderr.Emit doc removed (impl is obvious), capture package doc collapsed to one sentence, MeteringRecorder godoc trimmed to the lazy-init contract. * refactor(metering): centralize JSONL wire format on Entry.WriteTo Entry now implements io.WriterTo; file and stderr recorders share the same encoding path instead of each calling json.Marshal + appending \n. Wire-format changes (encoder swap, framing, compression) are now one diff in metering.go rather than scattered across backends. file.Emit collapses two log paths (marshal-error vs write-error) into one — the wrapped error already carries the cause, and downstream cares only that the entry failed. * refactor(metering): trim godocs - WriteTo: "writes one JSONL record" (was: wire-format + downstream tools). - file.Recorder / file.Emit: drop the JSON-encoding narrative now that it lives on Entry.WriteTo; keep only the file-specific WHY (O_APPEND atomicity, swallow rationale). - stderr.Recorder: routes to os.Stderr instead of restating the wire format. - config: drop v0.4.x backstory; the default-behavior sentence carries the same information. - metering_test: drop the comment that just paraphrased the test name.
1 parent 7d9b3dc commit 49818f8

19 files changed

Lines changed: 453 additions & 161 deletions

cmd/core/metering.go

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import (
1010

1111
"github.com/cocoonstack/cocoon/config"
1212
"github.com/cocoonstack/cocoon/metering"
13+
meteringfile "github.com/cocoonstack/cocoon/metering/file"
14+
meteringstderr "github.com/cocoonstack/cocoon/metering/stderr"
1315
)
1416

1517
const (
@@ -22,16 +24,41 @@ var (
2224
meteringRec metering.Recorder
2325
)
2426

25-
// MeteringRecorder returns a process-wide lifecycle recorder; lazy-init shares one ledger fd across all backends, falls back to NopRecorder on fs error.
27+
// MeteringRecorder returns the process-wide recorder per conf.Metering.Backend; lazy-init shared across callers.
2628
func MeteringRecorder(ctx context.Context, conf *config.Config) metering.Recorder {
2729
meteringOnce.Do(func() {
28-
dir := filepath.Join(conf.RootDir, meteringSubdir)
29-
if err := os.MkdirAll(dir, 0o750); err != nil {
30-
log.WithFunc("core.MeteringRecorder").Warnf(ctx, "mkdir %s: %v; metering disabled", dir, err)
31-
meteringRec = metering.NopRecorder{}
32-
return
33-
}
34-
meteringRec = metering.NewFileRecorder(ctx, filepath.Join(dir, meteringFile))
30+
meteringRec = buildRecorder(ctx, conf)
3531
})
3632
return meteringRec
3733
}
34+
35+
func buildRecorder(ctx context.Context, conf *config.Config) metering.Recorder {
36+
switch conf.Metering.Backend {
37+
case "", "file":
38+
return buildFileRecorder(ctx, conf)
39+
case "nop":
40+
return metering.NopRecorder{}
41+
case "stderr":
42+
return meteringstderr.New()
43+
default:
44+
log.WithFunc("core.MeteringRecorder").Warnf(ctx, "unknown metering backend %q; using nop", conf.Metering.Backend)
45+
return metering.NopRecorder{}
46+
}
47+
}
48+
49+
func buildFileRecorder(ctx context.Context, conf *config.Config) metering.Recorder {
50+
path := conf.Metering.File.Path
51+
if path == "" {
52+
path = filepath.Join(conf.RootDir, meteringSubdir, meteringFile)
53+
}
54+
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
55+
log.WithFunc("core.buildFileRecorder").Warnf(ctx, "mkdir %s: %v; metering disabled", filepath.Dir(path), err)
56+
return metering.NopRecorder{}
57+
}
58+
r, err := meteringfile.New(path)
59+
if err != nil {
60+
log.WithFunc("core.buildFileRecorder").Warnf(ctx, "open ledger: %v; metering disabled", err)
61+
return metering.NopRecorder{}
62+
}
63+
return r
64+
}

cmd/core/metering_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package core
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
7+
"github.com/cocoonstack/cocoon/config"
8+
"github.com/cocoonstack/cocoon/metering"
9+
meteringfile "github.com/cocoonstack/cocoon/metering/file"
10+
meteringstderr "github.com/cocoonstack/cocoon/metering/stderr"
11+
)
12+
13+
func TestBuildRecorderBackends(t *testing.T) {
14+
tests := []struct {
15+
name string
16+
backend config.MeteringBackend
17+
want metering.Recorder
18+
}{
19+
{"empty defaults to file", "", (*meteringfile.Recorder)(nil)},
20+
{"explicit file", config.MeteringFile, (*meteringfile.Recorder)(nil)},
21+
{"nop", config.MeteringNop, metering.NopRecorder{}},
22+
{"stderr", config.MeteringStderr, (*meteringstderr.Recorder)(nil)},
23+
{"unknown falls back to nop", "kafka", metering.NopRecorder{}},
24+
}
25+
for _, tt := range tests {
26+
t.Run(tt.name, func(t *testing.T) {
27+
conf := &config.Config{
28+
RootDir: t.TempDir(),
29+
Metering: config.MeteringConfig{Backend: tt.backend},
30+
}
31+
got := buildRecorder(t.Context(), conf)
32+
if gotT, wantT := fmt.Sprintf("%T", got), fmt.Sprintf("%T", tt.want); gotT != wantT {
33+
t.Errorf("backend=%q got %s, want %s", tt.backend, gotT, wantT)
34+
}
35+
})
36+
}
37+
}
38+
39+
func TestBuildFileRecorderCustomPath(t *testing.T) {
40+
dir := t.TempDir()
41+
conf := &config.Config{
42+
RootDir: dir,
43+
Metering: config.MeteringConfig{
44+
Backend: config.MeteringFile,
45+
File: config.FileMeteringConfig{Path: dir + "/custom/ledger.jsonl"},
46+
},
47+
}
48+
r := buildRecorder(t.Context(), conf)
49+
if _, ok := r.(*meteringfile.Recorder); !ok {
50+
t.Fatalf("got %T, want *meteringfile.Recorder", r)
51+
}
52+
}

config/config.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ type Config struct {
5757
TerminateGracePeriodSeconds int `json:"terminate_grace_period_seconds" mapstructure:"terminate_grace_period_seconds"`
5858
// Log configuration, uses eru core's ServerLogConfig.
5959
Log *coretypes.ServerLogConfig `json:"log" mapstructure:"log"`
60+
// Metering selects the lifecycle-event recorder backend.
61+
Metering MeteringConfig `json:"metering,omitzero" mapstructure:"metering"`
6062
}
6163

6264
// Hypervisor returns the selected hypervisor backend type.
@@ -93,6 +95,9 @@ func (c *Config) Validate() error {
9395
if _, err := c.DNSServers(); err != nil {
9496
return fmt.Errorf("dns: %w", err)
9597
}
98+
if err := c.Metering.Validate(); err != nil {
99+
return err
100+
}
96101
return nil
97102
}
98103

config/metering.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package config
2+
3+
import "fmt"
4+
5+
const (
6+
MeteringFile MeteringBackend = "file"
7+
MeteringNop MeteringBackend = "nop"
8+
MeteringStderr MeteringBackend = "stderr"
9+
)
10+
11+
// MeteringBackend identifies the lifecycle-event recorder backend.
12+
type MeteringBackend string
13+
14+
// MeteringConfig selects the recorder backend; empty Backend defaults to MeteringFile.
15+
type MeteringConfig struct {
16+
Backend MeteringBackend `json:"backend,omitempty" mapstructure:"backend"`
17+
File FileMeteringConfig `json:"file,omitzero" mapstructure:"file"`
18+
}
19+
20+
// FileMeteringConfig parameterizes the file-backend recorder; empty Path resolves to <RootDir>/metering/ledger.jsonl.
21+
type FileMeteringConfig struct {
22+
Path string `json:"path,omitempty" mapstructure:"path"`
23+
}
24+
25+
func (m MeteringConfig) Validate() error {
26+
switch m.Backend {
27+
case "", MeteringFile, MeteringNop, MeteringStderr:
28+
return nil
29+
default:
30+
return fmt.Errorf("metering.backend %q is not one of file|nop|stderr", m.Backend)
31+
}
32+
}

config/metering_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package config
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestMeteringConfigValidate(t *testing.T) {
9+
tests := []struct {
10+
name string
11+
backend MeteringBackend
12+
wantErr bool
13+
}{
14+
{"empty defaults", "", false},
15+
{"file", MeteringFile, false},
16+
{"nop", MeteringNop, false},
17+
{"stderr", MeteringStderr, false},
18+
{"unknown", "kafka", true},
19+
}
20+
for _, tt := range tests {
21+
t.Run(tt.name, func(t *testing.T) {
22+
err := MeteringConfig{Backend: tt.backend}.Validate()
23+
if (err != nil) != tt.wantErr {
24+
t.Errorf("Validate(%q) err = %v, wantErr=%v", tt.backend, err, tt.wantErr)
25+
}
26+
if tt.wantErr && err != nil && !strings.Contains(err.Error(), "metering.backend") {
27+
t.Errorf("err = %v; want containing 'metering.backend'", err)
28+
}
29+
})
30+
}
31+
}

hypervisor/state_test.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99

1010
"github.com/cocoonstack/cocoon/lock/flock"
1111
"github.com/cocoonstack/cocoon/metering"
12+
meteringcapture "github.com/cocoonstack/cocoon/metering/capture"
1213
storejson "github.com/cocoonstack/cocoon/storage/json"
1314
"github.com/cocoonstack/cocoon/types"
1415
)
@@ -41,12 +42,12 @@ func (stubBackendConfig) LogDir() string { panic("LogDir: n
4142
func (stubBackendConfig) VMRunDir(string) string { panic("VMRunDir: not implemented in stub") }
4243
func (stubBackendConfig) VMLogDir(string) string { panic("VMLogDir: not implemented in stub") }
4344

44-
func newMeteringTestBackend(t *testing.T) (*Backend, *metering.CaptureRecorder) {
45+
func newMeteringTestBackend(t *testing.T) (*Backend, *meteringcapture.Recorder) {
4546
t.Helper()
4647
dir := t.TempDir()
4748
locker := flock.New(filepath.Join(dir, "index.lock"))
4849
store := storejson.New[VMIndex](filepath.Join(dir, "index.json"), locker)
49-
rec := &metering.CaptureRecorder{}
50+
rec := meteringcapture.New()
5051
return &Backend{
5152
Typ: "test-hv",
5253
Conf: stubBackendConfig{},

metering/capture.go

Lines changed: 0 additions & 34 deletions
This file was deleted.

metering/capture/capture.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Package capture is a test-only metering recorder; Entries and Reset are testing helpers.
2+
package capture
3+
4+
import (
5+
"context"
6+
"sync"
7+
8+
"github.com/cocoonstack/cocoon/metering"
9+
)
10+
11+
var _ metering.Recorder = (*Recorder)(nil)
12+
13+
type Recorder struct {
14+
mu sync.Mutex
15+
entries []metering.Entry
16+
}
17+
18+
func New() *Recorder { return &Recorder{} }
19+
20+
func (r *Recorder) Emit(_ context.Context, e metering.Entry) {
21+
r.mu.Lock()
22+
defer r.mu.Unlock()
23+
r.entries = append(r.entries, e)
24+
}
25+
26+
// Entries returns a snapshot copy; callers may mutate the returned slice.
27+
func (r *Recorder) Entries() []metering.Entry {
28+
r.mu.Lock()
29+
defer r.mu.Unlock()
30+
out := make([]metering.Entry, len(r.entries))
31+
copy(out, r.entries)
32+
return out
33+
}
34+
35+
func (r *Recorder) Reset() {
36+
r.mu.Lock()
37+
defer r.mu.Unlock()
38+
r.entries = nil
39+
}

metering/capture/capture_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package capture
2+
3+
import (
4+
"sync"
5+
"testing"
6+
7+
"github.com/cocoonstack/cocoon/metering"
8+
)
9+
10+
func TestRecorderBasic(t *testing.T) {
11+
r := New()
12+
ctx := t.Context()
13+
r.Emit(ctx, metering.Entry{Kind: metering.KindVMComputeStart, VMID: "a"})
14+
r.Emit(ctx, metering.Entry{Kind: metering.KindVMComputeStop, VMID: "a"})
15+
got := r.Entries()
16+
if len(got) != 2 {
17+
t.Fatalf("got %d entries, want 2", len(got))
18+
}
19+
if got[0].Kind != metering.KindVMComputeStart || got[1].Kind != metering.KindVMComputeStop {
20+
t.Errorf("got kinds %v %v", got[0].Kind, got[1].Kind)
21+
}
22+
}
23+
24+
func TestRecorderEntriesIsCopy(t *testing.T) {
25+
r := New()
26+
r.Emit(t.Context(), metering.Entry{VMID: "a"})
27+
got := r.Entries()
28+
got[0].VMID = "tampered"
29+
if again := r.Entries(); again[0].VMID != "a" {
30+
t.Errorf("Entries() must return a copy; got %q after mutation", again[0].VMID)
31+
}
32+
}
33+
34+
func TestRecorderReset(t *testing.T) {
35+
r := New()
36+
r.Emit(t.Context(), metering.Entry{VMID: "a"})
37+
r.Reset()
38+
if got := r.Entries(); len(got) != 0 {
39+
t.Errorf("after Reset got %d entries, want 0", len(got))
40+
}
41+
}
42+
43+
func TestRecorderConcurrent(t *testing.T) {
44+
r := New()
45+
ctx := t.Context()
46+
const N = 200
47+
var wg sync.WaitGroup
48+
for range N {
49+
wg.Go(func() {
50+
r.Emit(ctx, metering.Entry{Kind: metering.KindVMComputeStart, VMID: "vm"})
51+
})
52+
}
53+
wg.Wait()
54+
if got := len(r.Entries()); got != N {
55+
t.Errorf("got %d entries, want %d", got, N)
56+
}
57+
}

metering/capture_test.go

Lines changed: 0 additions & 47 deletions
This file was deleted.

0 commit comments

Comments
 (0)