Files
orbit/sdk/go/event_integration_test.go

191 lines
7.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}