From bda549f8a81b996497dc2d405ffffbb3ae8683b7 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 14 Aug 2026 12:15:21 -0400 Subject: [PATCH] feat(artwork): add a resolution chain trace collector --- core/artwork/trace.go | 54 ++++++++++++++++++++++++++++++++++++++ core/artwork/trace_test.go | 43 ++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 core/artwork/trace.go create mode 100644 core/artwork/trace_test.go diff --git a/core/artwork/trace.go b/core/artwork/trace.go new file mode 100644 index 000000000..28c0e61ab --- /dev/null +++ b/core/artwork/trace.go @@ -0,0 +1,54 @@ +package artwork + +import ( + "context" + "slices" + "sync" +) + +const ( + outcomeHit = "hit" + outcomeMiss = "miss" + outcomeUnreadable = "unreadable" + outcomeSkipped = "skipped" + outcomeWouldTry = "would-try" + outcomeError = "error" + outcomeNotReached = "not-reached" +) + +// traceStep is one candidate the priority chain considered. +type traceStep struct { + Candidate string + Outcome string + Detail string +} + +// chainTrace collects the walk of a single resolution. The artwork worker never attaches +// one; only the CLI does, so resolution stays allocation-free in the hot path. +type chainTrace struct { + mu sync.Mutex + steps []traceStep +} + +func (t *chainTrace) add(step traceStep) { + t.mu.Lock() + defer t.mu.Unlock() + t.steps = append(t.steps, step) +} + +func (t *chainTrace) Steps() []traceStep { + t.mu.Lock() + defer t.mu.Unlock() + return slices.Clone(t.steps) +} + +type traceCtxKey struct{} + +func withTrace(ctx context.Context, t *chainTrace) context.Context { + return context.WithValue(ctx, traceCtxKey{}, t) +} + +func traceFrom(ctx context.Context) *chainTrace { + t, _ := ctx.Value(traceCtxKey{}).(*chainTrace) + return t +} diff --git a/core/artwork/trace_test.go b/core/artwork/trace_test.go new file mode 100644 index 000000000..e53fa3490 --- /dev/null +++ b/core/artwork/trace_test.go @@ -0,0 +1,43 @@ +package artwork + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("chainTrace", func() { + It("returns nil when no trace is attached", func() { + Expect(traceFrom(context.Background())).To(BeNil()) + }) + + It("collects steps in order", func() { + t := &chainTrace{} + ctx := withTrace(context.Background(), t) + + traceFrom(ctx).add(traceStep{Candidate: "cover.*", Outcome: outcomeMiss}) + traceFrom(ctx).add(traceStep{Candidate: "embedded", Outcome: outcomeHit, Detail: "/music/a.flac"}) + + Expect(t.Steps()).To(Equal([]traceStep{ + {Candidate: "cover.*", Outcome: outcomeMiss}, + {Candidate: "embedded", Outcome: outcomeHit, Detail: "/music/a.flac"}, + })) + }) + + It("is safe to use concurrently", func() { + t := &chainTrace{} + done := make(chan struct{}) + for range 10 { + go func() { + defer GinkgoRecover() + t.add(traceStep{Candidate: "x", Outcome: outcomeMiss}) + done <- struct{}{} + }() + } + for range 10 { + <-done + } + Expect(t.Steps()).To(HaveLen(10)) + }) +})