返回 DeepSeek-Reasonix
follow_test.go
根目录 / internal / transcript / follow_test.go
1 package transcript
2
3 import (
4 "context"
5 "errors"
6 "reflect"
7 "strings"
8 "sync"
9 "testing"
10 "time"
11
12 "reasonix/internal/event"
13 "reasonix/internal/turnevent"
14 )
15
16 func TestRuntimeRebindResetsIdleFollowerAndRejectsStaleFrames(t *testing.T) {
17 p, initial := newFollowProjection(t)
18 p.SetRuntimeEpoch("next-runtime")
19 response := followChanges(t, p, FollowRequest{Subscription: initial.Subscription, AfterRevision: initial.Snapshot.ProjectionRevision})
20 if !response.ResetRequired {
21 t.Fatal("idle follower did not learn runtime changed")
22 }
23 before := p.Boundary()
24 if err := p.ApplyFrame(turnevent.Envelope{SessionID: testIdentity.SessionID, RuntimeEpoch: testIdentity.RuntimeEpoch}, before.CoveredThroughSeq); err == nil {
25 t.Fatal("old controller frame accepted after runtime rebind")
26 }
27 if p.Boundary() != before {
28 t.Fatal("rejected frame changed publisher state")
29 }
30 if err := p.ApplyFrame(turnevent.Envelope{SessionID: "another-session", RuntimeEpoch: "next-runtime"}, before.CoveredThroughSeq); err == nil {
31 t.Fatal("foreign session frame accepted")
32 }
33 }
34
35 func newFollowProjection(t *testing.T) (*Projection, FollowResponse) {
36 t.Helper()
37 p, err := NewProjection(testIdentity, nil, 0)
38 if err != nil {
39 t.Fatal(err)
40 }
41 initial, err := p.Follow(t.Context(), FollowRequest{})
42 if err != nil || initial.Snapshot == nil || initial.Subscription == "" || initial.ProtocolVersion != FollowProtocolVersion {
43 t.Fatalf("initial follow: response=%+v error=%v", initial, err)
44 }
45 return p, initial
46 }
47
48 func followChanges(t *testing.T, p *Projection, request FollowRequest) FollowResponse {
49 t.Helper()
50 ctx, cancel := context.WithTimeout(t.Context(), time.Second)
51 defer cancel()
52 response, err := p.Follow(ctx, request)
53 if err != nil {
54 t.Fatalf("follow changes: %v", err)
55 }
56 return response
57 }
58
59 func TestFollowInitialCutAndConcurrentCommitHaveNoSubscriptionGap(t *testing.T) {
60 // Race both legal orderings: the accepted message belongs either to the
61 // initial cut or its queued suffix. It must never fall between them.
62 for range 32 {
63 p, err := NewProjection(testIdentity, nil, 0)
64 if err != nil {
65 t.Fatal(err)
66 }
67 start, committed := make(chan struct{}), make(chan struct{})
68 go func() {
69 <-start
70 p.AcceptBusiness([]Message{{RecordID: "m:answer", MessageID: "answer", Role: "assistant", Content: "answer"}}, 1, "turn", false)
71 close(committed)
72 }()
73 close(start)
74 initial, err := p.Follow(t.Context(), FollowRequest{})
75 if err != nil || initial.Snapshot == nil {
76 t.Fatalf("initial follow: %v", err)
77 }
78 <-committed
79 if initial.Snapshot.CoveredThroughSeq == 1 {
80 if len(initial.Snapshot.Records) != 1 || initial.Snapshot.Records[0].Message.Content != "answer" {
81 t.Fatal("initial cut advertises a commit without its message")
82 }
83 } else {
84 suffix := followChanges(t, p, FollowRequest{Subscription: initial.Subscription, AfterRevision: initial.Snapshot.ProjectionRevision})
85 if suffix.ResetRequired || len(suffix.Changes) != 1 || suffix.Changes[0].CommitSeq != 1 || len(suffix.Changes[0].Records) != 1 || suffix.Changes[0].Records[0].Content != "answer" {
86 t.Fatalf("message lost between registration and initial cut: %+v", suffix)
87 }
88 }
89 }
90 }
91
92 func TestFollowRetainsCommitsBeforeNextPollAndRetriesUnacknowledgedSuffix(t *testing.T) {
93 p, initial := newFollowProjection(t)
94 p.AcceptBusiness([]Message{{RecordID: "m:answer", MessageID: "answer", Role: "assistant", Content: "answer"}}, 1, "turn", false)
95 request := FollowRequest{Subscription: initial.Subscription, AfterRevision: initial.Snapshot.ProjectionRevision}
96 first := followChanges(t, p, request)
97 retry := followChanges(t, p, request)
98 if len(first.Changes) != 1 || !reflect.DeepEqual(first, retry) {
99 t.Fatalf("retry altered unacknowledged suffix: first=%+v retry=%+v", first, retry)
100 }
101 p.AcceptBusiness(nil, 2, "turn", false)
102 request.AfterRevision = first.Changes[0].Revision
103 next := followChanges(t, p, request)
104 if len(next.Changes) != 1 || next.Changes[0].FirstSeq != 2 || next.Changes[0].CommitSeq != 2 {
105 t.Fatalf("acknowledgment repeated or omitted a commit: %+v", next)
106 }
107 }
108
109 func TestFollowKeepsBusinessCoverageSeparateFromDisplayRevision(t *testing.T) {
110 p, initial := newFollowProjection(t)
111 p.AcceptBusiness(nil, 4, "turn", false)
112 p.SetDurableSequence(4)
113 businessFrame(t, p, 4, event.Event{Kind: event.Text, MessageID: "answer", Text: "first "})
114 businessFrame(t, p, 4, event.Event{Kind: event.Text, MessageID: "answer", Text: "second"})
115 suffix := followChanges(t, p, FollowRequest{Subscription: initial.Subscription, AfterRevision: initial.Snapshot.ProjectionRevision})
116 if len(suffix.Changes) != 4 {
117 t.Fatalf("expected business batch, durability update and two frames, got %+v", suffix)
118 }
119 for index, change := range suffix.Changes {
120 if change.CommitSeq != 4 || change.Revision <= initial.Snapshot.ProjectionRevision || index > 0 && change.Revision <= suffix.Changes[index-1].Revision {
121 t.Fatalf("invalid coverage or revision: %+v", suffix.Changes)
122 }
123 if index == 0 {
124 if change.FirstSeq != 1 || change.Event != nil {
125 t.Fatalf("non-visible business batch lost its coverage range: %+v", change)
126 }
127 } else if index == 1 {
128 if change.FirstSeq != 0 || change.Event != nil || len(change.Records) != 0 || change.DurableSeq != 4 {
129 t.Fatalf("durability update manufactured business or frame data: %+v", change)
130 }
131 } else if change.FirstSeq != 0 || change.Event == nil || change.DurableSeq != 4 {
132 t.Fatalf("frame manufactured business events or omitted durability: %+v", change)
133 }
134 }
135 }
136
137 func TestFollowDurabilityAdvancesSnapshotRevisionWithoutMutatingCachedCut(t *testing.T) {
138 p, err := NewProjection(testIdentity, nil, 0)
139 if err != nil {
140 t.Fatal(err)
141 }
142 p.AcceptBusiness([]Message{{RecordID: "m:answer", MessageID: "answer", Role: "assistant", Content: "accepted answer"}}, 4, "turn", false)
143 accepted := snapshot(t, p)
144 if accepted.DurableSeq != 0 || accepted.CoveredThroughSeq != 4 {
145 t.Fatalf("unexpected accepted-only cut: %+v", accepted.Boundary)
146 }
147 p.SetDurableSequence(4)
148 durable := snapshot(t, p)
149 if durable.SnapshotID == accepted.SnapshotID || durable.ProjectionRevision <= accepted.ProjectionRevision || durable.DurableSeq != 4 || durable.CoveredThroughSeq != 4 {
150 t.Fatalf("durability change reused a stale snapshot cut: accepted=%+v durable=%+v", accepted.Boundary, durable.Boundary)
151 }
152 if !reflect.DeepEqual(durable.Records, accepted.Records) {
153 t.Fatal("durability-only change modified visible records")
154 }
155 cached, err := p.Snapshot(PageRequest{SnapshotID: accepted.SnapshotID, Before: accepted.TotalRecords})
156 if err != nil || cached.Stale || cached.DurableSeq != 0 || cached.ProjectionRevision != accepted.ProjectionRevision || cached.CoveredThroughSeq != 4 {
157 t.Fatalf("cached accepted cut was relabeled with newer durability: %+v error=%v", cached.Boundary, err)
158 }
159 p.SetDurableSequence(4)
160 p.SetDurableSequence(2)
161 unchanged := snapshot(t, p)
162 if unchanged.Boundary != durable.Boundary {
163 t.Fatalf("duplicate or stale durability receipt changed the cut: before=%+v after=%+v", durable.Boundary, unchanged.Boundary)
164 }
165 }
166
167 func TestFollowOverflowRequiresNewBaseline(t *testing.T) {
168 for _, mode := range []string{"count", "bytes"} {
169 t.Run(mode, func(t *testing.T) {
170 p, initial := newFollowProjection(t)
171 var finalSequence uint64
172 if mode == "count" {
173 for sequence := uint64(1); sequence <= 257; sequence++ {
174 p.AcceptBusiness(nil, sequence, "turn", false)
175 finalSequence = sequence
176 }
177 } else {
178 p.AcceptBusiness([]Message{{RecordID: "m:large", MessageID: "large", Role: "assistant", Content: strings.Repeat("x", MaxResponseBytes/2)}}, 1, "turn", false)
179 finalSequence = 1
180 }
181 response := followChanges(t, p, FollowRequest{Subscription: initial.Subscription, AfterRevision: initial.Snapshot.ProjectionRevision})
182 if !response.ResetRequired || len(response.Changes) != 0 {
183 t.Fatalf("overflow silently returned a partial suffix: %+v", response)
184 }
185 // Acknowledging an arbitrarily newer revision must not clear the
186 // reset marker and make an incomplete subscription look healthy.
187 retry := followChanges(t, p, FollowRequest{Subscription: initial.Subscription, AfterRevision: ^uint64(0)})
188 if !retry.ResetRequired {
189 t.Fatal("overflow reset was cleared without a new baseline")
190 }
191 replacement, err := p.Follow(t.Context(), FollowRequest{})
192 if err != nil || replacement.Snapshot == nil || replacement.Snapshot.CoveredThroughSeq != finalSequence {
193 t.Fatalf("replacement baseline lost committed data: %+v error=%v", replacement, err)
194 }
195 })
196 }
197 }
198
199 // Done is evaluated by the long-poll select after it has released the owner
200 // lock. This provides a deterministic barrier without polling or sleeping.
201 type followWaitingContext struct {
202 context.Context
203 waiting chan struct{}
204 once sync.Once
205 }
206
207 func (c *followWaitingContext) Done() <-chan struct{} {
208 c.once.Do(func() { close(c.waiting) })
209 return c.Context.Done()
210 }
211
212 func TestFollowLongPollReleasesOwnerAndHonorsCancellation(t *testing.T) {
213 p, initial := newFollowProjection(t)
214 ctx, cancel := context.WithCancel(t.Context())
215 defer cancel()
216 waiting := &followWaitingContext{Context: ctx, waiting: make(chan struct{})}
217 result := make(chan error, 1)
218 go func() {
219 _, err := p.Follow(waiting, FollowRequest{Subscription: initial.Subscription, AfterRevision: initial.Snapshot.ProjectionRevision})
220 result <- err
221 }()
222 <-waiting.waiting
223 // Snapshot needs the same publisher lock as acceptance; it must remain
224 // available while a client is waiting on the network-facing operation.
225 if _, err := p.Snapshot(PageRequest{}); err != nil {
226 t.Fatal(err)
227 }
228 cancel()
229 select {
230 case err := <-result:
231 if !errors.Is(err, context.Canceled) {
232 t.Fatalf("long poll cancellation: %v", err)
233 }
234 case <-time.After(time.Second):
235 t.Fatal("long poll ignored cancellation")
236 }
237 }
238
239 func TestFollowCloseReleasesSubscriptionAndPendingLongPoll(t *testing.T) {
240 p, initial := newFollowProjection(t)
241 ctx, cancel := context.WithCancel(t.Context())
242 defer cancel()
243 waiting := &followWaitingContext{Context: ctx, waiting: make(chan struct{})}
244 result := make(chan FollowResponse, 1)
245 go func() {
246 response, _ := p.Follow(waiting, FollowRequest{Subscription: initial.Subscription, AfterRevision: initial.Snapshot.ProjectionRevision})
247 result <- response
248 }()
249 <-waiting.waiting
250 if _, err := p.Follow(t.Context(), FollowRequest{Subscription: initial.Subscription, Close: true}); err != nil {
251 t.Fatal(err)
252 }
253 missing := followChanges(t, p, FollowRequest{Subscription: initial.Subscription})
254 if !missing.ResetRequired {
255 t.Fatal("closed subscription remained usable")
256 }
257 select {
258 case <-result:
259 case <-time.After(time.Second):
260 t.Fatal("closed subscription retained its pending long poll")
261 }
262 }
263
263 lines GO