Skip to content

Commit 3b5ea21

Browse files
committed
refactor(sink): trim per-emit allocations and drop inert surface
Manifold.Attach is now copy-on-write so Sink reads the outlet slice without copying it on every emit. The http Post builds its request body with bytes.NewReader instead of copying through a string. CloudWatch gains a clock-injectable PutLogEventAt so the timestamp is testable without the wall clock. The unread WithPollerClock option and its stored-but-unused clock are removed, and the PhaseTransform godoc now states it is reserved rather than emitted by the built-in Emitter. Signed-off-by: Joshua Temple <joshua.temple@stablekernel.com>
1 parent e255916 commit 3b5ea21

7 files changed

Lines changed: 125 additions & 26 deletions

File tree

sink/cloudwatch/cloudwatch.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,19 @@ func PutLogEvents(input *cloudwatchlogs.PutLogEventsInput) csink.Op[Client] {
4242
// group and stream. The event timestamp is set to the current time in
4343
// milliseconds since the Unix epoch, as required by the CloudWatch Logs API.
4444
// Callers that need to control the timestamp, sequence token, or multiple
45-
// events in one request should use [PutLogEvents] instead.
45+
// events in one request should use [PutLogEventAt] or [PutLogEvents] instead.
4646
func PutLogEvent(logGroup, logStream, message string) csink.Op[Client] {
47+
return PutLogEventAt(logGroup, logStream, message, time.Now())
48+
}
49+
50+
// PutLogEventAt returns an Op that sends a single log event stamped with the
51+
// supplied time. It is the deterministic, clock-injectable form of
52+
// [PutLogEvent]: tests pass a fixed time and assert the emitted timestamp
53+
// without depending on the wall clock. The time is converted to milliseconds
54+
// since the Unix epoch, as required by the CloudWatch Logs API.
55+
func PutLogEventAt(logGroup, logStream, message string, at time.Time) csink.Op[Client] {
4756
return csink.OpFunc[Client](func(ctx context.Context, c Client) error {
48-
ts := time.Now().UnixMilli()
57+
ts := at.UnixMilli()
4958
_, err := c.PutLogEvents(ctx, &cloudwatchlogs.PutLogEventsInput{
5059
LogGroupName: &logGroup,
5160
LogStreamName: &logStream,
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package cloudwatch_test
4+
5+
import (
6+
"context"
7+
"testing"
8+
"time"
9+
10+
cw "github.com/stablekernel/crucible/sink/cloudwatch"
11+
)
12+
13+
// TestPutLogEventAt_StampsSuppliedTime verifies the clock-injectable variant
14+
// stamps the event with the caller-supplied time rather than the wall clock, so
15+
// the emitted timestamp is deterministic.
16+
func TestPutLogEventAt_StampsSuppliedTime(t *testing.T) {
17+
t.Parallel()
18+
19+
at := time.Unix(1700000000, 0) // a fixed, known instant
20+
c := &fakeClient{}
21+
op := cw.PutLogEventAt("/app/events", "app-stream", "deterministic", at)
22+
if err := op.Apply(context.Background(), c); err != nil {
23+
t.Fatalf("Apply() error = %v", err)
24+
}
25+
if len(c.calls) != 1 {
26+
t.Fatalf("PutLogEvents call count = %d, want 1", len(c.calls))
27+
}
28+
events := c.calls[0].LogEvents
29+
if len(events) != 1 {
30+
t.Fatalf("events count = %d, want 1", len(events))
31+
}
32+
if events[0].Timestamp == nil {
33+
t.Fatal("event timestamp is nil")
34+
}
35+
if got, want := *events[0].Timestamp, at.UnixMilli(); got != want {
36+
t.Fatalf("timestamp = %d, want %d (the supplied time in ms)", got, want)
37+
}
38+
if events[0].Message == nil || *events[0].Message != "deterministic" {
39+
t.Errorf("message = %v, want %q", events[0].Message, "deterministic")
40+
}
41+
}
42+
43+
// TestPutLogEvent_DelegatesToPutLogEventAt verifies the convenience wrapper
44+
// still emits exactly one event to the named group and stream.
45+
func TestPutLogEvent_DelegatesToPutLogEventAt(t *testing.T) {
46+
t.Parallel()
47+
48+
c := &fakeClient{}
49+
op := cw.PutLogEvent("/app/events", "app-stream", "now")
50+
if err := op.Apply(context.Background(), c); err != nil {
51+
t.Fatalf("Apply() error = %v", err)
52+
}
53+
if len(c.calls) != 1 || len(c.calls[0].LogEvents) != 1 {
54+
t.Fatalf("calls = %+v, want one call with one event", c.calls)
55+
}
56+
if c.calls[0].LogEvents[0].Timestamp == nil {
57+
t.Error("PutLogEvent should stamp a timestamp")
58+
}
59+
}

sink/errors.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ type Phase string
1818

1919
const (
2020
// PhaseTransform marks a failure turning a payload into a destination
21-
// operation (a registry transformer returning an error-bearing op).
21+
// operation. Registry transformers cannot themselves return an error, so the
22+
// built-in Emitter never emits this phase today; it is reserved for outlets
23+
// whose payload-to-operation step can fail and want to classify it.
2224
PhaseTransform Phase = "transform"
2325
// PhaseApply marks a failure applying the operation to the destination
2426
// client (the write itself).

sink/http/http.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@
1212
package http
1313

1414
import (
15+
"bytes"
1516
"context"
1617
"encoding/json"
1718
"fmt"
1819
"io"
1920
gohttp "net/http"
20-
"strings"
2121

2222
csink "github.com/stablekernel/crucible/sink"
2323
)
@@ -35,7 +35,7 @@ type Doer interface {
3535
// A non-2xx status code is returned as an error that includes the status text.
3636
func Post(url, contentType string, body []byte) csink.Op[Doer] {
3737
return csink.OpFunc[Doer](func(ctx context.Context, doer Doer) error {
38-
req, err := gohttp.NewRequestWithContext(ctx, gohttp.MethodPost, url, strings.NewReader(string(body)))
38+
req, err := gohttp.NewRequestWithContext(ctx, gohttp.MethodPost, url, bytes.NewReader(body))
3939
if err != nil {
4040
return fmt.Errorf("http sink: build request: %w", err)
4141
}

sink/manifold.go

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,20 +50,26 @@ func NewManifold(opts ...Option) *Manifold {
5050
}
5151

5252
// Attach adds outlets to the Manifold and returns it for chaining. It is safe
53-
// for concurrent use.
53+
// for concurrent use. Attach replaces the outlet slice with a freshly allocated
54+
// one (copy-on-write) rather than appending in place, so a concurrent Sink that
55+
// is iterating an earlier snapshot never observes a mutated backing array.
5456
func (m *Manifold) Attach(outlets ...Outlet) *Manifold {
5557
m.mu.Lock()
5658
defer m.mu.Unlock()
57-
m.outlets = append(m.outlets, outlets...)
59+
next := make([]Outlet, 0, len(m.outlets)+len(outlets))
60+
next = append(next, m.outlets...)
61+
next = append(next, outlets...)
62+
m.outlets = next
5863
return m
5964
}
6065

61-
// snapshot returns a stable view of the attached outlets for one operation, so a
62-
// concurrent Attach never mutates the slice mid-fan-out.
66+
// snapshot returns the current outlet slice for one operation. Because Attach
67+
// is copy-on-write the returned slice is never mutated in place, so callers may
68+
// read it without copying; the RLock only guards reading the slice header.
6369
func (m *Manifold) snapshot() []Outlet {
6470
m.mu.RLock()
6571
defer m.mu.RUnlock()
66-
return append([]Outlet(nil), m.outlets...)
72+
return m.outlets
6773
}
6874

6975
// Sink fans payload out to every attached outlet, fire-and-forget. It starts a

sink/manifold_concurrent_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package sink_test
4+
5+
import (
6+
"context"
7+
"sync"
8+
"testing"
9+
10+
"github.com/stablekernel/crucible/sink"
11+
)
12+
13+
// TestManifoldConcurrentSinkAndAttach exercises the copy-on-write snapshot path:
14+
// Sink reads the outlet slice without copying it, so a concurrent Attach must
15+
// replace the slice rather than mutate the one a fan-out is iterating. Run under
16+
// -race, this fails if Attach ever mutates a slice a Sink is reading.
17+
func TestManifoldConcurrentSinkAndAttach(t *testing.T) {
18+
t.Parallel()
19+
20+
m := sink.NewManifold().Attach(sink.NewBucket())
21+
22+
var wg sync.WaitGroup
23+
for i := 0; i < 16; i++ {
24+
wg.Add(2)
25+
go func() {
26+
defer wg.Done()
27+
m.Sink(context.Background(), "payload")
28+
}()
29+
go func() {
30+
defer wg.Done()
31+
m.Attach(sink.NewBucket())
32+
}()
33+
}
34+
wg.Wait()
35+
}

sink/poller.go

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,51 +18,39 @@ type PollerOption func(*pollerConfig)
1818

1919
type pollerConfig struct {
2020
interval time.Duration
21-
now func() time.Time
2221
}
2322

2423
func defaultPollerConfig() pollerConfig {
25-
return pollerConfig{interval: 60 * time.Second, now: time.Now}
24+
return pollerConfig{interval: 60 * time.Second}
2625
}
2726

2827
// WithPollInterval sets the sampling period. The default is 60s.
2928
func WithPollInterval(d time.Duration) PollerOption {
3029
return func(c *pollerConfig) { c.interval = d }
3130
}
3231

33-
// WithPollerClock injects the clock the Poller reads, for deterministic tests.
34-
// The default is time.Now. A nil clock is ignored.
35-
func WithPollerClock(now func() time.Time) PollerOption {
36-
return func(c *pollerConfig) {
37-
if now != nil {
38-
c.now = now
39-
}
40-
}
41-
}
42-
4332
// Poller periodically runs a CollectFunc and sinks each yielded payload to a
4433
// target Outlet (via SinkBatch when the target supports it). Construct with
4534
// NewPoller; drive it with Start and stop it with Stop.
4635
type Poller struct {
4736
target Outlet
4837
collect CollectFunc
4938
interval time.Duration
50-
now func() time.Time
5139

5240
mu sync.Mutex
5341
cancel context.CancelFunc
5442
done chan struct{}
5543
started bool
5644
}
5745

58-
// NewPoller binds a target and a CollectFunc into a Poller. Defaults: interval
59-
// 60s, now = time.Now.
46+
// NewPoller binds a target and a CollectFunc into a Poller. The default
47+
// interval is 60s.
6048
func NewPoller(target Outlet, collect CollectFunc, opts ...PollerOption) *Poller {
6149
cfg := defaultPollerConfig()
6250
for _, o := range opts {
6351
o(&cfg)
6452
}
65-
return &Poller{target: target, collect: collect, interval: cfg.interval, now: cfg.now}
53+
return &Poller{target: target, collect: collect, interval: cfg.interval}
6654
}
6755

6856
// Start launches the sampling loop and returns the Poller for chaining. It is

0 commit comments

Comments
 (0)