feat: add reliable event consumer SDK and process recovery tests

This commit is contained in:
2026-09-17 20:22:41 +08:00
parent 8485a662b2
commit b17d9b1977
25 changed files with 2170 additions and 28 deletions

View File

@@ -0,0 +1,190 @@
package orbit
import (
"context"
"encoding/json"
"errors"
"sync/atomic"
"testing"
"time"
)
func integrationEventSubscription(t *testing.T, ctx context.Context, api *Client, maxAttempts uint32) Subscription {
t.Helper()
queue, err := api.CreateQueue(ctx, workerTestID(t), CreateQueueRequest{Name: "event-sdk-" + workerTestID(t), MaxConcurrency: 1, RatePerSecond: 10})
if err != nil {
t.Fatal(err)
}
sub, err := api.CreateSubscription(ctx, workerTestID(t), CreateSubscriptionRequest{Name: "sdk-" + workerTestID(t), QueueID: queue.Queue.ID, Types: []string{"task.cancelled"}, MaxPending: 10, MaxAttempts: maxAttempts})
if err != nil {
t.Fatal(err)
}
return sub.Subscription
}
func integrationCancelledEvent(t *testing.T, ctx context.Context, api *Client, sub Subscription) Task {
t.Helper()
task, err := api.SubmitTask(ctx, workerTestID(t), SubmitTaskRequest{QueueID: sub.QueueID, Handler: "object.prepare", HandlerVersion: "v1", PayloadVersion: "v1", Payload: json.RawMessage(`{}`)})
if err != nil {
t.Fatal(err)
}
cancelled, err := api.CancelTask(ctx, task.Task.ID, workerTestID(t), CancelTaskRequest{ExpectedRevision: task.Task.Revision, Reason: "subscription SDK regression"})
if err != nil {
t.Fatal(err)
}
return cancelled.Task
}
func eventDialIntegration(t *testing.T, ctx context.Context, api *Client, sub Subscription, capacity uint32) *EventConnection {
t.Helper()
c, err := api.DialEvents(ctx, EventRegistration{SubscriptionID: sub.ID, Capacity: capacity})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { c.Close() })
return c
}
func TestIntegrationEventConnectionSDK(t *testing.T) {
api, _, _ := integrationWorkerClient(t)
ctx, stop := context.WithTimeout(context.Background(), 15*time.Second)
defer stop()
sub := integrationEventSubscription(t, ctx, api, 3)
task := integrationCancelledEvent(t, ctx, api, sub)
a := eventDialIntegration(t, ctx, api, sub, 1)
grant, err := a.Next(ctx)
if err != nil || grant.Event.Snapshot.TaskID != task.ID || grant.Event.Snapshot.Revision != task.Revision || grant.Event.Type != "task.cancelled" {
t.Fatal("SDK snapshot mismatch", err)
}
paused, err := api.ConfigureSubscription(ctx, sub.ID, workerTestID(t), ConfigureSubscriptionRequest{ExpectedRevision: sub.Revision, Reason: "pause with active delivery", Enabled: false})
if err != nil {
t.Fatal(err)
}
pong, err := a.Ping(ctx, workerTestID(t))
if err != nil || pong.Enabled {
t.Fatal("paused subscription heartbeat", err)
}
first, err := a.Ack(ctx, workerTestID(t), grant.Proof())
if err != nil {
t.Fatal(err)
}
a.Close()
b := eventDialIntegration(t, ctx, api, sub, 1)
duplicate, err := b.Ack(ctx, workerTestID(t), grant.Proof())
if err != nil || !duplicate.Deduplicated || !duplicate.AcknowledgedAt.Equal(first.AcknowledgedAt) {
t.Fatal("ACK proof could not cross connections", err)
}
_ = integrationCancelledEvent(t, ctx, api, sub)
short, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
_, err = b.Next(short)
cancel()
if !errors.Is(err, context.DeadlineExceeded) || b.Err() != nil {
t.Fatal("paused subscription delivered or Next closed connection", err)
}
if _, err = api.ConfigureSubscription(ctx, sub.ID, workerTestID(t), ConfigureSubscriptionRequest{ExpectedRevision: paused.Subscription.Revision, Reason: "resume", Enabled: true}); err != nil {
t.Fatal(err)
}
second, err := b.Next(ctx)
if err != nil || second.Event.ID == grant.Event.ID {
t.Fatal("resume failed", err)
}
if _, err = b.Ack(ctx, workerTestID(t), second.Proof()); err != nil {
t.Fatal(err)
}
current, err := api.GetSubscription(ctx, sub.ID)
if err != nil || current.PendingCount != 0 {
t.Fatal("SDK acknowledgement accounting", err)
}
}
func TestIntegrationEventConsumerHeartbeatAndLostAck(t *testing.T) {
api, _, token := integrationWorkerClient(t)
ctx, stop := context.WithTimeout(context.Background(), 25*time.Second)
defer stop()
sub := integrationEventSubscription(t, ctx, api, 3)
_ = integrationCancelledEvent(t, ctx, api, sub)
gateway := newProtocolFaultGateway(t, api.baseURL, "/v1/events/connect", eventSubprotocol)
gateway.drop.Store("event.ack")
client, err := NewClient(gateway.server.URL, Options{Token: token, HTTPClient: gateway.server.Client()})
if err != nil {
t.Fatal(err)
}
var calls atomic.Int64
consumer, err := NewEventConsumer(client, EventConsumerOptions{SubscriptionID: sub.ID, Capacity: 1, ShutdownTimeout: time.Second, Handle: func(work context.Context, event ResultEvent) error {
calls.Add(1)
// 慢业务期间真实等待15秒自动心跳;确认计数来自上游服务端ACK,不是客户端写入。
ticker := time.NewTicker(20 * time.Millisecond)
defer ticker.Stop()
for gateway.confirmedEventPings.Load() == 0 {
select {
case <-work.Done():
return work.Err()
case <-ticker.C:
}
}
return nil
}})
if err != nil {
t.Fatal(err)
}
run, cancel := context.WithCancel(ctx)
defer cancel()
done := make(chan error, 1)
go func() { done <- consumer.Run(run) }()
for consumer.Stats().Acknowledged == 0 {
select {
case <-ctx.Done():
t.Fatal("consumer did not reconcile committed ACK")
case err := <-done:
t.Fatal("consumer stopped", err)
case <-time.After(20 * time.Millisecond):
}
}
cancel()
if err := awaitConsumerStop(t, done); err != nil {
t.Fatal(err)
}
if calls.Load() != 1 || consumer.Stats().Reconnects < 1 || gateway.confirmedEventPings.Load() < 1 {
t.Fatal("heartbeat or reconciliation missing", consumer.Stats())
}
page, err := api.ListEventDeliveries(ctx, sub.ID, DeliveryPageRequest{State: DeliveryAcked})
if err != nil || len(page.Items) != 1 || page.Items[0].Attempts != 1 {
t.Fatal("lost ACK created another delivery attempt", err)
}
t.Log("real 15-second heartbeat and committed ACK loss recovered; handler and delivery attempt each remained one")
}
func TestIntegrationEventConsumerFailureRedeliversNaturally(t *testing.T) {
api, _, _ := integrationWorkerClient(t)
ctx, stop := context.WithTimeout(context.Background(), 45*time.Second)
defer stop()
sub := integrationEventSubscription(t, ctx, api, 2)
_ = integrationCancelledEvent(t, ctx, api, sub)
var calls atomic.Int64
consumer, err := NewEventConsumer(api, EventConsumerOptions{SubscriptionID: sub.ID, Capacity: 1, ShutdownTimeout: time.Second, Handle: func(context.Context, ResultEvent) error {
if calls.Add(1) == 1 {
return errors.New("receiver transaction rolled back")
}
return nil
}})
if err != nil {
t.Fatal(err)
}
run, cancel := context.WithCancel(ctx)
defer cancel()
done := make(chan error, 1)
go func() { done <- consumer.Run(run) }()
for consumer.Stats().Acknowledged == 0 {
select {
case <-ctx.Done():
t.Fatal("delivery did not retry after natural lease expiry")
case err := <-done:
t.Fatal("consumer stopped", err)
case <-time.After(50 * time.Millisecond):
}
}
cancel()
if err := awaitConsumerStop(t, done); err != nil {
t.Fatal(err)
}
page, err := api.ListEventDeliveries(ctx, sub.ID, DeliveryPageRequest{State: DeliveryAcked})
if err != nil || len(page.Items) != 1 || page.Items[0].Attempts != 2 || calls.Load() != 2 || consumer.Stats().HandlerFailures != 1 {
t.Fatal("handler error or retry budget mishandled", err)
}
t.Log("handler error caused no ACK; same delivery reappeared after its real 30-second lease and backoff, then acknowledged at attempt two")
}