From 0bbef2b8842549b62f87e75c3a912a0b7acb4667 Mon Sep 17 00:00:00 2001 From: Chris Miles Date: Mon, 20 Jul 2026 08:38:26 +0000 Subject: [PATCH 1/4] refactor: remove legacy queue APIs --- README.md | 8 +- bus/bus.go | 650 ------------- bus/bus_test.go | 463 ---------- bus/construction_compat_test.go | 185 ---- bus/doc.go | 8 - bus/driver/temporal/temporal.go | 328 ------- bus/driver/temporal/temporal_test.go | 331 ------- bus/events.go | 124 --- bus/facade_forwarding_test.go | 370 -------- bus/fake.go | 216 ----- bus/fake_test.go | 362 -------- bus/job_options_test.go | 41 - bus/middleware.go | 73 -- bus/middleware_test.go | 353 ------- bus/observer_adapter_contract_test.go | 102 --- bus/options_builder_coverage_test.go | 120 --- bus/source_compat_test.go | 449 --------- bus/store.go | 96 -- bus/store_managed_schema_test.go | 37 - bus/store_sql_compat_test.go | 401 -------- bus/test_runtime_helper_test.go | 30 - bus/testdata/compat/workflow/v1/sqlite.sql | 89 -- bus/testhooks_integration.go | 74 -- bus/testhooks_integration_test.go | 90 -- bus/types.go | 100 -- bus/wire_compat_test.go | 304 ------- bus/workflow_adapter_behavior_test.go | 190 ---- bus/workflow_adapters.go | 368 -------- bus/workflow_adapters_test.go | 290 ------ docs/bus-design.md | 400 -------- docs/bus-implementation-checklist.md | 168 ---- docs/direct-delivery-migration.md | 2 +- docs/events.md | 11 +- docs/examplegen/main.go | 12 - docs/ga-readiness.md | 2 +- docs/metrics-contract.md | 10 +- docs/plan.md | 48 +- docs/production-config.md | 2 - docs/readme/testcounts/integration_count.json | 4 +- driver/natsqueue/natsqueue.go | 3 +- driver/rabbitmqqueue/rabbitmqqueue.go | 3 +- driver/redisqueue/delivery_metadata_test.go | 1 + driver/redisqueue/queue_redis_impl_test.go | 1 + driver/redisqueue/redisqueue.go | 14 +- driver/redisqueue/redisqueue_test.go | 4 +- driver/sqlqueuecore/wrapper.go | 3 +- driver/sqsqueue/sqsqueue.go | 3 +- driver_runtime.go | 11 +- error_contract_test.go | 2 +- examples/newfake/main.go | 2 +- examples/queuefake-fake-count/main.go | 22 - examples/queuefake-fake-countjob/main.go | 22 - examples/queuefake-fake-counton/main.go | 21 - examples/queuefake-fake-queue/main.go | 20 - examples/queuefake-fake-records/main.go | 21 - fake_queue.go | 2 +- .../all/handler_context_integration_test.go | 6 +- .../bus/callback_sql_integration_test.go | 339 ------- integration/bus/config_builders_test.go | 51 -- .../dispatch_failure_sql_integration_test.go | 154 ---- integration/bus/integration_test.go | 859 ------------------ integration/bus/queue_factory_test.go | 17 - integration/root/config_builders_test.go | 4 +- .../workflow_contract_integration_test.go | 159 ++++ internal/architecture/comments_test.go | 4 +- internal/architecture/imports_test.go | 6 +- internal/driverbridge/bridge.go | 4 +- internal/driverbridge/bridge_test.go | 7 +- .../readme_manual_snippets_test.go | 10 +- internal/runtimehook/hook.go | 2 +- observability_benchmark_test.go | 5 +- observability_test.go | 28 +- public_workflow_identity_test.go | 94 -- queue.go | 34 +- queue_runtime_unit_test.go | 20 +- queuefake/doc.go | 5 - queuefake/fake.go | 338 ------- queuefake/fake_test.go | 122 --- runtime.go | 24 - runtime_hook.go | 8 +- runtime_test.go | 4 +- test-plan.md | 5 +- unified_observer_test.go | 62 +- 83 files changed, 298 insertions(+), 9139 deletions(-) delete mode 100644 bus/bus.go delete mode 100644 bus/bus_test.go delete mode 100644 bus/construction_compat_test.go delete mode 100644 bus/doc.go delete mode 100644 bus/driver/temporal/temporal.go delete mode 100644 bus/driver/temporal/temporal_test.go delete mode 100644 bus/events.go delete mode 100644 bus/facade_forwarding_test.go delete mode 100644 bus/fake.go delete mode 100644 bus/fake_test.go delete mode 100644 bus/job_options_test.go delete mode 100644 bus/middleware.go delete mode 100644 bus/middleware_test.go delete mode 100644 bus/observer_adapter_contract_test.go delete mode 100644 bus/options_builder_coverage_test.go delete mode 100644 bus/source_compat_test.go delete mode 100644 bus/store.go delete mode 100644 bus/store_managed_schema_test.go delete mode 100644 bus/store_sql_compat_test.go delete mode 100644 bus/test_runtime_helper_test.go delete mode 100644 bus/testdata/compat/workflow/v1/sqlite.sql delete mode 100644 bus/testhooks_integration.go delete mode 100644 bus/testhooks_integration_test.go delete mode 100644 bus/types.go delete mode 100644 bus/wire_compat_test.go delete mode 100644 bus/workflow_adapter_behavior_test.go delete mode 100644 bus/workflow_adapters.go delete mode 100644 bus/workflow_adapters_test.go delete mode 100644 docs/bus-design.md delete mode 100644 docs/bus-implementation-checklist.md delete mode 100644 examples/queuefake-fake-count/main.go delete mode 100644 examples/queuefake-fake-countjob/main.go delete mode 100644 examples/queuefake-fake-counton/main.go delete mode 100644 examples/queuefake-fake-queue/main.go delete mode 100644 examples/queuefake-fake-records/main.go delete mode 100644 integration/bus/callback_sql_integration_test.go delete mode 100644 integration/bus/config_builders_test.go delete mode 100644 integration/bus/dispatch_failure_sql_integration_test.go delete mode 100644 integration/bus/integration_test.go delete mode 100644 integration/bus/queue_factory_test.go create mode 100644 integration/root/workflow_contract_integration_test.go delete mode 100644 queuefake/doc.go delete mode 100644 queuefake/fake.go delete mode 100644 queuefake/fake_test.go diff --git a/README.md b/README.md index 19fcbc0..3743e82 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,8 @@ Latest tag - Unit tests (executed count) - Integration tests (executed count) + Unit tests (executed count) + Integration tests (executed count)

@@ -2091,7 +2091,7 @@ q, err := redisqueue.NewWithConfig( Addr: "127.0.0.1:6379", // required Password: "", // optional; default empty DB: 0, // optional; default 0 - ServerLogger: nil, // optional; default backend logger + Logger: nil, // optional; default backend logger ServerLogLevel: redisqueue.ServerLogLevelDefault, // optional }, queue.WithWorkers(4), // optional; default: runtime.NumCPU() (min 1) @@ -2362,7 +2362,7 @@ FindChain returns workflow state created by the fake's production engine. #### NewFake -NewFake creates the canonical fake used directly and by deprecated testing adapters. +NewFake creates the canonical fake for direct and workflow tests. ```go fake := queue.NewFake() diff --git a/bus/bus.go b/bus/bus.go deleted file mode 100644 index 8d12b68..0000000 --- a/bus/bus.go +++ /dev/null @@ -1,650 +0,0 @@ -package bus - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "time" - - "github.com/goforj/queue" - "github.com/goforj/queue/internal/workflow" -) - -// ErrQueueOptionsUnsupported indicates that bus construction options cannot be -// retrofitted onto an already configured queue.Queue. -var ErrQueueOptionsUnsupported = errors.New("bus options cannot configure an existing queue.Queue") - -// Bus is the legacy workflow orchestration contract. -// -// Deprecated: use queue.Queue. -type Bus interface { - // Register binds a legacy workflow handler to a job type. - Register(jobType string, handler Handler) - // Dispatch submits one legacy workflow job. - Dispatch(ctx context.Context, job Job) (DispatchResult, error) - // Chain creates a sequential workflow builder. - Chain(jobs ...Job) ChainBuilder - // Batch creates an aggregate workflow builder. - Batch(jobs ...Job) BatchBuilder - // StartWorkers starts the underlying queue worker runtime. - StartWorkers(ctx context.Context) error - // Shutdown stops the underlying queue worker runtime. - Shutdown(ctx context.Context) error - // FindBatch returns persisted batch state. - FindBatch(ctx context.Context, batchID string) (BatchState, error) - // FindChain returns persisted chain state. - FindChain(ctx context.Context, chainID string) (ChainState, error) - // Prune removes terminal workflow state older than the supplied time. - Prune(ctx context.Context, before time.Time) error -} - -// ChainBuilder configures and dispatches a sequential workflow. -// -// Deprecated: use queue.ChainBuilder. -type ChainBuilder interface { - // OnQueue applies a default queue to chain jobs without an explicit target. - OnQueue(queue string) ChainBuilder - // Catch registers the explicitly ephemeral chain failure callback. - Catch(fn func(ctx context.Context, st ChainState, err error) error) ChainBuilder - // Finally registers the explicitly ephemeral chain terminal callback. - Finally(fn func(ctx context.Context, st ChainState) error) ChainBuilder - // Dispatch persists and starts the chain workflow. - Dispatch(ctx context.Context) (string, error) -} - -// BatchBuilder configures and dispatches an aggregate workflow. -// -// Deprecated: use queue.BatchBuilder. -type BatchBuilder interface { - // Name assigns an application-facing label to the batch. - Name(name string) BatchBuilder - // OnQueue applies a default queue to batch jobs without an explicit target. - OnQueue(queue string) BatchBuilder - // AllowFailures keeps remaining members active after a terminal member failure. - AllowFailures() BatchBuilder - // Progress registers the explicitly ephemeral batch progress callback. - Progress(fn func(ctx context.Context, st BatchState) error) BatchBuilder - // Then registers the explicitly ephemeral batch success callback. - Then(fn func(ctx context.Context, st BatchState) error) BatchBuilder - // Catch registers the explicitly ephemeral batch failure callback. - Catch(fn func(ctx context.Context, st BatchState, err error) error) BatchBuilder - // Finally registers the explicitly ephemeral batch terminal callback. - Finally(fn func(ctx context.Context, st BatchState) error) BatchBuilder - // Dispatch persists and starts the batch workflow. - Dispatch(ctx context.Context) (string, error) -} - -// Option configures the legacy raw-runtime construction route. -// -// Deprecated: configure queue.Queue directly. Options are rejected when New -// receives an already constructed queue.Queue because its engine already exists. -type Option func(*optionConfig) - -type optionConfig struct { - observer Observer - store Store - clock func() time.Time - middlewares []Middleware -} - -// WithObserver installs a legacy workflow observer on a raw-runtime bus. -// -// Deprecated: use queue.WithObserver. -func WithObserver(observer Observer) Option { - return func(config *optionConfig) { - config.observer = observer - } -} - -// WithStore selects the workflow store on a raw-runtime bus. -// -// Deprecated: use queue.WithStore. -func WithStore(store Store) Option { - return func(config *optionConfig) { - if store != nil { - config.store = store - } - } -} - -// WithClock selects the workflow clock on a raw-runtime bus. -// -// Deprecated: use queue.WithClock. -func WithClock(clock func() time.Time) Option { - return func(config *optionConfig) { - if clock != nil { - config.clock = clock - } - } -} - -// WithMiddleware appends middleware to a raw-runtime bus. -// -// Deprecated: use queue.WithMiddleware. -func WithMiddleware(middlewares ...Middleware) Option { - return func(config *optionConfig) { - for _, middleware := range middlewares { - if middleware != nil { - config.middlewares = append(config.middlewares, middleware) - } - } - } -} - -// New returns a compatibility view over queue.Queue or constructs the retained -// low-level route when q implements busruntime.Runtime. -// -// Deprecated: construct and use queue.Queue directly. -func New(q any, opts ...Option) (Bus, error) { - if existing, ok := q.(*queue.Queue); ok { - if existing == nil { - return nil, errors.New("queue is required") - } - if hasConstructionOptions(opts) { - return nil, fmt.Errorf("%w: pass options to queue.New instead", ErrQueueOptionsUnsupported) - } - return &queueAdapter{queue: existing}, nil - } - return newRawRuntimeAdapter(q, nil, opts...) -} - -// NewWithStore constructs the retained low-level bus route with an explicit -// store. An existing queue.Queue must instead receive queue.WithStore when built. -// -// Deprecated: use queue.New with queue.WithStore. -func NewWithStore(q any, store Store, opts ...Option) (Bus, error) { - if existing, ok := q.(*queue.Queue); ok { - if existing == nil { - return nil, errors.New("queue is required") - } - return nil, fmt.Errorf("%w: pass queue.WithStore to queue.New instead", ErrQueueOptionsUnsupported) - } - return newRawRuntimeAdapter(q, store, opts...) -} - -// hasConstructionOptions distinguishes an option-free facade request from an -// attempt to mutate an engine that queue.Queue has already configured. -func hasConstructionOptions(opts []Option) bool { - for _, option := range opts { - if option != nil { - return true - } - } - return false -} - -// newRawRuntimeAdapter preserves the advanced busruntime.Runtime construction -// seam while delegating all orchestration behavior to the single internal engine. -func newRawRuntimeAdapter(q any, store Store, opts ...Option) (Bus, error) { - config := optionConfig{store: store} - for _, option := range opts { - if option != nil { - option(&config) - } - } - engineOptions := make([]workflow.Option, 0, 3) - if config.observer != nil { - engineOptions = append(engineOptions, workflow.WithObserver(legacyObserverAdapter{observer: config.observer})) - } - if config.clock != nil { - engineOptions = append(engineOptions, workflow.WithClock(config.clock)) - } - if len(config.middlewares) > 0 { - engineOptions = append(engineOptions, workflow.WithMiddleware(toWorkflowMiddlewares(config.middlewares)...)) - } - engine, err := workflow.NewWithStore(q, toWorkflowStore(config.store), engineOptions...) - if err != nil { - return nil, err - } - return &runtimeAdapter{engine: engine}, nil -} - -type legacyObserverAdapter struct { - observer Observer -} - -// Observe translates the canonical engine event into the frozen legacy event shape. -func (a legacyObserverAdapter) Observe(ctx context.Context, event workflow.Event) { - safeObserve(ctx, a.observer, Event{ - SchemaVersion: event.SchemaVersion, - EventID: event.EventID, - Kind: EventKind(event.Kind), - DispatchID: event.DispatchID, - JobID: event.JobID, - ChainID: event.ChainID, - BatchID: event.BatchID, - Attempt: event.Attempt, - JobType: event.JobType, - JobKey: event.JobKey, - Queue: event.Queue, - Duration: event.Duration, - Time: event.Time, - Err: event.Err, - }) -} - -type runtimeAdapter struct { - engine workflow.Engine -} - -var _ Bus = (*runtimeAdapter)(nil) - -// Register adapts a legacy handler to the internal engine message contract. -func (a *runtimeAdapter) Register(jobType string, handler Handler) { - if handler == nil { - return - } - a.engine.Register(jobType, func(ctx context.Context, message workflow.Context) error { - return handler(ctx, toQueueMessage(message)) - }) -} - -// Dispatch converts the legacy boundary DTO without changing when payload JSON is encoded. -func (a *runtimeAdapter) Dispatch(ctx context.Context, job Job) (DispatchResult, error) { - result, err := a.engine.Dispatch(ctx, toWorkflowJob(job)) - return toQueueDispatchResult(result), err -} - -// Chain converts legacy job DTOs and wraps the engine's self-returning builder interface. -func (a *runtimeAdapter) Chain(jobs ...Job) ChainBuilder { - converted := make([]workflow.Job, 0, len(jobs)) - for _, job := range jobs { - converted = append(converted, toWorkflowJob(job)) - } - return &runtimeChainBuilder{inner: a.engine.Chain(converted...)} -} - -// Batch converts legacy job DTOs and wraps the engine's self-returning builder interface. -func (a *runtimeAdapter) Batch(jobs ...Job) BatchBuilder { - converted := make([]workflow.Job, 0, len(jobs)) - for _, job := range jobs { - converted = append(converted, toWorkflowJob(job)) - } - return &runtimeBatchBuilder{inner: a.engine.Batch(converted...)} -} - -// StartWorkers forwards worker startup to the raw runtime engine. -func (a *runtimeAdapter) StartWorkers(ctx context.Context) error { - return a.engine.StartWorkers(ctx) -} - -// Shutdown forwards worker shutdown to the raw runtime engine. -func (a *runtimeAdapter) Shutdown(ctx context.Context) error { - return a.engine.Shutdown(ctx) -} - -// FindBatch forwards persisted batch lookup to the internal engine. -func (a *runtimeAdapter) FindBatch(ctx context.Context, batchID string) (BatchState, error) { - state, err := a.engine.FindBatch(ctx, batchID) - return toQueueBatchState(state), err -} - -// FindChain forwards persisted chain lookup to the internal engine. -func (a *runtimeAdapter) FindChain(ctx context.Context, chainID string) (ChainState, error) { - state, err := a.engine.FindChain(ctx, chainID) - return toQueueChainState(state), err -} - -// Prune forwards workflow retention to the internal engine. -func (a *runtimeAdapter) Prune(ctx context.Context, before time.Time) error { - return a.engine.Prune(ctx, before) -} - -// toWorkflowJob maps the legacy mutable DTO into the engine model without -// encoding Payload, preserving the legacy Dispatch-time failure boundary. -func toWorkflowJob(job Job) workflow.Job { - return workflow.Job{ - Type: job.Type, - Payload: job.Payload, - Options: workflow.JobOptions{ - Queue: job.Options.Queue, - Delay: job.Options.Delay, - Timeout: job.Options.Timeout, - Retry: job.Options.Retry, - Backoff: job.Options.Backoff, - UniqueFor: job.Options.UniqueFor, - }, - } -} - -type runtimeChainBuilder struct { - inner workflow.ChainBuilder -} - -// OnQueue applies a default queue to jobs without an explicit target. -func (b *runtimeChainBuilder) OnQueue(queueName string) ChainBuilder { - b.inner = b.inner.OnQueue(queueName) - return b -} - -// Catch registers the legacy failure callback on the internal builder. -func (b *runtimeChainBuilder) Catch(callback func(context.Context, ChainState, error) error) ChainBuilder { - if callback == nil { - b.inner = b.inner.Catch(nil) - return b - } - b.inner = b.inner.Catch(func(ctx context.Context, state workflow.ChainState, err error) error { - return callback(ctx, toQueueChainState(state), err) - }) - return b -} - -// Finally registers the legacy terminal callback on the internal builder. -func (b *runtimeChainBuilder) Finally(callback func(context.Context, ChainState) error) ChainBuilder { - if callback == nil { - b.inner = b.inner.Finally(nil) - return b - } - b.inner = b.inner.Finally(func(ctx context.Context, state workflow.ChainState) error { - return callback(ctx, toQueueChainState(state)) - }) - return b -} - -// Dispatch creates and starts the internal chain workflow. -func (b *runtimeChainBuilder) Dispatch(ctx context.Context) (string, error) { - return b.inner.Dispatch(ctx) -} - -type runtimeBatchBuilder struct { - inner workflow.BatchBuilder -} - -// Name sets the display name on the internal batch builder. -func (b *runtimeBatchBuilder) Name(name string) BatchBuilder { - b.inner = b.inner.Name(name) - return b -} - -// OnQueue applies a default queue to jobs without an explicit target. -func (b *runtimeBatchBuilder) OnQueue(queueName string) BatchBuilder { - b.inner = b.inner.OnQueue(queueName) - return b -} - -// AllowFailures keeps remaining batch jobs active after one member fails. -func (b *runtimeBatchBuilder) AllowFailures() BatchBuilder { - b.inner = b.inner.AllowFailures() - return b -} - -// Progress registers the legacy progress callback on the internal builder. -func (b *runtimeBatchBuilder) Progress(callback func(context.Context, BatchState) error) BatchBuilder { - if callback == nil { - b.inner = b.inner.Progress(nil) - return b - } - b.inner = b.inner.Progress(func(ctx context.Context, state workflow.BatchState) error { - return callback(ctx, toQueueBatchState(state)) - }) - return b -} - -// Then registers the legacy success callback on the internal builder. -func (b *runtimeBatchBuilder) Then(callback func(context.Context, BatchState) error) BatchBuilder { - if callback == nil { - b.inner = b.inner.Then(nil) - return b - } - b.inner = b.inner.Then(func(ctx context.Context, state workflow.BatchState) error { - return callback(ctx, toQueueBatchState(state)) - }) - return b -} - -// Catch registers the legacy failure callback on the internal builder. -func (b *runtimeBatchBuilder) Catch(callback func(context.Context, BatchState, error) error) BatchBuilder { - if callback == nil { - b.inner = b.inner.Catch(nil) - return b - } - b.inner = b.inner.Catch(func(ctx context.Context, state workflow.BatchState, err error) error { - return callback(ctx, toQueueBatchState(state), err) - }) - return b -} - -// Finally registers the legacy terminal callback on the internal builder. -func (b *runtimeBatchBuilder) Finally(callback func(context.Context, BatchState) error) BatchBuilder { - if callback == nil { - b.inner = b.inner.Finally(nil) - return b - } - b.inner = b.inner.Finally(func(ctx context.Context, state workflow.BatchState) error { - return callback(ctx, toQueueBatchState(state)) - }) - return b -} - -// Dispatch creates and starts the internal batch workflow. -func (b *runtimeBatchBuilder) Dispatch(ctx context.Context) (string, error) { - return b.inner.Dispatch(ctx) -} - -type queueAdapter struct { - queue *queue.Queue -} - -var _ Bus = (*queueAdapter)(nil) - -// Register forwards a legacy handler to the already configured root queue. -func (a *queueAdapter) Register(jobType string, handler Handler) { - a.queue.Register(jobType, handler) -} - -// Dispatch binds ctx to the root queue for this call and preserves legacy payload JSON semantics. -func (a *queueAdapter) Dispatch(ctx context.Context, job Job) (DispatchResult, error) { - converted, err := toQueueJob(job) - if err != nil { - return DispatchResult{}, err - } - return a.queue.WithContext(ctx).Dispatch(converted) -} - -// Chain snapshots legacy job values while preserving Dispatch-time payload encoding. -func (a *queueAdapter) Chain(jobs ...Job) ChainBuilder { - return &queueChainBuilder{ - queue: a.queue, - jobs: append([]Job(nil), jobs...), - } -} - -// Batch snapshots legacy job values while preserving Dispatch-time payload encoding. -func (a *queueAdapter) Batch(jobs ...Job) BatchBuilder { - return &queueBatchBuilder{ - queue: a.queue, - jobs: append([]Job(nil), jobs...), - } -} - -// StartWorkers starts the existing root queue runtime. -func (a *queueAdapter) StartWorkers(ctx context.Context) error { - return a.queue.StartWorkers(ctx) -} - -// Shutdown stops the existing root queue runtime. -func (a *queueAdapter) Shutdown(ctx context.Context) error { - return a.queue.Shutdown(ctx) -} - -// FindBatch reads batch state from the root queue's configured store. -func (a *queueAdapter) FindBatch(ctx context.Context, batchID string) (BatchState, error) { - return a.queue.FindBatch(ctx, batchID) -} - -// FindChain reads chain state from the root queue's configured store. -func (a *queueAdapter) FindChain(ctx context.Context, chainID string) (ChainState, error) { - return a.queue.FindChain(ctx, chainID) -} - -// Prune applies retention through the root queue's configured store. -func (a *queueAdapter) Prune(ctx context.Context, before time.Time) error { - return a.queue.Prune(ctx, before) -} - -// queueWorkflowTarget is the canonical root builder surface shared by the -// production facade and its recording fake. -type queueWorkflowTarget interface { - // Chain creates a canonical sequential workflow builder. - Chain(jobs ...queue.Job) queue.ChainBuilder - // Batch creates a canonical aggregate workflow builder. - Batch(jobs ...queue.Job) queue.BatchBuilder -} - -type queueChainBuilder struct { - queue queueWorkflowTarget - jobs []Job - queueName string - catch func(context.Context, ChainState, error) error - finally func(context.Context, ChainState) error -} - -// OnQueue forwards queue selection while retaining the legacy fluent return type. -func (b *queueChainBuilder) OnQueue(queueName string) ChainBuilder { - b.queueName = queueName - return b -} - -// Catch forwards the legacy failure callback to the root builder. -func (b *queueChainBuilder) Catch(callback func(context.Context, ChainState, error) error) ChainBuilder { - b.catch = callback - return b -} - -// Finally forwards the legacy terminal callback to the root builder. -func (b *queueChainBuilder) Finally(callback func(context.Context, ChainState) error) ChainBuilder { - b.finally = callback - return b -} - -// Dispatch converts the shallow legacy job snapshot at the historical dispatch boundary. -func (b *queueChainBuilder) Dispatch(ctx context.Context) (string, error) { - converted, err := toQueueJobs(b.jobs) - if err != nil { - return "", err - } - return b.queue.Chain(converted...). - OnQueue(b.queueName). - Catch(b.catch). - Finally(b.finally). - Dispatch(ctx) -} - -type queueBatchBuilder struct { - queue queueWorkflowTarget - jobs []Job - name string - queueName string - allowFailures bool - progress func(context.Context, BatchState) error - then func(context.Context, BatchState) error - catch func(context.Context, BatchState, error) error - finally func(context.Context, BatchState) error -} - -// Name forwards the application-facing batch label while retaining the legacy fluent return type. -func (b *queueBatchBuilder) Name(name string) BatchBuilder { - b.name = name - return b -} - -// OnQueue forwards queue selection while retaining the legacy fluent return type. -func (b *queueBatchBuilder) OnQueue(queueName string) BatchBuilder { - b.queueName = queueName - return b -} - -// AllowFailures forwards fail-soft behavior while retaining the legacy fluent return type. -func (b *queueBatchBuilder) AllowFailures() BatchBuilder { - b.allowFailures = true - return b -} - -// Progress forwards the legacy progress callback to the root builder. -func (b *queueBatchBuilder) Progress(callback func(context.Context, BatchState) error) BatchBuilder { - b.progress = callback - return b -} - -// Then forwards the legacy success callback to the root builder. -func (b *queueBatchBuilder) Then(callback func(context.Context, BatchState) error) BatchBuilder { - b.then = callback - return b -} - -// Catch forwards the legacy failure callback to the root builder. -func (b *queueBatchBuilder) Catch(callback func(context.Context, BatchState, error) error) BatchBuilder { - b.catch = callback - return b -} - -// Finally forwards the legacy terminal callback to the root builder. -func (b *queueBatchBuilder) Finally(callback func(context.Context, BatchState) error) BatchBuilder { - b.finally = callback - return b -} - -// Dispatch converts the shallow legacy job snapshot at the historical dispatch boundary. -func (b *queueBatchBuilder) Dispatch(ctx context.Context) (string, error) { - converted, err := toQueueJobs(b.jobs) - if err != nil { - return "", err - } - builder := b.queue.Batch(converted...). - Name(b.name). - OnQueue(b.queueName) - if b.allowFailures { - builder = builder.AllowFailures() - } - return builder. - Progress(b.progress). - Then(b.then). - Catch(b.catch). - Finally(b.finally). - Dispatch(ctx) -} - -// toQueueJobs converts a legacy workflow job slice while retaining the first conversion error. -func toQueueJobs(jobs []Job) ([]queue.Job, error) { - converted := make([]queue.Job, 0, len(jobs)) - for _, job := range jobs { - convertedJob, err := toQueueJob(job) - if err != nil { - return nil, err - } - converted = append(converted, convertedJob) - } - return converted, nil -} - -// toQueueJob freezes the legacy DTO's json.Marshal result as raw canonical -// payload bytes so strings, byte slices, RawMessage, nil, and custom marshalers -// retain their historical wire representation. -func toQueueJob(job Job) (queue.Job, error) { - if job.Type == "" { - return queue.Job{}, errors.New("bus job type is required") - } - payload, err := json.Marshal(job.Payload) - if err != nil { - return queue.Job{}, err - } - converted := queue.NewJob(job.Type).Payload(json.RawMessage(payload)) - if job.Options.Queue != "" { - converted = converted.OnQueue(job.Options.Queue) - } - if job.Options.Delay != 0 { - converted = converted.Delay(job.Options.Delay) - } - if job.Options.Timeout != 0 { - converted = converted.Timeout(job.Options.Timeout) - } - converted = converted.Retry(job.Options.Retry) - if job.Options.Backoff != 0 { - converted = converted.Backoff(job.Options.Backoff) - } - if job.Options.UniqueFor != 0 { - converted = converted.UniqueFor(job.Options.UniqueFor) - } - return converted, nil -} diff --git a/bus/bus_test.go b/bus/bus_test.go deleted file mode 100644 index e59f5e5..0000000 --- a/bus/bus_test.go +++ /dev/null @@ -1,463 +0,0 @@ -package bus_test - -import ( - "context" - "errors" - "sync" - "testing" - "time" - - "github.com/goforj/queue" - "github.com/goforj/queue/bus" -) - -func TestDispatchExecutesRegisteredHandler(t *testing.T) { - q, err := newBusTestRuntime(queue.Config{Driver: queue.DriverSync}) - if err != nil { - t.Fatalf("new sync queue: %v", err) - } - b, err := bus.New(q) - if err != nil { - t.Fatalf("new bus: %v", err) - } - if err := b.StartWorkers(context.Background()); err != nil { - t.Fatalf("start workers: %v", err) - } - - var called bool - b.Register("monitor:poll", func(ctx context.Context, c bus.Context) error { - called = true - return nil - }) - - if _, err := b.Dispatch(context.Background(), bus.NewJob("monitor:poll", map[string]string{"url": "https://x"})); err != nil { - t.Fatalf("dispatch: %v", err) - } - if !called { - t.Fatal("expected handler to be called") - } -} - -func TestChainStopsOnFailureAndRunsCallbacksOnce(t *testing.T) { - q, err := newBusTestRuntime(queue.Config{Driver: queue.DriverSync}) - if err != nil { - t.Fatalf("new sync queue: %v", err) - } - b, err := bus.New(q) - if err != nil { - t.Fatalf("new bus: %v", err) - } - if err := b.StartWorkers(context.Background()); err != nil { - t.Fatalf("start workers: %v", err) - } - - mu := sync.Mutex{} - order := make([]string, 0, 3) - var catchCount int - var finallyCount int - - b.Register("monitor:poll", func(ctx context.Context, c bus.Context) error { - mu.Lock() - order = append(order, c.JobType) - mu.Unlock() - return nil - }) - b.Register("monitor:downsample", func(ctx context.Context, c bus.Context) error { - mu.Lock() - order = append(order, c.JobType) - mu.Unlock() - return errors.New("boom") - }) - b.Register("monitor:alert", func(ctx context.Context, c bus.Context) error { - mu.Lock() - order = append(order, c.JobType) - mu.Unlock() - return nil - }) - - if _, err := b.Chain( - bus.NewJob("monitor:poll", nil), - bus.NewJob("monitor:downsample", nil), - bus.NewJob("monitor:alert", nil), - ).Catch(func(ctx context.Context, st bus.ChainState, err error) error { - catchCount++ - return nil - }).Finally(func(ctx context.Context, st bus.ChainState) error { - finallyCount++ - return nil - }).Dispatch(context.Background()); err == nil { - t.Fatal("expected chain dispatch to surface failure") - } - - mu.Lock() - defer mu.Unlock() - if len(order) != 2 { - t.Fatalf("expected 2 executed jobs, got %d (%v)", len(order), order) - } - if order[0] != "monitor:poll" || order[1] != "monitor:downsample" { - t.Fatalf("unexpected execution order: %v", order) - } - if catchCount != 1 { - t.Fatalf("expected catch once, got %d", catchCount) - } - if finallyCount != 1 { - t.Fatalf("expected finally once, got %d", finallyCount) - } -} - -func TestBatchTracksCompletion(t *testing.T) { - q, err := newBusTestRuntime(queue.Config{Driver: queue.DriverSync}) - if err != nil { - t.Fatalf("new sync queue: %v", err) - } - b, err := bus.New(q) - if err != nil { - t.Fatalf("new bus: %v", err) - } - if err := b.StartWorkers(context.Background()); err != nil { - t.Fatalf("start workers: %v", err) - } - - b.Register("monitor:poll", func(context.Context, bus.Context) error { return nil }) - b.Register("monitor:downsample", func(context.Context, bus.Context) error { return nil }) - b.Register("monitor:alert", func(context.Context, bus.Context) error { return nil }) - - var thenCount int - var finallyCount int - batchID, err := b.Batch( - bus.NewJob("monitor:poll", nil), - bus.NewJob("monitor:downsample", nil), - bus.NewJob("monitor:alert", nil), - ).Then(func(ctx context.Context, st bus.BatchState) error { - thenCount++ - return nil - }).Finally(func(ctx context.Context, st bus.BatchState) error { - finallyCount++ - return nil - }).Dispatch(context.Background()) - if err != nil { - t.Fatalf("dispatch batch: %v", err) - } - - st, err := b.FindBatch(context.Background(), batchID) - if err != nil { - t.Fatalf("find batch: %v", err) - } - if !st.Completed { - t.Fatal("expected batch completed") - } - if st.Processed != 3 { - t.Fatalf("expected processed=3, got %d", st.Processed) - } - if thenCount != 1 { - t.Fatalf("expected then once, got %d", thenCount) - } - if finallyCount != 1 { - t.Fatalf("expected finally once, got %d", finallyCount) - } -} - -func TestCallbackJobEmitsCallbackEvents(t *testing.T) { - q, err := newBusTestRuntime(queue.Config{Driver: queue.DriverSync}) - if err != nil { - t.Fatalf("new sync queue: %v", err) - } - - var callbackStarted int - var callbackSucceeded int - observer := bus.ObserverFunc(func(_ context.Context, e bus.Event) { - if e.Kind == bus.EventCallbackStarted { - callbackStarted++ - } - if e.Kind == bus.EventCallbackSucceeded { - callbackSucceeded++ - } - }) - - b, err := bus.New(q, bus.WithObserver(observer)) - if err != nil { - t.Fatalf("new bus: %v", err) - } - if err := b.StartWorkers(context.Background()); err != nil { - t.Fatalf("start workers: %v", err) - } - - b.Register("monitor:poll", func(context.Context, bus.Context) error { return nil }) - b.Register("monitor:downsample", func(context.Context, bus.Context) error { return errors.New("boom") }) - b.Register("monitor:alert", func(context.Context, bus.Context) error { return nil }) - - _, _ = b.Chain( - bus.NewJob("monitor:poll", nil), - bus.NewJob("monitor:downsample", nil), - bus.NewJob("monitor:alert", nil), - ).Catch(func(context.Context, bus.ChainState, error) error { - return nil - }).Finally(func(context.Context, bus.ChainState) error { - return nil - }).Dispatch(context.Background()) - - if callbackStarted == 0 { - t.Fatal("expected callback started events") - } - if callbackSucceeded == 0 { - t.Fatal("expected callback succeeded events") - } -} - -func TestDispatchEmitsStartedAndSucceeded(t *testing.T) { - q, err := newBusTestRuntime(queue.Config{Driver: queue.DriverSync}) - if err != nil { - t.Fatalf("new sync queue: %v", err) - } - - var started int - var succeeded int - observer := bus.ObserverFunc(func(_ context.Context, e bus.Event) { - if e.Kind == bus.EventDispatchStarted { - started++ - } - if e.Kind == bus.EventDispatchSucceeded { - succeeded++ - } - }) - - b, err := bus.New(q, bus.WithObserver(observer)) - if err != nil { - t.Fatalf("new bus: %v", err) - } - if err := b.StartWorkers(context.Background()); err != nil { - t.Fatalf("start workers: %v", err) - } - - b.Register("monitor:poll", func(context.Context, bus.Context) error { return nil }) - if _, err := b.Dispatch(context.Background(), bus.NewJob("monitor:poll", nil)); err != nil { - t.Fatalf("dispatch: %v", err) - } - if started != 1 { - t.Fatalf("expected dispatch started once, got %d", started) - } - if succeeded != 1 { - t.Fatalf("expected dispatch succeeded once, got %d", succeeded) - } -} - -func TestBatchFailFastEmitsCancelled(t *testing.T) { - q, err := newBusTestRuntime(queue.Config{Driver: queue.DriverSync}) - if err != nil { - t.Fatalf("new sync queue: %v", err) - } - - var cancelled int - observer := bus.ObserverFunc(func(_ context.Context, e bus.Event) { - if e.Kind == bus.EventBatchCancelled { - cancelled++ - } - }) - - b, err := bus.New(q, bus.WithObserver(observer)) - if err != nil { - t.Fatalf("new bus: %v", err) - } - if err := b.StartWorkers(context.Background()); err != nil { - t.Fatalf("start workers: %v", err) - } - - b.Register("monitor:poll", func(context.Context, bus.Context) error { return errors.New("fail-fast") }) - if _, err := b.Batch(bus.NewJob("monitor:poll", nil)).Dispatch(context.Background()); err == nil { - t.Fatal("expected batch dispatch error") - } - if cancelled != 1 { - t.Fatalf("expected batch cancelled once, got %d", cancelled) - } -} - -func TestChainCatchRunsOnceForDuplicateCallbackJobs(t *testing.T) { - q, err := newBusTestRuntime(queue.Config{Driver: queue.DriverSync}) - if err != nil { - t.Fatalf("new sync queue: %v", err) - } - store := bus.NewMemoryStore() - - b, err := bus.NewWithStore(q, store) - if err != nil { - t.Fatalf("new bus: %v", err) - } - if err := b.StartWorkers(context.Background()); err != nil { - t.Fatalf("start workers: %v", err) - } - - var catchCount int - b.Register("monitor:downsample", func(context.Context, bus.Context) error { - return errors.New("boom") - }) - - chainID, err := b.Chain( - bus.NewJob("monitor:downsample", nil), - ).Catch(func(context.Context, bus.ChainState, error) error { - catchCount++ - return nil - }).Dispatch(context.Background()) - if err == nil { - t.Fatal("expected chain dispatch error") - } - if catchCount != 1 { - t.Fatalf("expected catch once after failed chain, got %d", catchCount) - } - - cbPayload := map[string]any{ - "schema_version": 1, - "dispatch_id": "dup-dispatch", - "kind": "callback", - "job_id": "dup-job", - "chain_id": chainID, - "callback_kind": "chain_catch", - "error": "boom", - } - if err := q.Dispatch(queue.NewJob("bus:callback").Payload(cbPayload)); err != nil { - t.Fatalf("dispatch duplicate callback: %v", err) - } - if catchCount != 1 { - t.Fatalf("expected catch to remain once after duplicate callback, got %d", catchCount) - } -} - -func TestBatchThenFinallyRunOnceForDuplicateCallbackJobs(t *testing.T) { - q, err := newBusTestRuntime(queue.Config{Driver: queue.DriverSync}) - if err != nil { - t.Fatalf("new sync queue: %v", err) - } - store := bus.NewMemoryStore() - - b, err := bus.NewWithStore(q, store) - if err != nil { - t.Fatalf("new bus: %v", err) - } - if err := b.StartWorkers(context.Background()); err != nil { - t.Fatalf("start workers: %v", err) - } - - var thenCount int - var finallyCount int - b.Register("monitor:poll", func(context.Context, bus.Context) error { return nil }) - - batchID, err := b.Batch( - bus.NewJob("monitor:poll", nil), - ).Then(func(context.Context, bus.BatchState) error { - thenCount++ - return nil - }).Finally(func(context.Context, bus.BatchState) error { - finallyCount++ - return nil - }).Dispatch(context.Background()) - if err != nil { - t.Fatalf("dispatch batch: %v", err) - } - if thenCount != 1 { - t.Fatalf("expected then once, got %d", thenCount) - } - if finallyCount != 1 { - t.Fatalf("expected finally once, got %d", finallyCount) - } - - for _, callbackKind := range []string{"batch_then", "batch_finally"} { - cbPayload := map[string]any{ - "schema_version": 1, - "dispatch_id": "dup-dispatch", - "kind": "callback", - "job_id": "dup-job-" + callbackKind, - "batch_id": batchID, - "callback_kind": callbackKind, - } - if err := q.Dispatch(queue.NewJob("bus:callback").Payload(cbPayload)); err != nil { - t.Fatalf("dispatch duplicate callback (%s): %v", callbackKind, err) - } - } - - if thenCount != 1 { - t.Fatalf("expected then to remain once after duplicate callbacks, got %d", thenCount) - } - if finallyCount != 1 { - t.Fatalf("expected finally to remain once after duplicate callbacks, got %d", finallyCount) - } -} - -func TestBatchCatchRunsOnceForDuplicateCallbackJobs(t *testing.T) { - q, err := newBusTestRuntime(queue.Config{Driver: queue.DriverSync}) - if err != nil { - t.Fatalf("new sync queue: %v", err) - } - store := bus.NewMemoryStore() - - b, err := bus.NewWithStore(q, store) - if err != nil { - t.Fatalf("new bus: %v", err) - } - if err := b.StartWorkers(context.Background()); err != nil { - t.Fatalf("start workers: %v", err) - } - - var catchCount int - b.Register("monitor:downsample", func(context.Context, bus.Context) error { - return errors.New("boom") - }) - - batchID, err := b.Batch( - bus.NewJob("monitor:downsample", nil), - ).Catch(func(context.Context, bus.BatchState, error) error { - catchCount++ - return nil - }).Dispatch(context.Background()) - if err == nil { - t.Fatal("expected batch dispatch error") - } - if catchCount != 1 { - t.Fatalf("expected catch once after failed batch, got %d", catchCount) - } - - cbPayload := map[string]any{ - "schema_version": 1, - "dispatch_id": "dup-dispatch", - "kind": "callback", - "job_id": "dup-job-batch-catch", - "batch_id": batchID, - "callback_kind": "batch_catch", - "error": "boom", - } - if err := q.Dispatch(queue.NewJob("bus:callback").Payload(cbPayload)); err != nil { - t.Fatalf("dispatch duplicate callback: %v", err) - } - if catchCount != 1 { - t.Fatalf("expected catch to remain once after duplicate callback, got %d", catchCount) - } -} - -func TestBusPruneRemovesTerminalWorkflowState(t *testing.T) { - q, err := newBusTestRuntime(queue.Config{Driver: queue.DriverSync}) - if err != nil { - t.Fatalf("new sync queue: %v", err) - } - store := bus.NewMemoryStore() - b, err := bus.NewWithStore(q, store) - if err != nil { - t.Fatalf("new bus: %v", err) - } - if err := b.StartWorkers(context.Background()); err != nil { - t.Fatalf("start workers: %v", err) - } - - b.Register("monitor:poll", func(context.Context, bus.Context) error { return nil }) - chainID, err := b.Chain(bus.NewJob("monitor:poll", nil)).Dispatch(context.Background()) - if err != nil { - t.Fatalf("dispatch chain: %v", err) - } - if _, err := b.FindChain(context.Background(), chainID); err != nil { - t.Fatalf("find chain before prune: %v", err) - } - - if err := b.Prune(context.Background(), time.Now().Add(1*time.Minute)); err != nil { - t.Fatalf("prune: %v", err) - } - if _, err := b.FindChain(context.Background(), chainID); !errors.Is(err, bus.ErrNotFound) { - t.Fatalf("expected chain pruned, got err=%v", err) - } -} diff --git a/bus/construction_compat_test.go b/bus/construction_compat_test.go deleted file mode 100644 index ab9df73..0000000 --- a/bus/construction_compat_test.go +++ /dev/null @@ -1,185 +0,0 @@ -package bus_test - -import ( - "context" - "encoding/json" - "errors" - "sync/atomic" - "testing" - "time" - - "github.com/goforj/queue" - "github.com/goforj/queue/bus" -) - -type facadeContextKey struct{} - -// TestBusNewWithQueueSharesCanonicalEngine proves compatibility wrappers do not construct or register a second workflow engine. -func TestBusNewWithQueueSharesCanonicalEngine(t *testing.T) { - root, err := queue.NewSync() - if err != nil { - t.Fatalf("new sync queue: %v", err) - } - first, err := bus.New(root) - if err != nil { - t.Fatalf("new first compatibility facade: %v", err) - } - second, err := bus.New(root, nil) - if err != nil { - t.Fatalf("new second compatibility facade: %v", err) - } - if err := first.StartWorkers(context.Background()); err != nil { - t.Fatalf("start workers through facade: %v", err) - } - t.Cleanup(func() { - if shutdownErr := root.Shutdown(context.Background()); shutdownErr != nil { - t.Errorf("shutdown root queue: %v", shutdownErr) - } - }) - - var handled atomic.Int32 - first.Register("compat:shared", func(ctx context.Context, message bus.Context) error { - if ctx.Value(facadeContextKey{}) != "legacy-context" { - return errors.New("legacy dispatch context was not forwarded") - } - var payload struct { - ID int `json:"id"` - } - if err := message.Bind(&payload); err != nil { - return err - } - if payload.ID != 7 && payload.ID != 8 { - return errors.New("unexpected shared handler payload") - } - handled.Add(1) - return nil - }) - ctx := context.WithValue(context.Background(), facadeContextKey{}, "legacy-context") - if _, err := second.Dispatch(ctx, bus.NewJob("compat:shared", map[string]int{"id": 7})); err != nil { - t.Fatalf("dispatch through second facade: %v", err) - } - if _, err := root.WithContext(ctx).Dispatch(queue.NewJob("compat:shared").PayloadJSON(map[string]int{"id": 8})); err != nil { - t.Fatalf("dispatch through root after facade registration: %v", err) - } - if handled.Load() != 2 { - t.Fatalf("shared handler calls = %d, want 2", handled.Load()) - } - - first.Register("compat:step", func(context.Context, bus.Context) error { return nil }) - chainID, err := second.Chain( - bus.NewJob("compat:step", nil), - bus.NewJob("compat:step", nil), - ).Dispatch(context.Background()) - if err != nil { - t.Fatalf("dispatch legacy chain through root engine: %v", err) - } - chainState, err := root.FindChain(context.Background(), chainID) - if err != nil { - t.Fatalf("find legacy chain through root: %v", err) - } - if !chainState.Completed || chainState.NextIndex != 2 { - t.Fatalf("shared chain state = %+v, want completed two-node chain", chainState) - } - - batchID, err := root.Batch( - queue.NewJob("compat:step"), - queue.NewJob("compat:step"), - ).Dispatch(context.Background()) - if err != nil { - t.Fatalf("dispatch root batch: %v", err) - } - batchState, err := first.FindBatch(context.Background(), batchID) - if err != nil { - t.Fatalf("find root batch through facade: %v", err) - } - if !batchState.Completed || batchState.Processed != 2 { - t.Fatalf("shared batch state = %+v, want completed two-job batch", batchState) - } - - if _, err := first.FindChain(context.Background(), "missing-chain"); !errors.Is(err, bus.ErrNotFound) || !errors.Is(err, queue.ErrWorkflowNotFound) { - t.Fatalf("shared not-found identity = %v", err) - } -} - -// TestBusNewWithQueueRejectsConstructionOptions makes already-applied queue configuration explicit instead of silently ignoring it. -func TestBusNewWithQueueRejectsConstructionOptions(t *testing.T) { - root, err := queue.NewSync() - if err != nil { - t.Fatalf("new sync queue: %v", err) - } - tests := []struct { - name string - option bus.Option - }{ - {name: "observer", option: bus.WithObserver(bus.ObserverFunc(func(context.Context, bus.Event) {}))}, - {name: "store", option: bus.WithStore(bus.NewMemoryStore())}, - {name: "clock", option: bus.WithClock(time.Now)}, - {name: "middleware", option: bus.WithMiddleware(bus.RetryPolicy{})}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - if _, err := bus.New(root, test.option); !errors.Is(err, bus.ErrQueueOptionsUnsupported) { - t.Fatalf("bus.New option error = %v, want ErrQueueOptionsUnsupported", err) - } - }) - } - if _, err := bus.NewWithStore(root, bus.NewMemoryStore()); !errors.Is(err, bus.ErrQueueOptionsUnsupported) { - t.Fatalf("bus.NewWithStore error = %v, want ErrQueueOptionsUnsupported", err) - } - if _, err := bus.New((*queue.Queue)(nil)); err == nil || err.Error() != "queue is required" { - t.Fatalf("typed nil queue error = %v, want queue is required", err) - } -} - -// TestBusQueueFacadePreservesLegacyPayloadEncoding proves the new canonical route does not reinterpret compatibility DTO payloads. -func TestBusQueueFacadePreservesLegacyPayloadEncoding(t *testing.T) { - root, err := queue.NewSync() - if err != nil { - t.Fatalf("new sync queue: %v", err) - } - compatibility, err := bus.New(root) - if err != nil { - t.Fatalf("new compatibility facade: %v", err) - } - if err := compatibility.StartWorkers(context.Background()); err != nil { - t.Fatalf("start workers: %v", err) - } - t.Cleanup(func() { - if shutdownErr := compatibility.Shutdown(context.Background()); shutdownErr != nil { - t.Errorf("shutdown compatibility facade: %v", shutdownErr) - } - }) - - var got []byte - compatibility.Register("compat:payload", func(_ context.Context, message bus.Context) error { - got = message.PayloadBytes() - return nil - }) - tests := []struct { - name string - payload any - want string - }{ - {name: "nil", payload: nil, want: "null"}, - {name: "map", payload: map[string]bool{"ready": true}, want: `{"ready":true}`}, - {name: "string", payload: "raw", want: `"raw"`}, - {name: "bytes", payload: []byte{0, 1, 2}, want: `"AAEC"`}, - {name: "raw message", payload: json.RawMessage(`{"raw":true}`), want: `{"raw":true}`}, - {name: "custom marshaler", payload: fixedJSONPayload{}, want: `{"custom":true}`}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - got = nil - if _, err := compatibility.Dispatch(context.Background(), bus.NewJob("compat:payload", test.payload)); err != nil { - t.Fatalf("dispatch legacy payload: %v", err) - } - if string(got) != test.want { - t.Fatalf("handler payload = %q, want %q", got, test.want) - } - }) - } - - if _, err := compatibility.Dispatch(context.Background(), bus.NewJob("compat:payload", failingJSONPayload{})); err == nil || err.Error() != "json: error calling MarshalJSON for type bus_test.failingJSONPayload: compat marshal failure" { - t.Fatalf("marshal failure = %v, want legacy deferred error", err) - } -} diff --git a/bus/doc.go b/bus/doc.go deleted file mode 100644 index f83a1bc..0000000 --- a/bus/doc.go +++ /dev/null @@ -1,8 +0,0 @@ -// Package bus preserves the legacy workflow API as a compatibility facade over queue. -// -// Deprecated: use the top-level queue package. The low-level raw-runtime route -// remains temporarily available for existing integrations, but all orchestration -// behavior is owned by queue's internal workflow engine. Compatible model aliases -// resolve to physical root queue types; legacy boundary DTOs and interfaces remain -// here only where aliasing would change source behavior. -package bus diff --git a/bus/driver/temporal/temporal.go b/bus/driver/temporal/temporal.go deleted file mode 100644 index aec5253..0000000 --- a/bus/driver/temporal/temporal.go +++ /dev/null @@ -1,328 +0,0 @@ -package temporal - -import ( - "context" - "crypto/rand" - "encoding/hex" - "encoding/json" - "errors" - "sync" - "time" - - "github.com/goforj/queue/bus" -) - -var ErrEngineRequired = errors.New("temporal driver requires an engine") - -// ErrNotImplemented is kept for backward compatibility. -var ErrNotImplemented = ErrEngineRequired - -type Config struct { - Namespace string - JobQueue string - Engine Engine -} - -type Engine interface { - StartWorkflow(ctx context.Context, req StartWorkflowRequest) (StartWorkflowResult, error) -} - -type StartWorkflowRequest struct { - DispatchID string - JobID string - Namespace string - JobQueue string - JobType string - Payload []byte - Options bus.JobOptions -} - -type StartWorkflowResult struct { - WorkflowID string - RunID string -} - -type Adapter struct { - cfg Config - - mu sync.RWMutex - handlers map[string]bus.Handler - chainStates map[string]bus.ChainState - batchStates map[string]bus.BatchState -} - -var _ bus.Bus = (*Adapter)(nil) - -func New(cfg Config) (*Adapter, error) { - if cfg.JobQueue == "" { - cfg.JobQueue = "default" - } - return &Adapter{ - cfg: cfg, - handlers: make(map[string]bus.Handler), - chainStates: make(map[string]bus.ChainState), - batchStates: make(map[string]bus.BatchState), - }, nil -} - -func (a *Adapter) Register(jobType string, handler bus.Handler) { - if jobType == "" || handler == nil { - return - } - a.mu.Lock() - a.handlers[jobType] = handler - a.mu.Unlock() -} - -func (a *Adapter) Dispatch(ctx context.Context, job bus.Job) (bus.DispatchResult, error) { - if a.cfg.Engine == nil { - return bus.DispatchResult{}, ErrEngineRequired - } - wj, err := toWireJob(job) - if err != nil { - return bus.DispatchResult{}, err - } - dispatchID := newID("dsp") - jobID := newID("job") - result, err := a.cfg.Engine.StartWorkflow(ctx, StartWorkflowRequest{ - DispatchID: dispatchID, - JobID: jobID, - Namespace: a.cfg.Namespace, - JobQueue: queueForJob(a.cfg.JobQueue, wj.Options.Queue), - JobType: wj.Type, - Payload: wj.Payload, - Options: wj.Options, - }) - if err != nil { - return bus.DispatchResult{DispatchID: dispatchID}, err - } - _ = result - return bus.DispatchResult{DispatchID: dispatchID}, nil -} - -func (a *Adapter) Chain(jobs ...bus.Job) bus.ChainBuilder { - return &temporalChainBuilder{adapter: a, jobs: append([]bus.Job(nil), jobs...)} -} - -func (a *Adapter) Batch(jobs ...bus.Job) bus.BatchBuilder { - return &temporalBatchBuilder{adapter: a, jobs: append([]bus.Job(nil), jobs...)} -} - -func (a *Adapter) Shutdown(context.Context) error { return nil } -func (a *Adapter) Prune(context.Context, time.Time) error { return nil } - -type temporalChainBuilder struct { - adapter *Adapter - jobs []bus.Job - queue string -} - -func (b *temporalChainBuilder) OnQueue(queue string) bus.ChainBuilder { - b.queue = queue - return b -} -func (b *temporalChainBuilder) Catch(fn func(context.Context, bus.ChainState, error) error) bus.ChainBuilder { - return b -} -func (b *temporalChainBuilder) Finally(fn func(context.Context, bus.ChainState) error) bus.ChainBuilder { - return b -} -func (b *temporalChainBuilder) Dispatch(ctx context.Context) (string, error) { - if len(b.jobs) == 0 { - return "", errors.New("chain requires at least one job") - } - if b.adapter == nil || b.adapter.cfg.Engine == nil { - return "", ErrEngineRequired - } - now := time.Now() - chainID := newID("chn") - steps := make([]wireJob, 0, len(b.jobs)) - for _, job := range b.jobs { - wj, err := toWireJob(job) - if err != nil { - return "", err - } - steps = append(steps, wj) - } - chainPayload, err := json.Marshal(struct { - Steps []wireJob `json:"steps"` - }{Steps: steps}) - if err != nil { - return "", err - } - _, err = b.adapter.cfg.Engine.StartWorkflow(ctx, StartWorkflowRequest{ - DispatchID: chainID, - JobID: newID("job"), - Namespace: b.adapter.cfg.Namespace, - JobQueue: queueForJob(b.adapter.cfg.JobQueue, b.queue), - JobType: "bus:chain", - Payload: chainPayload, - Options: bus.JobOptions{Queue: b.queue}, - }) - if err != nil { - return "", err - } - b.adapter.mu.Lock() - b.adapter.chainStates[chainID] = bus.ChainState{ - ChainID: chainID, - DispatchID: chainID, - Queue: queueForJob(b.adapter.cfg.JobQueue, b.queue), - Nodes: nil, - NextIndex: 0, - Completed: false, - Failed: false, - CreatedAt: now, - UpdatedAt: now, - } - b.adapter.mu.Unlock() - return chainID, nil -} - -type temporalBatchBuilder struct { - adapter *Adapter - jobs []bus.Job - name string - queue string - allowFailures bool -} - -func (b *temporalBatchBuilder) Name(name string) bus.BatchBuilder { - b.name = name - return b -} -func (b *temporalBatchBuilder) OnQueue(queue string) bus.BatchBuilder { - b.queue = queue - return b -} -func (b *temporalBatchBuilder) AllowFailures() bus.BatchBuilder { - b.allowFailures = true - return b -} -func (b *temporalBatchBuilder) Dispatch(ctx context.Context) (string, error) { - if len(b.jobs) == 0 { - return "", errors.New("batch requires at least one job") - } - if b.adapter == nil || b.adapter.cfg.Engine == nil { - return "", ErrEngineRequired - } - now := time.Now() - batchID := newID("bat") - steps := make([]wireJob, 0, len(b.jobs)) - for _, job := range b.jobs { - wj, err := toWireJob(job) - if err != nil { - return "", err - } - steps = append(steps, wj) - } - batchPayload, err := json.Marshal(struct { - Name string `json:"name"` - AllowFailures bool `json:"allow_failures"` - Steps []wireJob `json:"steps"` - }{ - Name: b.name, - AllowFailures: b.allowFailures, - Steps: steps, - }) - if err != nil { - return "", err - } - _, err = b.adapter.cfg.Engine.StartWorkflow(ctx, StartWorkflowRequest{ - DispatchID: batchID, - JobID: newID("job"), - Namespace: b.adapter.cfg.Namespace, - JobQueue: queueForJob(b.adapter.cfg.JobQueue, b.queue), - JobType: "bus:batch", - Payload: batchPayload, - Options: bus.JobOptions{Queue: b.queue}, - }) - if err != nil { - return "", err - } - b.adapter.mu.Lock() - b.adapter.batchStates[batchID] = bus.BatchState{ - BatchID: batchID, - DispatchID: batchID, - Name: b.name, - Queue: queueForJob(b.adapter.cfg.JobQueue, b.queue), - AllowFailed: b.allowFailures, - Total: len(b.jobs), - Pending: len(b.jobs), - Processed: 0, - Failed: 0, - Cancelled: false, - Completed: false, - CreatedAt: now, - UpdatedAt: now, - } - b.adapter.mu.Unlock() - return batchID, nil -} -func (b *temporalBatchBuilder) Progress(fn func(context.Context, bus.BatchState) error) bus.BatchBuilder { - return b -} -func (b *temporalBatchBuilder) Then(fn func(context.Context, bus.BatchState) error) bus.BatchBuilder { - return b -} -func (b *temporalBatchBuilder) Catch(fn func(context.Context, bus.BatchState, error) error) bus.BatchBuilder { - return b -} -func (b *temporalBatchBuilder) Finally(fn func(context.Context, bus.BatchState) error) bus.BatchBuilder { - return b -} - -func (a *Adapter) StartWorkers(context.Context) error { return nil } - -func (a *Adapter) FindChain(_ context.Context, chainID string) (bus.ChainState, error) { - a.mu.RLock() - defer a.mu.RUnlock() - st, ok := a.chainStates[chainID] - if !ok { - return bus.ChainState{}, bus.ErrNotFound - } - return st, nil -} - -func (a *Adapter) FindBatch(_ context.Context, batchID string) (bus.BatchState, error) { - a.mu.RLock() - defer a.mu.RUnlock() - st, ok := a.batchStates[batchID] - if !ok { - return bus.BatchState{}, bus.ErrNotFound - } - return st, nil -} - -func toWireJob(job bus.Job) (wireJob, error) { - if job.Type == "" { - return wireJob{}, errors.New("bus job type is required") - } - payload, err := json.Marshal(job.Payload) - if err != nil { - return wireJob{}, err - } - return wireJob{ - Type: job.Type, - Payload: payload, - Options: job.Options, - }, nil -} - -func queueForJob(defaultQueue, override string) string { - if override != "" { - return override - } - return defaultQueue -} - -type wireJob struct { - Type string `json:"type"` - Payload []byte `json:"payload"` - Options bus.JobOptions `json:"options"` -} - -func newID(prefix string) string { - var b [8]byte - _, _ = rand.Read(b[:]) - return prefix + "_" + hex.EncodeToString(b[:]) -} diff --git a/bus/driver/temporal/temporal_test.go b/bus/driver/temporal/temporal_test.go deleted file mode 100644 index 5474fc2..0000000 --- a/bus/driver/temporal/temporal_test.go +++ /dev/null @@ -1,331 +0,0 @@ -package temporal - -import ( - "context" - "encoding/json" - "errors" - "testing" - "time" - - "github.com/goforj/queue/bus" -) - -type stubEngine struct { - req StartWorkflowRequest - res StartWorkflowResult - err error - calls int -} - -func (s *stubEngine) StartWorkflow(_ context.Context, req StartWorkflowRequest) (StartWorkflowResult, error) { - s.calls++ - s.req = req - if s.err != nil { - return StartWorkflowResult{}, s.err - } - return s.res, nil -} - -func TestDispatch_BasicJobSuccess(t *testing.T) { - eng := &stubEngine{res: StartWorkflowResult{WorkflowID: "wf-1", RunID: "run-1"}} - a, err := New(Config{ - Namespace: "default", - JobQueue: "monitor", - Engine: eng, - }) - if err != nil { - t.Fatalf("new adapter: %v", err) - } - - res, err := a.Dispatch(context.Background(), bus.NewJob("monitor:poll", map[string]string{"url": "https://goforj.dev/health"})) - if err != nil { - t.Fatalf("dispatch: %v", err) - } - if eng.calls != 1 { - t.Fatalf("expected 1 call, got %d", eng.calls) - } - if res.DispatchID == "" { - t.Fatal("expected non-empty dispatch id") - } - if eng.req.JobType != "monitor:poll" { - t.Fatalf("expected job type monitor:poll, got %q", eng.req.JobType) - } - if eng.req.JobQueue != "monitor" { - t.Fatalf("expected job queue monitor, got %q", eng.req.JobQueue) - } -} - -func TestDispatch_FailsWithoutEngine(t *testing.T) { - a, err := New(Config{}) - if err != nil { - t.Fatalf("new adapter: %v", err) - } - _, err = a.Dispatch(context.Background(), bus.NewJob("monitor:poll", nil)) - if !errors.Is(err, ErrNotImplemented) { - t.Fatalf("expected ErrNotImplemented, got %v", err) - } -} - -func TestDispatch_EngineFailure(t *testing.T) { - eng := &stubEngine{err: errors.New("engine down")} - a, err := New(Config{Engine: eng}) - if err != nil { - t.Fatalf("new adapter: %v", err) - } - res, err := a.Dispatch(context.Background(), bus.NewJob("monitor:poll", nil)) - if err == nil { - t.Fatal("expected error") - } - if res.DispatchID == "" { - t.Fatal("expected non-empty dispatch id on engine failure") - } - if err.Error() != "engine down" { - t.Fatalf("expected engine error, got %v", err) - } -} - -func TestDispatch_PassesJobOptionsToEngine(t *testing.T) { - eng := &stubEngine{res: StartWorkflowResult{WorkflowID: "wf-1", RunID: "run-1"}} - a, err := New(Config{ - Namespace: "default", - JobQueue: "monitor", - Engine: eng, - }) - if err != nil { - t.Fatalf("new adapter: %v", err) - } - - job := bus.NewJob("monitor:poll", map[string]string{"url": "https://goforj.dev/health"}). - OnQueue("monitor-critical"). - Delay(2 * time.Second). - Timeout(15 * time.Second). - Retry(4). - Backoff(500 * time.Millisecond). - UniqueFor(30 * time.Second) - if _, err := a.Dispatch(context.Background(), job); err != nil { - t.Fatalf("dispatch: %v", err) - } - - if eng.req.JobQueue != "monitor-critical" { - t.Fatalf("expected job queue override monitor-critical, got %q", eng.req.JobQueue) - } - if eng.req.Options.Queue != "monitor-critical" { - t.Fatalf("expected options queue monitor-critical, got %q", eng.req.Options.Queue) - } - if eng.req.Options.Delay != 2*time.Second { - t.Fatalf("expected delay 2s, got %v", eng.req.Options.Delay) - } - if eng.req.Options.Timeout != 15*time.Second { - t.Fatalf("expected timeout 15s, got %v", eng.req.Options.Timeout) - } - if eng.req.Options.Retry != 4 { - t.Fatalf("expected retry 4, got %d", eng.req.Options.Retry) - } - if eng.req.Options.Backoff != 500*time.Millisecond { - t.Fatalf("expected backoff 500ms, got %v", eng.req.Options.Backoff) - } - if eng.req.Options.UniqueFor != 30*time.Second { - t.Fatalf("expected unique_for 30s, got %v", eng.req.Options.UniqueFor) - } -} - -func TestChain_BasicSuccess(t *testing.T) { - eng := &stubEngine{res: StartWorkflowResult{WorkflowID: "wf-chain-1", RunID: "run-1"}} - a, err := New(Config{ - Namespace: "default", - JobQueue: "monitor", - Engine: eng, - }) - if err != nil { - t.Fatalf("new adapter: %v", err) - } - - id, err := a.Chain( - bus.NewJob("monitor:poll", map[string]string{"url": "https://a"}), - bus.NewJob("monitor:downsample", map[string]any{"window": "5m"}), - bus.NewJob("monitor:alert", map[string]any{"level": "critical"}), - ).OnQueue("critical-monitor").Dispatch(context.Background()) - if err != nil { - t.Fatalf("chain dispatch: %v", err) - } - if id == "" { - t.Fatal("expected non-empty chain id") - } - if eng.calls != 1 { - t.Fatalf("expected 1 engine call, got %d", eng.calls) - } - if eng.req.JobType != "bus:chain" { - t.Fatalf("expected job type bus:chain, got %q", eng.req.JobType) - } - if eng.req.JobQueue != "critical-monitor" { - t.Fatalf("expected queue override critical-monitor, got %q", eng.req.JobQueue) - } - st, err := a.FindChain(context.Background(), id) - if err != nil { - t.Fatalf("find chain: %v", err) - } - if st.ChainID != id { - t.Fatalf("expected chain id %q, got %q", id, st.ChainID) - } - var payload struct { - Steps []wireJob `json:"steps"` - } - if err := json.Unmarshal(eng.req.Payload, &payload); err != nil { - t.Fatalf("unmarshal chain payload: %v", err) - } - if len(payload.Steps) != 3 { - t.Fatalf("expected 3 steps, got %d", len(payload.Steps)) - } -} - -func TestChain_RequiresJobs(t *testing.T) { - eng := &stubEngine{} - a, err := New(Config{Engine: eng}) - if err != nil { - t.Fatalf("new adapter: %v", err) - } - _, err = a.Chain().Dispatch(context.Background()) - if err == nil { - t.Fatal("expected error for empty chain") - } -} - -func TestBatch_BasicSuccess(t *testing.T) { - eng := &stubEngine{res: StartWorkflowResult{WorkflowID: "wf-batch-1", RunID: "run-1"}} - a, err := New(Config{ - Namespace: "default", - JobQueue: "monitor", - Engine: eng, - }) - if err != nil { - t.Fatalf("new adapter: %v", err) - } - - id, err := a.Batch( - bus.NewJob("monitor:poll", map[string]string{"url": "https://a"}), - bus.NewJob("monitor:downsample", map[string]any{"window": "5m"}), - bus.NewJob("monitor:alert", map[string]any{"level": "critical"}), - ).Name("Monitor Sweep").OnQueue("batch-monitor").AllowFailures().Dispatch(context.Background()) - if err != nil { - t.Fatalf("batch dispatch: %v", err) - } - if id == "" { - t.Fatal("expected non-empty batch id") - } - if eng.calls != 1 { - t.Fatalf("expected 1 engine call, got %d", eng.calls) - } - if eng.req.JobType != "bus:batch" { - t.Fatalf("expected job type bus:batch, got %q", eng.req.JobType) - } - if eng.req.JobQueue != "batch-monitor" { - t.Fatalf("expected queue override batch-monitor, got %q", eng.req.JobQueue) - } - st, err := a.FindBatch(context.Background(), id) - if err != nil { - t.Fatalf("find batch: %v", err) - } - if st.BatchID != id { - t.Fatalf("expected batch id %q, got %q", id, st.BatchID) - } - var payload struct { - Name string `json:"name"` - AllowFailures bool `json:"allow_failures"` - Steps []wireJob `json:"steps"` - } - if err := json.Unmarshal(eng.req.Payload, &payload); err != nil { - t.Fatalf("unmarshal batch payload: %v", err) - } - if payload.Name != "Monitor Sweep" { - t.Fatalf("expected payload name Monitor Sweep, got %q", payload.Name) - } - if !payload.AllowFailures { - t.Fatal("expected allow_failures=true") - } - if len(payload.Steps) != 3 { - t.Fatalf("expected 3 steps, got %d", len(payload.Steps)) - } -} - -func TestBatch_RequiresJobs(t *testing.T) { - eng := &stubEngine{} - a, err := New(Config{Engine: eng}) - if err != nil { - t.Fatalf("new adapter: %v", err) - } - _, err = a.Batch().Dispatch(context.Background()) - if err == nil { - t.Fatal("expected error for empty batch") - } -} - -func TestFindNotFoundAndPruneNoop(t *testing.T) { - a, err := New(Config{}) - if err != nil { - t.Fatalf("new adapter: %v", err) - } - if _, err := a.FindChain(context.Background(), "missing"); !errors.Is(err, bus.ErrNotFound) { - t.Fatalf("expected ErrNotFound for chain, got %v", err) - } - if _, err := a.FindBatch(context.Background(), "missing"); !errors.Is(err, bus.ErrNotFound) { - t.Fatalf("expected ErrNotFound for batch, got %v", err) - } - if err := a.Prune(context.Background(), time.Now()); err != nil { - t.Fatalf("expected prune noop, got %v", err) - } -} - -func TestAdapter_RegisterAndNoopRuntimeMethods(t *testing.T) { - a, err := New(Config{}) - if err != nil { - t.Fatalf("new adapter: %v", err) - } - a.Register("", func(context.Context, bus.Context) error { return nil }) - a.Register("job:nil", nil) - a.Register("job:ok", func(context.Context, bus.Context) error { return nil }) - if len(a.handlers) != 1 { - t.Fatalf("expected one registered handler, got %d", len(a.handlers)) - } - if err := a.StartWorkers(context.Background()); err != nil { - t.Fatalf("start workers noop should succeed: %v", err) - } - if err := a.Shutdown(context.Background()); err != nil { - t.Fatalf("shutdown noop should succeed: %v", err) - } -} - -func TestBuilderNoopCallbacksAndToWireJobErrors(t *testing.T) { - eng := &stubEngine{res: StartWorkflowResult{WorkflowID: "wf-1", RunID: "run-1"}} - a, err := New(Config{JobQueue: "default", Engine: eng}) - if err != nil { - t.Fatalf("new adapter: %v", err) - } - chainID, err := a.Chain(bus.NewJob("job:one", map[string]any{"id": 1})). - Catch(func(context.Context, bus.ChainState, error) error { return nil }). - Finally(func(context.Context, bus.ChainState) error { return nil }). - Dispatch(context.Background()) - if err != nil { - t.Fatalf("chain dispatch: %v", err) - } - if chainID == "" { - t.Fatal("expected non-empty chain id") - } - batchID, err := a.Batch(bus.NewJob("job:two", map[string]any{"id": 2})). - Progress(func(context.Context, bus.BatchState) error { return nil }). - Then(func(context.Context, bus.BatchState) error { return nil }). - Catch(func(context.Context, bus.BatchState, error) error { return nil }). - Finally(func(context.Context, bus.BatchState) error { return nil }). - Dispatch(context.Background()) - if err != nil { - t.Fatalf("batch dispatch: %v", err) - } - if batchID == "" { - t.Fatal("expected non-empty batch id") - } - if _, err := toWireJob(bus.Job{}); err == nil { - t.Fatal("expected toWireJob to reject empty job type") - } - if _, err := toWireJob(bus.NewJob("bad", func() {})); err == nil { - t.Fatal("expected toWireJob to reject non-marshable payload") - } -} diff --git a/bus/events.go b/bus/events.go deleted file mode 100644 index c05913e..0000000 --- a/bus/events.go +++ /dev/null @@ -1,124 +0,0 @@ -package bus - -import ( - "context" - "time" -) - -// EventKind identifies one legacy workflow lifecycle fact. -// -// Deprecated: use queue.EventKind. -type EventKind string - -const ( - // EventDispatchStarted identifies the start of a legacy dispatch operation. - EventDispatchStarted EventKind = "dispatch_started" - // EventDispatchSucceeded identifies an accepted legacy dispatch operation. - EventDispatchSucceeded EventKind = "dispatch_succeeded" - // EventDispatchFailed identifies a rejected legacy dispatch operation. - EventDispatchFailed EventKind = "dispatch_failed" - // EventJobStarted identifies the start of logical workflow job execution. - EventJobStarted EventKind = "job_started" - // EventJobSucceeded identifies committed logical workflow job success. - EventJobSucceeded EventKind = "job_succeeded" - // EventJobFailed identifies terminal logical workflow job failure. - EventJobFailed EventKind = "job_failed" - // EventChainStarted identifies creation of a chain workflow. - EventChainStarted EventKind = "chain_started" - // EventChainAdvanced identifies committed advancement of a chain workflow. - EventChainAdvanced EventKind = "chain_advanced" - // EventChainCompleted identifies successful completion of a chain workflow. - EventChainCompleted EventKind = "chain_completed" - // EventChainFailed identifies terminal failure of a chain workflow. - EventChainFailed EventKind = "chain_failed" - // EventBatchStarted identifies creation of a batch workflow. - EventBatchStarted EventKind = "batch_started" - // EventBatchProgressed identifies committed progress of a batch workflow. - EventBatchProgressed EventKind = "batch_progressed" - // EventBatchCompleted identifies completion of a batch workflow. - EventBatchCompleted EventKind = "batch_completed" - // EventBatchFailed identifies a failed member of a batch workflow. - EventBatchFailed EventKind = "batch_failed" - // EventBatchCancelled identifies cancellation of a batch workflow. - EventBatchCancelled EventKind = "batch_cancelled" - // EventCallbackStarted identifies the start of an ephemeral callback. - EventCallbackStarted EventKind = "callback_started" - // EventCallbackSucceeded identifies successful completion of an ephemeral callback. - EventCallbackSucceeded EventKind = "callback_succeeded" - // EventCallbackFailed identifies failure of an ephemeral callback. - EventCallbackFailed EventKind = "callback_failed" -) - -// Event carries the legacy bus workflow event shape. -// -// Deprecated: use queue.Event. This shape remains available only at the bus -// compatibility boundary and is translated from the canonical producer. -type Event struct { - SchemaVersion int - EventID string - Kind EventKind - DispatchID string - JobID string - ChainID string - BatchID string - Attempt int - JobType string - JobKey string - Queue string - Duration time.Duration - Time time.Time - Err error -} - -// Observer receives legacy bus workflow events. -// -// Deprecated: use queue.Observer. -type Observer interface { - Observe(ctx context.Context, event Event) -} - -// ObserverFunc adapts a function to Observer. -// -// Deprecated: use queue.ObserverFunc. -type ObserverFunc func(ctx context.Context, event Event) - -// Observe calls the wrapped observer function. -func (f ObserverFunc) Observe(ctx context.Context, event Event) { - f(ctx, event) -} - -// MultiObserver fans out one legacy event while isolating observer panics. -// -// Deprecated: use queue.MultiObserver. -func MultiObserver(observers ...Observer) Observer { - filtered := make([]Observer, 0, len(observers)) - for _, observer := range observers { - if observer != nil { - filtered = append(filtered, observer) - } - } - return multiObserver(filtered) -} - -type multiObserver []Observer - -// Observe forwards the unchanged legacy event to each configured observer. -func (m multiObserver) Observe(ctx context.Context, event Event) { - for _, observer := range m { - safeObserve(ctx, observer, event) - } -} - -// safeObserve prevents optional telemetry from changing workflow execution. -func safeObserve(ctx context.Context, observer Observer, event Event) { - if observer == nil { - return - } - if ctx == nil { - ctx = context.Background() - } - defer func() { - _ = recover() - }() - observer.Observe(ctx, event) -} diff --git a/bus/facade_forwarding_test.go b/bus/facade_forwarding_test.go deleted file mode 100644 index 6c5d9eb..0000000 --- a/bus/facade_forwarding_test.go +++ /dev/null @@ -1,370 +0,0 @@ -package bus_test - -import ( - "context" - "encoding/json" - "errors" - "strings" - "sync/atomic" - "testing" - "time" - - "github.com/goforj/queue" - "github.com/goforj/queue/bus" -) - -type facadeDeferredPayload struct { - calls *atomic.Int32 - value int -} - -// MarshalJSON records when the compatibility facade freezes the referenced payload state. -func (p *facadeDeferredPayload) MarshalJSON() ([]byte, error) { - p.calls.Add(1) - return json.Marshal(struct { - Value int `json:"value"` - }{Value: p.value}) -} - -// TestQueueFacadeForwardsFluentBuildersCallbacksAndPrune exercises the retained facade through one shared root engine. -func TestQueueFacadeForwardsFluentBuildersCallbacksAndPrune(t *testing.T) { - root, err := queue.NewSync() - if err != nil { - t.Fatalf("new sync queue: %v", err) - } - compatibility, err := bus.New(root) - if err != nil { - t.Fatalf("new compatibility facade: %v", err) - } - if err := compatibility.StartWorkers(context.Background()); err != nil { - t.Fatalf("start compatibility workers: %v", err) - } - t.Cleanup(func() { - if shutdownErr := compatibility.Shutdown(context.Background()); shutdownErr != nil { - t.Errorf("shutdown compatibility workers: %v", shutdownErr) - } - }) - - cause := errors.New("compatibility handler failed") - compatibility.Register("compat:facade-fail", func(context.Context, bus.Context) error { - return cause - }) - - var chainCatch atomic.Int32 - var chainFinally atomic.Int32 - chainID, err := compatibility.Chain(bus.NewJob("compat:facade-fail", nil)). - OnQueue("critical"). - Catch(func(_ context.Context, state bus.ChainState, callbackErr error) error { - if state.Queue != "critical" || callbackErr == nil || callbackErr.Error() != cause.Error() { - t.Errorf("chain catch state/error = %+v/%v", state, callbackErr) - } - chainCatch.Add(1) - return nil - }). - Finally(func(_ context.Context, state bus.ChainState) error { - if state.Queue != "critical" || !state.Failed { - t.Errorf("chain finally state = %+v", state) - } - chainFinally.Add(1) - return nil - }). - Dispatch(context.Background()) - if !errors.Is(err, cause) { - t.Fatalf("chain error = %v, want handler cause", err) - } - if chainCatch.Load() != 1 || chainFinally.Load() != 1 { - t.Fatalf("chain callback counts = %d/%d, want 1/1", chainCatch.Load(), chainFinally.Load()) - } - chainState, err := compatibility.FindChain(context.Background(), chainID) - if err != nil { - t.Fatalf("find chain: %v", err) - } - if len(chainState.Nodes) != 1 || chainState.Nodes[0].Job.Options.Queue != "critical" { - t.Fatalf("chain routing state = %+v", chainState) - } - - var batchProgress atomic.Int32 - var batchThen atomic.Int32 - var batchCatch atomic.Int32 - var batchFinally atomic.Int32 - batchID, err := compatibility.Batch(bus.NewJob("compat:facade-fail", nil)). - Name("compatibility batch"). - OnQueue("bulk"). - AllowFailures(). - Progress(func(_ context.Context, state bus.BatchState) error { - if state.Name != "compatibility batch" || state.Queue != "bulk" { - t.Errorf("batch progress state = %+v", state) - } - batchProgress.Add(1) - return nil - }). - Then(func(_ context.Context, state bus.BatchState) error { - if !state.Completed || state.Cancelled { - t.Errorf("batch then state = %+v", state) - } - batchThen.Add(1) - return nil - }). - Catch(func(_ context.Context, state bus.BatchState, callbackErr error) error { - if state.Failed != 1 || callbackErr == nil || callbackErr.Error() != cause.Error() { - t.Errorf("batch catch state/error = %+v/%v", state, callbackErr) - } - batchCatch.Add(1) - return nil - }). - Finally(func(_ context.Context, state bus.BatchState) error { - if !state.Completed || state.Queue != "bulk" { - t.Errorf("batch finally state = %+v", state) - } - batchFinally.Add(1) - return nil - }). - Dispatch(context.Background()) - if !errors.Is(err, cause) { - t.Fatalf("batch error = %v, want handler cause", err) - } - if batchProgress.Load() != 1 || batchThen.Load() != 1 || batchCatch.Load() != 1 || batchFinally.Load() != 1 { - t.Fatalf("batch callback counts = %d/%d/%d/%d, want 1/1/1/1", batchProgress.Load(), batchThen.Load(), batchCatch.Load(), batchFinally.Load()) - } - batchState, err := compatibility.FindBatch(context.Background(), batchID) - if err != nil { - t.Fatalf("find batch: %v", err) - } - if batchState.Name != "compatibility batch" || batchState.Queue != "bulk" || !batchState.AllowFailed || !batchState.Completed || batchState.Cancelled { - t.Fatalf("batch state = %+v", batchState) - } - - if err := compatibility.Prune(context.Background(), time.Now().Add(time.Hour)); err != nil { - t.Fatalf("prune compatibility state: %v", err) - } - if _, err := compatibility.FindChain(context.Background(), chainID); !errors.Is(err, bus.ErrNotFound) { - t.Fatalf("find pruned chain error = %v, want ErrNotFound", err) - } - if _, err := compatibility.FindBatch(context.Background(), batchID); !errors.Is(err, bus.ErrNotFound) { - t.Fatalf("find pruned batch error = %v, want ErrNotFound", err) - } -} - -// TestQueueFacadeDefersLegacyConversionFailuresAcrossFluentBuilders verifies errors remain at the legacy Dispatch boundary. -func TestQueueFacadeDefersLegacyConversionFailuresAcrossFluentBuilders(t *testing.T) { - root, err := queue.NewSync() - if err != nil { - t.Fatalf("new sync queue: %v", err) - } - t.Cleanup(func() { - if shutdownErr := root.Shutdown(context.Background()); shutdownErr != nil { - t.Errorf("shutdown root queue: %v", shutdownErr) - } - }) - compatibility, err := bus.New(root) - if err != nil { - t.Fatalf("new compatibility facade: %v", err) - } - - chainCallbackCalled := false - _, err = compatibility.Chain(bus.Job{}). - OnQueue("critical"). - Catch(func(context.Context, bus.ChainState, error) error { - chainCallbackCalled = true - return nil - }). - Finally(func(context.Context, bus.ChainState) error { - chainCallbackCalled = true - return nil - }). - Dispatch(context.Background()) - if err == nil || err.Error() != "bus job type is required" { - t.Fatalf("chain conversion error = %v, want missing legacy type", err) - } - if chainCallbackCalled { - t.Fatal("deferred chain conversion failure invoked callbacks") - } - - batchCallbackCalled := false - _, err = compatibility.Batch(bus.NewJob("compat:marshal-failure", failingJSONPayload{})). - Name("unreachable"). - OnQueue("bulk"). - AllowFailures(). - Progress(func(context.Context, bus.BatchState) error { - batchCallbackCalled = true - return nil - }). - Then(func(context.Context, bus.BatchState) error { - batchCallbackCalled = true - return nil - }). - Catch(func(context.Context, bus.BatchState, error) error { - batchCallbackCalled = true - return nil - }). - Finally(func(context.Context, bus.BatchState) error { - batchCallbackCalled = true - return nil - }). - Dispatch(context.Background()) - if err == nil || err.Error() != "json: error calling MarshalJSON for type bus_test.failingJSONPayload: compat marshal failure" { - t.Fatalf("batch conversion error = %v, want legacy marshal failure", err) - } - if batchCallbackCalled { - t.Fatal("deferred batch conversion failure invoked callbacks") - } -} - -// TestQueueFacadeDefersBuilderEncodingAndKeepsShallowJobSnapshots freezes the legacy builder timing contract. -func TestQueueFacadeDefersBuilderEncodingAndKeepsShallowJobSnapshots(t *testing.T) { - root, err := queue.NewSync() - if err != nil { - t.Fatalf("new sync queue: %v", err) - } - compatibility, err := bus.New(root) - if err != nil { - t.Fatalf("new compatibility facade: %v", err) - } - if err := compatibility.StartWorkers(context.Background()); err != nil { - t.Fatalf("start compatibility workers: %v", err) - } - t.Cleanup(func() { - if shutdownErr := compatibility.Shutdown(context.Background()); shutdownErr != nil { - t.Errorf("shutdown compatibility workers: %v", shutdownErr) - } - }) - - var chainSeen atomic.Int32 - compatibility.Register("compat:deferred-chain", func(_ context.Context, message bus.Context) error { - var payload struct { - Value int `json:"value"` - } - if bindErr := message.Bind(&payload); bindErr != nil { - return bindErr - } - chainSeen.Store(int32(payload.Value)) - return nil - }) - var batchSeen atomic.Int32 - compatibility.Register("compat:deferred-batch", func(_ context.Context, message bus.Context) error { - var payload struct { - Value int `json:"value"` - } - if bindErr := message.Bind(&payload); bindErr != nil { - return bindErr - } - batchSeen.Store(int32(payload.Value)) - return nil - }) - - var chainMarshalCalls atomic.Int32 - chainPayload := &facadeDeferredPayload{calls: &chainMarshalCalls, value: 1} - chainJobs := []bus.Job{bus.NewJob("compat:deferred-chain", chainPayload).OnQueue("chain-job")} - chainBuilder := compatibility.Chain(chainJobs...).OnQueue("chain-default").Catch(nil).Finally(nil) - if chainMarshalCalls.Load() != 0 { - t.Fatalf("chain marshal calls before Dispatch = %d, want 0", chainMarshalCalls.Load()) - } - chainJobs[0].Type = "compat:mutated-chain" - chainJobs[0].Options.Queue = "mutated-chain-job" - chainPayload.value = 2 - chainID, err := chainBuilder.Dispatch(context.Background()) - if err != nil { - t.Fatalf("dispatch deferred chain: %v", err) - } - if chainMarshalCalls.Load() != 1 || chainSeen.Load() != 2 { - t.Fatalf("chain marshal calls/payload = %d/%d, want 1/2", chainMarshalCalls.Load(), chainSeen.Load()) - } - chainState, err := compatibility.FindChain(context.Background(), chainID) - if err != nil { - t.Fatalf("find deferred chain: %v", err) - } - if len(chainState.Nodes) != 1 || chainState.Nodes[0].Job.Type != "compat:deferred-chain" || chainState.Nodes[0].Job.Options.Queue != "chain-job" { - t.Fatalf("chain shallow snapshot = %+v", chainState) - } - - var batchMarshalCalls atomic.Int32 - batchPayload := &facadeDeferredPayload{calls: &batchMarshalCalls, value: 3} - batchJobs := []bus.Job{bus.NewJob("compat:deferred-batch", batchPayload).OnQueue("batch-job")} - batchBuilder := compatibility.Batch(batchJobs...).Name("deferred batch").OnQueue("batch-default").AllowFailures(). - Progress(nil).Then(nil).Catch(nil).Finally(nil) - if batchMarshalCalls.Load() != 0 { - t.Fatalf("batch marshal calls before Dispatch = %d, want 0", batchMarshalCalls.Load()) - } - batchJobs[0].Type = "compat:mutated-batch" - batchJobs[0].Options.Queue = "mutated-batch-job" - batchPayload.value = 4 - batchID, err := batchBuilder.Dispatch(context.Background()) - if err != nil { - t.Fatalf("dispatch deferred batch: %v", err) - } - if batchMarshalCalls.Load() != 1 || batchSeen.Load() != 4 { - t.Fatalf("batch marshal calls/payload = %d/%d, want 1/4", batchMarshalCalls.Load(), batchSeen.Load()) - } - batchState, err := compatibility.FindBatch(context.Background(), batchID) - if err != nil { - t.Fatalf("find deferred batch: %v", err) - } - if batchState.Name != "deferred batch" || batchState.Queue != "batch-default" || !batchState.AllowFailed || !batchState.Completed { - t.Fatalf("deferred batch state = %+v", batchState) - } -} - -// TestRawRuntimeFacadeForwardsNilCallbacksAndShutdown covers the retained low-level compatibility lifecycle. -func TestRawRuntimeFacadeForwardsNilCallbacksAndShutdown(t *testing.T) { - runtime, err := newBusTestRuntime(queue.Config{Driver: queue.DriverSync}) - if err != nil { - t.Fatalf("new raw sync runtime: %v", err) - } - compatibility, err := bus.New(runtime) - if err != nil { - t.Fatalf("new raw compatibility facade: %v", err) - } - t.Cleanup(func() { - _ = compatibility.Shutdown(context.Background()) - }) - compatibility.Register("compat:nil-handler", nil) - compatibility.Register("compat:raw-success", func(context.Context, bus.Context) error { return nil }) - if err := compatibility.StartWorkers(context.Background()); err != nil { - t.Fatalf("start raw compatibility workers: %v", err) - } - if _, err := compatibility.Dispatch(context.Background(), bus.NewJob("compat:nil-handler", nil)); err == nil { - t.Fatal("nil compatibility registration accepted a delivery") - } else if !strings.Contains(err.Error(), "handler not registered") { - t.Fatalf("nil compatibility dispatch error = %v, want missing handler", err) - } - - chainID, err := compatibility.Chain(bus.NewJob("compat:raw-success", nil)). - OnQueue("raw-chain"). - Catch(nil). - Finally(nil). - Dispatch(context.Background()) - if err != nil { - t.Fatalf("dispatch raw chain: %v", err) - } - chainState, err := compatibility.FindChain(context.Background(), chainID) - if err != nil { - t.Fatalf("find raw chain: %v", err) - } - if chainState.Queue != "raw-chain" || !chainState.Completed { - t.Fatalf("raw chain state = %+v", chainState) - } - - batchID, err := compatibility.Batch(bus.NewJob("compat:raw-success", nil)). - Name("raw batch"). - OnQueue("raw-batch"). - AllowFailures(). - Progress(nil). - Then(nil). - Catch(nil). - Finally(nil). - Dispatch(context.Background()) - if err != nil { - t.Fatalf("dispatch raw batch: %v", err) - } - batchState, err := compatibility.FindBatch(context.Background(), batchID) - if err != nil { - t.Fatalf("find raw batch: %v", err) - } - if batchState.Name != "raw batch" || batchState.Queue != "raw-batch" || !batchState.AllowFailed || !batchState.Completed { - t.Fatalf("raw batch state = %+v", batchState) - } - - if err := compatibility.Shutdown(context.Background()); err != nil { - t.Fatalf("shutdown raw compatibility workers: %v", err) - } -} diff --git a/bus/fake.go b/bus/fake.go deleted file mode 100644 index 356658e..0000000 --- a/bus/fake.go +++ /dev/null @@ -1,216 +0,0 @@ -package bus - -import ( - "context" - "sync" - "testing" - "time" - - "github.com/goforj/queue" -) - -// Fake preserves the legacy bus testing surface as a thin view of the -// concurrency-safe root queue fake. -// -// Deprecated: use queue.NewFake. -type Fake struct { - queue *queue.FakeQueue -} - -var _ Bus = (*Fake)(nil) - -var fakeInitializationMu sync.Mutex - -// NewFake creates a legacy workflow view over one canonical root fake. -// @group Constructors -// -// Example: new bus fake -// -// fake := bus.NewFake() -// _, _ = fake.Dispatch(context.Background(), bus.NewJob("monitor:poll", nil)) -func NewFake() *Fake { - return &Fake{queue: queue.NewFake()} -} - -// Queue returns the canonical root fake shared by this compatibility view. -// @group Testing -func (f *Fake) Queue() *queue.FakeQueue { - return f.canonicalQueue() -} - -// canonicalQueue lazily initializes the historical zero value under a package -// lock while keeping Fake values safe to copy after construction. -func (f *Fake) canonicalQueue() *queue.FakeQueue { - fakeInitializationMu.Lock() - defer fakeInitializationMu.Unlock() - if f.queue == nil { - f.queue = queue.NewFake() - } - return f.queue -} - -// Register is inert because Fake records accepted intent instead of executing handlers. -func (f *Fake) Register(string, Handler) {} - -// Dispatch converts and records a legacy job through the canonical fake. -// @group Testing -// -// Example: record dispatch -// -// fake := bus.NewFake() -// _, _ = fake.Dispatch(context.Background(), bus.NewJob("emails:send", nil)) -func (f *Fake) Dispatch(ctx context.Context, job Job) (DispatchResult, error) { - converted, err := toQueueJob(job) - if err != nil { - return DispatchResult{}, err - } - if err := f.canonicalQueue().WithContext(ctx).Dispatch(converted); err != nil { - return DispatchResult{}, err - } - return DispatchResult{DispatchID: "fake"}, nil -} - -// Chain snapshots legacy jobs and delegates execution-time conversion to the -// same compatibility builder used by a production queue facade. -// @group Testing -func (f *Fake) Chain(jobs ...Job) ChainBuilder { - return &queueChainBuilder{ - queue: f.canonicalQueue(), - jobs: append([]Job(nil), jobs...), - } -} - -// Batch snapshots legacy jobs and delegates execution-time conversion to the -// same compatibility builder used by a production queue facade. -// @group Testing -func (f *Fake) Batch(jobs ...Job) BatchBuilder { - return &queueBatchBuilder{ - queue: f.canonicalQueue(), - jobs: append([]Job(nil), jobs...), - } -} - -// StartWorkers delegates the inert lifecycle contract to the canonical fake. -func (f *Fake) StartWorkers(ctx context.Context) error { - if f == nil { - return nil - } - return f.canonicalQueue().StartWorkers(ctx) -} - -// Shutdown delegates the inert lifecycle contract to the canonical fake. -func (f *Fake) Shutdown(ctx context.Context) error { - if f == nil { - return nil - } - return f.canonicalQueue().Shutdown(ctx) -} - -// FindBatch returns state created by an accepted fake batch. -func (f *Fake) FindBatch(ctx context.Context, batchID string) (BatchState, error) { - if f == nil { - return BatchState{}, ErrNotFound - } - return f.canonicalQueue().FindBatch(ctx, batchID) -} - -// FindChain returns state created by an accepted fake chain. -func (f *Fake) FindChain(ctx context.Context, chainID string) (ChainState, error) { - if f == nil { - return ChainState{}, ErrNotFound - } - return f.canonicalQueue().FindChain(ctx, chainID) -} - -// Prune applies workflow retention to the canonical fake store. -func (f *Fake) Prune(ctx context.Context, before time.Time) error { - if f == nil { - return nil - } - return f.canonicalQueue().Prune(ctx, before) -} - -// AssertNothingDispatched fails if any direct job was accepted. -// @group Testing -func (f *Fake) AssertNothingDispatched(t testing.TB) { - t.Helper() - f.canonicalQueue().AssertNothingDispatched(t) -} - -// AssertDispatched fails if the given job type was never accepted. -// @group Testing -func (f *Fake) AssertDispatched(t testing.TB, jobType string) { - t.Helper() - f.canonicalQueue().AssertDispatched(t, jobType) -} - -// AssertDispatchedTimes fails if the accepted count for jobType does not match n. -// @group Testing -func (f *Fake) AssertDispatchedTimes(t testing.TB, jobType string, n int) { - t.Helper() - f.canonicalQueue().AssertDispatchedTimes(t, jobType, n) -} - -// AssertNotDispatched fails if the given job type was accepted. -// @group Testing -func (f *Fake) AssertNotDispatched(t testing.TB, jobType string) { - t.Helper() - f.canonicalQueue().AssertNotDispatched(t, jobType) -} - -// AssertCount fails if the total direct dispatch count does not match n. -// @group Testing -func (f *Fake) AssertCount(t testing.TB, n int) { - t.Helper() - f.canonicalQueue().AssertCount(t, n) -} - -// AssertDispatchedOn fails if a job type was not accepted on queueName. -// @group Testing -func (f *Fake) AssertDispatchedOn(t testing.TB, queueName, jobType string) { - t.Helper() - f.canonicalQueue().AssertDispatchedOn(t, queueName, jobType) -} - -// AssertChained fails if no accepted chain matches the expected job order. -// @group Testing -func (f *Fake) AssertChained(t testing.TB, expected []string) { - t.Helper() - f.canonicalQueue().AssertChained(t, expected) -} - -// AssertBatchCount fails if the accepted batch count does not match n. -// @group Testing -func (f *Fake) AssertBatchCount(t testing.TB, n int) { - t.Helper() - f.canonicalQueue().AssertBatchCount(t, n) -} - -// AssertNothingBatched fails if any batch was accepted. -// @group Testing -func (f *Fake) AssertNothingBatched(t testing.TB) { - t.Helper() - f.canonicalQueue().AssertNothingBatched(t) -} - -// AssertBatched fails unless one canonical batch matches the legacy projection. -// The predicate runs outside recorder locks. -// @group Testing -func (f *Fake) AssertBatched(t testing.TB, predicate func(spec BatchSpec) bool) { - t.Helper() - for _, record := range f.canonicalQueue().BatchRecords() { - spec := BatchSpec{JobTypes: make([]string, 0, len(record.Jobs))} - for _, job := range record.Jobs { - spec.JobTypes = append(spec.JobTypes, job.Job.Type) - } - if predicate(spec) { - return - } - } - t.Fatalf("expected at least one batch to match predicate") -} - -// BatchSpec is the frozen assertion projection retained for legacy source compatibility. -type BatchSpec struct { - JobTypes []string -} diff --git a/bus/fake_test.go b/bus/fake_test.go deleted file mode 100644 index 12d17a2..0000000 --- a/bus/fake_test.go +++ /dev/null @@ -1,362 +0,0 @@ -package bus_test - -import ( - "context" - "errors" - "sync" - "testing" - "time" - - "github.com/goforj/queue" - "github.com/goforj/queue/bus" -) - -// fakeDeferredPayload proves legacy builders retain Dispatch-time JSON encoding. -type fakeDeferredPayload struct { - Value int `json:"value"` -} - -func TestFakeAssertions(t *testing.T) { - f := bus.NewFake() - f.AssertNothingDispatched(t) - f.AssertNothingBatched(t) - f.AssertBatchCount(t, 0) - - _, _ = f.Dispatch(context.Background(), bus.NewJob("monitor:poll", nil)) - _, _ = f.Dispatch(context.Background(), bus.Job{ - Type: "monitor:poll", - Options: bus.JobOptions{ - Queue: "monitor-critical", - }, - }) - _, _ = f.Dispatch(context.Background(), bus.NewJob("monitor:alert", nil)) - - f.AssertCount(t, 3) - f.AssertDispatched(t, "monitor:poll") - f.AssertDispatchedOn(t, "monitor-critical", "monitor:poll") - f.AssertDispatchedTimes(t, "monitor:poll", 2) - f.AssertNotDispatched(t, "monitor:downsample") - - _, _ = f.Chain( - bus.NewJob("monitor:poll", nil), - bus.NewJob("monitor:downsample", nil), - bus.NewJob("monitor:alert", nil), - ).Dispatch(context.Background()) - f.AssertChained(t, []string{"monitor:poll", "monitor:downsample", "monitor:alert"}) - - _, _ = f.Batch( - bus.NewJob("monitor:poll", nil), - bus.NewJob("monitor:downsample", nil), - ).Dispatch(context.Background()) - f.AssertBatchCount(t, 1) - f.AssertBatched(t, func(spec bus.BatchSpec) bool { - return len(spec.JobTypes) == 2 && spec.JobTypes[0] == "monitor:poll" && spec.JobTypes[1] == "monitor:downsample" - }) -} - -// TestFakeZeroValueSharesOneConcurrentRecorder preserves the historical usable -// zero value without allowing concurrent callers to initialize separate state. -func TestFakeZeroValueSharesOneConcurrentRecorder(t *testing.T) { - var fake bus.Fake - queues := make(chan *queue.FakeQueue, 16) - var wait sync.WaitGroup - for i := 0; i < cap(queues); i++ { - wait.Add(1) - go func() { - defer wait.Done() - queues <- fake.Queue() - }() - } - wait.Wait() - close(queues) - var first *queue.FakeQueue - for candidate := range queues { - if first == nil { - first = candidate - continue - } - if candidate != first { - t.Fatal("zero-value Fake initialized more than one canonical queue") - } - } - copied := fake - if copied.Queue() != fake.Queue() { - t.Fatal("copy of initialized Fake lost canonical queue identity") - } - if _, err := fake.Dispatch(context.Background(), bus.NewJob("zero:direct", nil)); err != nil { - t.Fatalf("zero-value direct dispatch: %v", err) - } - if _, err := fake.Chain(bus.NewJob("zero:chain", nil)).Dispatch(context.Background()); err != nil { - t.Fatalf("zero-value chain dispatch: %v", err) - } - if _, err := fake.Batch(bus.NewJob("zero:batch", nil)).Dispatch(context.Background()); err != nil { - t.Fatalf("zero-value batch dispatch: %v", err) - } - fake.AssertDispatched(t, "zero:direct") - fake.AssertChained(t, []string{"zero:chain"}) - fake.AssertBatchCount(t, 1) -} - -func TestFakeFindNotFound(t *testing.T) { - f := bus.NewFake() - _, err := f.FindChain(context.Background(), "missing") - if !errors.Is(err, bus.ErrNotFound) { - t.Fatalf("expected ErrNotFound for chain lookup, got %v", err) - } - _, err = f.FindBatch(context.Background(), "missing") - if !errors.Is(err, bus.ErrNotFound) { - t.Fatalf("expected ErrNotFound for batch lookup, got %v", err) - } -} - -// TestFakeNilReceiverLifecycleCompatibility preserves the historical inert -// lifecycle and missing-state behavior on a nil legacy fake pointer. -func TestFakeNilReceiverLifecycleCompatibility(t *testing.T) { - var fake *bus.Fake - if err := fake.StartWorkers(context.Background()); err != nil { - t.Fatalf("nil StartWorkers: %v", err) - } - if err := fake.Shutdown(context.Background()); err != nil { - t.Fatalf("nil Shutdown: %v", err) - } - if err := fake.Prune(context.Background(), time.Now()); err != nil { - t.Fatalf("nil Prune: %v", err) - } - if _, err := fake.FindChain(context.Background(), "missing"); !errors.Is(err, bus.ErrNotFound) { - t.Fatalf("nil FindChain error = %v, want ErrNotFound", err) - } - if _, err := fake.FindBatch(context.Background(), "missing"); !errors.Is(err, bus.ErrNotFound) { - t.Fatalf("nil FindBatch error = %v, want ErrNotFound", err) - } -} - -// TestFakePrunePreservesActiveWorkflow verifies the compatibility method uses -// canonical retention semantics rather than an unrelated fake no-op. -func TestFakePrunePreservesActiveWorkflow(t *testing.T) { - f := bus.NewFake() - chainID, err := f.Chain(bus.NewJob("prune:active", nil)).Dispatch(context.Background()) - if err != nil { - t.Fatalf("dispatch active chain: %v", err) - } - if err := f.Prune(context.Background(), time.Now().Add(time.Hour)); err != nil { - t.Fatalf("prune active fake state: %v", err) - } - if _, err := f.FindChain(context.Background(), chainID); err != nil { - t.Fatalf("active chain was pruned: %v", err) - } -} - -func TestFakeRuntimeNoopAndFluentBuilders(t *testing.T) { - f := bus.NewFake() - - // No-op runtime methods should be callable. - f.Register("monitor:noop", func(context.Context, bus.Context) error { return nil }) - if err := f.StartWorkers(context.Background()); err != nil { - t.Fatalf("start workers noop failed: %v", err) - } - if err := f.Shutdown(context.Background()); err != nil { - t.Fatalf("shutdown noop failed: %v", err) - } - - // Chain fluent methods should be callable and keep chain dispatch functional. - chainID, err := f.Chain(bus.NewJob("a", nil)). - OnQueue("critical"). - Catch(func(context.Context, bus.ChainState, error) error { return nil }). - Finally(func(context.Context, bus.ChainState) error { return nil }). - Dispatch(context.Background()) - if err != nil { - t.Fatalf("chain dispatch failed: %v", err) - } - if chainID == "" { - t.Fatal("expected chain id") - } - - // Batch fluent methods should be callable and keep batch dispatch functional. - batchID, err := f.Batch(bus.NewJob("a", nil)). - Name("nightly"). - OnQueue("critical"). - AllowFailures(). - Progress(func(context.Context, bus.BatchState) error { return nil }). - Then(func(context.Context, bus.BatchState) error { return nil }). - Catch(func(context.Context, bus.BatchState, error) error { return nil }). - Finally(func(context.Context, bus.BatchState) error { return nil }). - Dispatch(context.Background()) - if err != nil { - t.Fatalf("batch dispatch failed: %v", err) - } - if batchID == "" { - t.Fatal("expected batch id") - } -} - -// TestFakeSharesCanonicalRootState verifies the legacy surface is only a typed -// conversion and assertion view over queue.FakeQueue. -func TestFakeSharesCanonicalRootState(t *testing.T) { - fake := bus.NewFake() - root := fake.Queue() - if root == nil { - t.Fatal("Queue returned nil canonical fake") - } - if err := root.Dispatch(queue.NewJob("root:dispatch").OnQueue("root")); err != nil { - t.Fatalf("root dispatch: %v", err) - } - result, err := fake.Dispatch(context.Background(), bus.NewJob("bus:dispatch", nil).OnQueue("legacy")) - if err != nil { - t.Fatalf("bus dispatch: %v", err) - } - if result.DispatchID != "fake" { - t.Fatalf("legacy direct fake ID = %q, want fake", result.DispatchID) - } - - fake.AssertDispatched(t, "root:dispatch") - root.AssertDispatched(t, "bus:dispatch") - if got := len(root.Records()); got != 2 { - t.Fatalf("shared direct records = %d, want 2", got) - } -} - -// TestFakeBuildersUseProductionTimingAndOptions verifies deferred legacy -// encoding and fluent workflow policy survive the compatibility adapter. -func TestFakeBuildersUseProductionTimingAndOptions(t *testing.T) { - fake := bus.NewFake() - payload := &fakeDeferredPayload{Value: 1} - chainBuilder := fake.Chain( - bus.NewJob("chain:first", payload), - bus.NewJob("chain:second", nil).OnQueue("dedicated"), - ).OnQueue("chain-default") - batchBuilder := fake.Batch( - bus.NewJob("batch:first", payload), - bus.NewJob("batch:second", nil).OnQueue("priority"), - ).Name("compatibility batch").OnQueue("batch-default").AllowFailures() - if len(fake.Queue().ChainRecords()) != 0 || len(fake.Queue().BatchRecords()) != 0 { - t.Fatal("builder construction recorded workflow state") - } - payload.Value = 2 - - chainID, err := chainBuilder.Dispatch(context.Background()) - if err != nil { - t.Fatalf("dispatch chain: %v", err) - } - batchID, err := batchBuilder.Dispatch(context.Background()) - if err != nil { - t.Fatalf("dispatch batch: %v", err) - } - chains := fake.Queue().ChainRecords() - if len(chains) != 1 || chains[0].ChainID != chainID || chains[0].Queue != "chain-default" { - t.Fatalf("chain record = %+v", chains) - } - if got := string(chains[0].Nodes[0].Job.Payload); got != `{"value":2}` { - t.Fatalf("deferred chain payload = %q", got) - } - if chains[0].Nodes[0].Job.Options.Queue != "chain-default" || chains[0].Nodes[1].Job.Options.Queue != "dedicated" { - t.Fatalf("chain queue precedence = %+v", chains[0].Nodes) - } - batches := fake.Queue().BatchRecords() - if len(batches) != 1 || batches[0].BatchID != batchID || batches[0].Name != "compatibility batch" || !batches[0].AllowFailed { - t.Fatalf("batch record = %+v", batches) - } - if batches[0].Jobs[0].Job.Options.Queue != "batch-default" || batches[0].Jobs[1].Job.Options.Queue != "priority" { - t.Fatalf("batch queue precedence = %+v", batches[0].Jobs) - } - if _, err := fake.FindChain(context.Background(), chainID); err != nil { - t.Fatalf("find accepted chain: %v", err) - } - if state, err := fake.FindBatch(context.Background(), batchID); err != nil || state.Total != 2 { - t.Fatalf("find accepted batch = %+v, %v", state, err) - } -} - -// TestFakeRejectedBuildersRemainInvisible verifies validation and context -// failures do not create false-positive legacy assertions. -func TestFakeRejectedBuildersRemainInvisible(t *testing.T) { - fake := bus.NewFake() - _ = fake.Chain(bus.NewJob("abandoned", nil)) - _ = fake.Batch(bus.NewJob("abandoned", nil)) - if _, err := fake.Dispatch(context.Background(), bus.NewJob("", nil)); err == nil { - t.Fatal("empty direct type error = nil") - } - if _, err := fake.Dispatch(context.Background(), bus.NewJob("bad:direct-payload", failingJSONPayload{})); err == nil { - t.Fatal("invalid direct payload error = nil") - } - if _, err := fake.Chain().Dispatch(context.Background()); err == nil { - t.Fatal("empty chain error = nil") - } - if _, err := fake.Batch().Dispatch(context.Background()); err == nil { - t.Fatal("empty batch error = nil") - } - if _, err := fake.Chain(bus.NewJob("", nil)).Dispatch(context.Background()); err == nil { - t.Fatal("empty chain member type error = nil") - } - if _, err := fake.Batch(bus.NewJob("bad:retry", nil).Retry(-1)).Dispatch(context.Background()); err == nil { - t.Fatal("invalid batch retry error = nil") - } - if _, err := fake.Chain(bus.NewJob("bad:chain-payload", failingJSONPayload{})).Dispatch(context.Background()); err == nil { - t.Fatal("invalid chain payload error = nil") - } - if _, err := fake.Batch(bus.NewJob("bad:batch-payload", failingJSONPayload{})).Dispatch(context.Background()); err == nil { - t.Fatal("invalid batch payload error = nil") - } - canceled, cancel := context.WithCancel(context.Background()) - cancel() - callbackCalled := false - if _, err := fake.Dispatch(canceled, bus.NewJob("cancelled:direct", nil)); !errors.Is(err, context.Canceled) { - t.Fatalf("canceled direct error = %v", err) - } - if _, err := fake.Chain(bus.NewJob("cancelled:chain", nil)). - Catch(func(context.Context, bus.ChainState, error) error { callbackCalled = true; return nil }). - Finally(func(context.Context, bus.ChainState) error { callbackCalled = true; return nil }). - Dispatch(canceled); !errors.Is(err, context.Canceled) { - t.Fatalf("canceled chain error = %v", err) - } - if _, err := fake.Batch(bus.NewJob("cancelled:batch", nil)). - Progress(func(context.Context, bus.BatchState) error { callbackCalled = true; return nil }). - Then(func(context.Context, bus.BatchState) error { callbackCalled = true; return nil }). - Catch(func(context.Context, bus.BatchState, error) error { callbackCalled = true; return nil }). - Finally(func(context.Context, bus.BatchState) error { callbackCalled = true; return nil }). - Dispatch(canceled); !errors.Is(err, context.Canceled) { - t.Fatalf("canceled batch error = %v", err) - } - if callbackCalled { - t.Fatal("recording fake invoked a workflow callback") - } - if len(fake.Queue().Records()) != 0 || len(fake.Queue().ChainRecords()) != 0 || len(fake.Queue().BatchRecords()) != 0 { - t.Fatalf("rejected records = direct:%d chains:%d batches:%d", len(fake.Queue().Records()), len(fake.Queue().ChainRecords()), len(fake.Queue().BatchRecords())) - } -} - -// TestFakeConcurrentCompatibilityViews exercises legacy conversion and shared -// root inspection under the race detector. -func TestFakeConcurrentCompatibilityViews(t *testing.T) { - fake := bus.NewFake() - var wait sync.WaitGroup - for worker := 0; worker < 9; worker++ { - worker := worker - wait.Add(1) - go func() { - defer wait.Done() - for iteration := 0; iteration < 30; iteration++ { - switch worker % 3 { - case 0: - _, _ = fake.Dispatch(context.Background(), bus.NewJob("direct:legacy", map[string]int{"iteration": iteration})) - case 1: - _, _ = fake.Chain(bus.NewJob("chain:legacy", iteration)).Dispatch(context.Background()) - case 2: - _, _ = fake.Batch(bus.NewJob("batch:legacy", iteration)).Dispatch(context.Background()) - } - _ = fake.Queue().Records() - _ = fake.Queue().ChainRecords() - _ = fake.Queue().BatchRecords() - } - }() - } - wait.Wait() - if got := len(fake.Queue().Records()); got != 90 { - t.Fatalf("concurrent direct records = %d, want 90", got) - } - if got := len(fake.Queue().ChainRecords()); got != 90 { - t.Fatalf("concurrent chain records = %d, want 90", got) - } - if got := len(fake.Queue().BatchRecords()); got != 90 { - t.Fatalf("concurrent batch records = %d, want 90", got) - } -} diff --git a/bus/job_options_test.go b/bus/job_options_test.go deleted file mode 100644 index 0c90f83..0000000 --- a/bus/job_options_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package bus_test - -import ( - "testing" - "time" - - "github.com/goforj/queue/bus" -) - -func TestJobFluentOptions(t *testing.T) { - base := bus.NewJob("monitor:poll", map[string]string{"url": "https://goforj.dev/health"}) - job := base. - OnQueue("monitor-critical"). - Delay(2 * time.Second). - Timeout(10 * time.Second). - Retry(5). - Backoff(500 * time.Millisecond). - UniqueFor(30 * time.Second) - - if base.Options.Queue != "" { - t.Fatalf("expected base job unchanged, got %+v", base.Options) - } - if job.Options.Queue != "monitor-critical" { - t.Fatalf("expected queue monitor-critical, got %q", job.Options.Queue) - } - if job.Options.Delay != 2*time.Second { - t.Fatalf("expected delay 2s, got %v", job.Options.Delay) - } - if job.Options.Timeout != 10*time.Second { - t.Fatalf("expected timeout 10s, got %v", job.Options.Timeout) - } - if job.Options.Retry != 5 { - t.Fatalf("expected retry 5, got %d", job.Options.Retry) - } - if job.Options.Backoff != 500*time.Millisecond { - t.Fatalf("expected backoff 500ms, got %v", job.Options.Backoff) - } - if job.Options.UniqueFor != 30*time.Second { - t.Fatalf("expected unique_for 30s, got %v", job.Options.UniqueFor) - } -} diff --git a/bus/middleware.go b/bus/middleware.go deleted file mode 100644 index 6736beb..0000000 --- a/bus/middleware.go +++ /dev/null @@ -1,73 +0,0 @@ -package bus - -import "github.com/goforj/queue" - -// Next invokes the next workflow middleware or handler. -// -// Deprecated: use queue.Next. -type Next = queue.Next - -// Middleware intercepts workflow job execution. -// -// Deprecated: use queue.Middleware. -type Middleware = queue.Middleware - -// MiddlewareFunc adapts a function to workflow middleware. -// -// Deprecated: use queue.MiddlewareFunc. -type MiddlewareFunc = queue.MiddlewareFunc - -// RetryPolicy is the legacy pass-through retry policy helper. -// -// Deprecated: use queue.RetryPolicy. -type RetryPolicy = queue.RetryPolicy - -// SkipWhen skips execution when its predicate matches. -// -// Deprecated: use queue.SkipWhen. -type SkipWhen = queue.SkipWhen - -// FailOnError converts matched errors into terminal failures. -// -// Deprecated: use queue.FailOnError. -type FailOnError = queue.FailOnError - -// RateLimiter decides whether a keyed workflow job may execute. -// -// Deprecated: use queue.RateLimiter. -type RateLimiter = queue.RateLimiter - -// RateLimit applies rate limiting before workflow job execution. -// -// Deprecated: use queue.RateLimit. -type RateLimit = queue.RateLimit - -// Lock is released after overlap-protected execution completes. -// -// Deprecated: use queue.Lock. -type Lock = queue.Lock - -// Locker acquires locks used to prevent overlapping execution. -// -// Deprecated: use queue.Locker. -type Locker = queue.Locker - -// WithoutOverlapping prevents concurrent execution for one key. -// -// Deprecated: use queue.WithoutOverlapping. -type WithoutOverlapping = queue.WithoutOverlapping - -var ( - // ErrSkipped indicates middleware intentionally skipped workflow job execution. - // - // Deprecated: use queue.ErrSkipped. - ErrSkipped = queue.ErrSkipped - // ErrRateLimited indicates middleware denied workflow job execution under its current rate limit. - // - // Deprecated: use queue.ErrRateLimited. - ErrRateLimited = queue.ErrRateLimited - // ErrOverlapping indicates middleware prevented overlapping workflow job execution. - // - // Deprecated: use queue.ErrOverlapping. - ErrOverlapping = queue.ErrOverlapping -) diff --git a/bus/middleware_test.go b/bus/middleware_test.go deleted file mode 100644 index 4e1d85b..0000000 --- a/bus/middleware_test.go +++ /dev/null @@ -1,353 +0,0 @@ -package bus_test - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/goforj/queue" - "github.com/goforj/queue/bus" -) - -func TestMiddlewareOrder(t *testing.T) { - q, err := newBusTestRuntime(queue.Config{Driver: queue.DriverSync}) - if err != nil { - t.Fatalf("new sync queue: %v", err) - } - order := make([]string, 0, 4) - m1 := bus.MiddlewareFunc(func(ctx context.Context, jc bus.Context, next bus.Next) error { - order = append(order, "m1-before") - err := next(ctx, jc) - order = append(order, "m1-after") - return err - }) - m2 := bus.MiddlewareFunc(func(ctx context.Context, jc bus.Context, next bus.Next) error { - order = append(order, "m2-before") - err := next(ctx, jc) - order = append(order, "m2-after") - return err - }) - b, err := bus.New(q, bus.WithMiddleware(m1, m2)) - if err != nil { - t.Fatalf("new bus: %v", err) - } - if err := b.StartWorkers(context.Background()); err != nil { - t.Fatalf("start workers: %v", err) - } - b.Register("monitor:poll", func(context.Context, bus.Context) error { - order = append(order, "handler") - return nil - }) - if _, err := b.Dispatch(context.Background(), bus.NewJob("monitor:poll", nil)); err != nil { - t.Fatalf("dispatch: %v", err) - } - expected := []string{"m1-before", "m2-before", "handler", "m2-after", "m1-after"} - if len(order) != len(expected) { - t.Fatalf("unexpected order length: got %v want %v", order, expected) - } - for i := range expected { - if order[i] != expected[i] { - t.Fatalf("unexpected order[%d]=%q want %q full=%v", i, order[i], expected[i], order) - } - } -} - -func TestSkipWhenMiddlewareSkipsHandler(t *testing.T) { - q, err := newBusTestRuntime(queue.Config{Driver: queue.DriverSync}) - if err != nil { - t.Fatalf("new sync queue: %v", err) - } - var called bool - b, err := bus.New(q, bus.WithMiddleware(bus.SkipWhen{ - Predicate: func(context.Context, bus.Context) bool { return true }, - })) - if err != nil { - t.Fatalf("new bus: %v", err) - } - if err := b.StartWorkers(context.Background()); err != nil { - t.Fatalf("start workers: %v", err) - } - b.Register("monitor:poll", func(context.Context, bus.Context) error { - called = true - return nil - }) - if _, err := b.Dispatch(context.Background(), bus.NewJob("monitor:poll", nil)); err != nil { - t.Fatalf("dispatch: %v", err) - } - if called { - t.Fatal("expected handler not called") - } -} - -// TestFailOnErrorMarksPermanent verifies middleware uses the shared terminal-error contract and stops physical retries. -func TestFailOnErrorMarksPermanent(t *testing.T) { - q, err := newBusTestRuntime(queue.Config{Driver: queue.DriverSync}) - if err != nil { - t.Fatalf("new sync queue: %v", err) - } - b, err := bus.New(q, bus.WithMiddleware(bus.FailOnError{})) - if err != nil { - t.Fatalf("new bus: %v", err) - } - if err := b.StartWorkers(context.Background()); err != nil { - t.Fatalf("start workers: %v", err) - } - cause := errors.New("boom") - calls := 0 - b.Register("monitor:poll", func(context.Context, bus.Context) error { - calls++ - return cause - }) - _, err = b.Dispatch(context.Background(), bus.NewJob("monitor:poll", nil).Retry(4)) - if err == nil { - t.Fatal("expected error") - } - if !queue.IsPermanent(err) || !errors.Is(err, cause) { - t.Fatalf("expected permanent error preserving cause, got %v", err) - } - if err.Error() != "fatal bus error: boom" { - t.Fatalf("error text = %q, want compatibility prefix", err.Error()) - } - if calls != 1 { - t.Fatalf("handler calls = %d, want 1", calls) - } -} - -type stubRateLimiter struct { - allowed bool - err error - keySeen string -} - -func (s *stubRateLimiter) Allow(_ context.Context, key string) (bool, time.Duration, error) { - s.keySeen = key - if s.err != nil { - return false, 0, s.err - } - return s.allowed, 0, nil -} - -func TestRateLimitDeniedPreventsHandler(t *testing.T) { - q, err := newBusTestRuntime(queue.Config{Driver: queue.DriverSync}) - if err != nil { - t.Fatalf("new sync queue: %v", err) - } - limiter := &stubRateLimiter{allowed: false} - var called bool - b, err := bus.New(q, bus.WithMiddleware(bus.RateLimit{ - Limiter: limiter, - Key: func(context.Context, bus.Context) string { - return "monitor:bucket" - }, - })) - if err != nil { - t.Fatalf("new bus: %v", err) - } - if err := b.StartWorkers(context.Background()); err != nil { - t.Fatalf("start workers: %v", err) - } - b.Register("monitor:poll", func(context.Context, bus.Context) error { - called = true - return nil - }) - if _, err := b.Dispatch(context.Background(), bus.NewJob("monitor:poll", nil)); !errors.Is(err, bus.ErrRateLimited) { - t.Fatalf("expected ErrRateLimited, got %v", err) - } - if called { - t.Fatal("expected handler not called when rate-limited") - } - if limiter.keySeen != "monitor:bucket" { - t.Fatalf("expected limiter key monitor:bucket, got %q", limiter.keySeen) - } -} - -func TestRateLimitLimiterErrorPropagates(t *testing.T) { - q, err := newBusTestRuntime(queue.Config{Driver: queue.DriverSync}) - if err != nil { - t.Fatalf("new sync queue: %v", err) - } - limiter := &stubRateLimiter{err: errors.New("limiter unavailable")} - b, err := bus.New(q, bus.WithMiddleware(bus.RateLimit{ - Limiter: limiter, - })) - if err != nil { - t.Fatalf("new bus: %v", err) - } - if err := b.StartWorkers(context.Background()); err != nil { - t.Fatalf("start workers: %v", err) - } - b.Register("monitor:poll", func(context.Context, bus.Context) error { return nil }) - if _, err := b.Dispatch(context.Background(), bus.NewJob("monitor:poll", nil)); err == nil { - t.Fatal("expected limiter error") - } -} - -func TestRetryPolicyPassesThrough(t *testing.T) { - q, err := newBusTestRuntime(queue.Config{Driver: queue.DriverSync}) - if err != nil { - t.Fatalf("new sync queue: %v", err) - } - var called bool - b, err := bus.New(q, bus.WithMiddleware(bus.RetryPolicy{})) - if err != nil { - t.Fatalf("new bus: %v", err) - } - if err := b.StartWorkers(context.Background()); err != nil { - t.Fatalf("start workers: %v", err) - } - b.Register("monitor:poll", func(context.Context, bus.Context) error { - called = true - return nil - }) - if _, err := b.Dispatch(context.Background(), bus.NewJob("monitor:poll", nil)); err != nil { - t.Fatalf("dispatch: %v", err) - } - if !called { - t.Fatal("expected handler called") - } -} - -type stubLock struct { - released bool -} - -func (l *stubLock) Release(context.Context) error { - l.released = true - return nil -} - -type stubLocker struct { - ok bool - err error - lock *stubLock - keySeen string - ttlSeen time.Duration -} - -func (s *stubLocker) Acquire(_ context.Context, key string, ttl time.Duration) (bus.Lock, bool, error) { - s.keySeen = key - s.ttlSeen = ttl - if s.err != nil { - return nil, false, s.err - } - if s.lock == nil { - s.lock = &stubLock{} - } - return s.lock, s.ok, nil -} - -func TestWithoutOverlappingDeniedPreventsHandler(t *testing.T) { - q, err := newBusTestRuntime(queue.Config{Driver: queue.DriverSync}) - if err != nil { - t.Fatalf("new sync queue: %v", err) - } - locker := &stubLocker{ok: false} - var called bool - b, err := bus.New(q, bus.WithMiddleware(bus.WithoutOverlapping{ - Locker: locker, - TTL: 30 * time.Second, - Key: func(context.Context, bus.Context) string { - return "monitor:poll:key" - }, - })) - if err != nil { - t.Fatalf("new bus: %v", err) - } - if err := b.StartWorkers(context.Background()); err != nil { - t.Fatalf("start workers: %v", err) - } - b.Register("monitor:poll", func(context.Context, bus.Context) error { - called = true - return nil - }) - if _, err := b.Dispatch(context.Background(), bus.NewJob("monitor:poll", nil)); !errors.Is(err, bus.ErrOverlapping) { - t.Fatalf("expected ErrOverlapping, got %v", err) - } - if called { - t.Fatal("expected handler not called when overlap denied") - } - if locker.keySeen != "monitor:poll:key" { - t.Fatalf("expected locker key monitor:poll:key, got %q", locker.keySeen) - } - if locker.ttlSeen != 30*time.Second { - t.Fatalf("expected ttl 30s, got %v", locker.ttlSeen) - } -} - -func TestWithoutOverlappingAcquireErrorPropagates(t *testing.T) { - q, err := newBusTestRuntime(queue.Config{Driver: queue.DriverSync}) - if err != nil { - t.Fatalf("new sync queue: %v", err) - } - locker := &stubLocker{err: errors.New("locker unavailable")} - b, err := bus.New(q, bus.WithMiddleware(bus.WithoutOverlapping{ - Locker: locker, - })) - if err != nil { - t.Fatalf("new bus: %v", err) - } - if err := b.StartWorkers(context.Background()); err != nil { - t.Fatalf("start workers: %v", err) - } - b.Register("monitor:poll", func(context.Context, bus.Context) error { return nil }) - if _, err := b.Dispatch(context.Background(), bus.NewJob("monitor:poll", nil)); err == nil { - t.Fatal("expected locker acquire error") - } -} - -func TestWithoutOverlappingReleasesLockAfterHandler(t *testing.T) { - q, err := newBusTestRuntime(queue.Config{Driver: queue.DriverSync}) - if err != nil { - t.Fatalf("new sync queue: %v", err) - } - lock := &stubLock{} - locker := &stubLocker{ok: true, lock: lock} - b, err := bus.New(q, bus.WithMiddleware(bus.WithoutOverlapping{ - Locker: locker, - TTL: 5 * time.Second, - })) - if err != nil { - t.Fatalf("new bus: %v", err) - } - if err := b.StartWorkers(context.Background()); err != nil { - t.Fatalf("start workers: %v", err) - } - b.Register("monitor:poll", func(context.Context, bus.Context) error { - return nil - }) - if _, err := b.Dispatch(context.Background(), bus.NewJob("monitor:poll", nil)); err != nil { - t.Fatalf("dispatch: %v", err) - } - if !lock.released { - t.Fatal("expected lock released after handler") - } -} - -func TestWithoutOverlappingReleasesLockAfterHandlerError(t *testing.T) { - q, err := newBusTestRuntime(queue.Config{Driver: queue.DriverSync}) - if err != nil { - t.Fatalf("new sync queue: %v", err) - } - lock := &stubLock{} - locker := &stubLocker{ok: true, lock: lock} - b, err := bus.New(q, bus.WithMiddleware(bus.WithoutOverlapping{ - Locker: locker, - TTL: 5 * time.Second, - })) - if err != nil { - t.Fatalf("new bus: %v", err) - } - if err := b.StartWorkers(context.Background()); err != nil { - t.Fatalf("start workers: %v", err) - } - b.Register("monitor:poll", func(context.Context, bus.Context) error { - return errors.New("handler failed") - }) - if _, err := b.Dispatch(context.Background(), bus.NewJob("monitor:poll", nil)); err == nil { - t.Fatal("expected dispatch error") - } - if !lock.released { - t.Fatal("expected lock released after handler error") - } -} diff --git a/bus/observer_adapter_contract_test.go b/bus/observer_adapter_contract_test.go deleted file mode 100644 index df4f914..0000000 --- a/bus/observer_adapter_contract_test.go +++ /dev/null @@ -1,102 +0,0 @@ -package bus - -import ( - "context" - "errors" - "reflect" - "testing" - "time" - - "github.com/goforj/queue/internal/workflow" -) - -// TestLegacyObserverAdapterTranslatesEveryField verifies the deprecated bus -// observer shape remains an exact compatibility projection of engine facts. -func TestLegacyObserverAdapterTranslatesEveryField(t *testing.T) { - assertWorkflowEventShape(t) - - type contextKey struct{} - ctx := context.WithValue(context.Background(), contextKey{}, "legacy-observer-context") - wantErr := errors.New("chain failed") - wantTime := time.Date(2026, time.July, 20, 14, 0, 0, 0, time.UTC) - internalEvent := workflow.Event{ - SchemaVersion: 9, - EventID: "evt_legacy_adapter", - Kind: workflow.EventChainFailed, - DispatchID: "dsp_legacy_adapter", - JobID: "job_legacy_adapter", - ChainID: "chn_legacy_adapter", - BatchID: "bat_legacy_adapter", - Attempt: 4, - JobType: "reports:archive", - JobKey: "job-key-legacy-adapter", - Queue: "critical", - Duration: 73 * time.Millisecond, - Time: wantTime, - Err: wantErr, - } - - var ( - gotContext context.Context - gotEvent Event - ) - adapter := legacyObserverAdapter{observer: ObserverFunc(func(observedContext context.Context, event Event) { - gotContext = observedContext - gotEvent = event - })} - adapter.Observe(ctx, internalEvent) - - wantEvent := Event{ - SchemaVersion: internalEvent.SchemaVersion, - EventID: internalEvent.EventID, - Kind: EventChainFailed, - DispatchID: internalEvent.DispatchID, - JobID: internalEvent.JobID, - ChainID: internalEvent.ChainID, - BatchID: internalEvent.BatchID, - Attempt: internalEvent.Attempt, - JobType: internalEvent.JobType, - JobKey: internalEvent.JobKey, - Queue: internalEvent.Queue, - Duration: internalEvent.Duration, - Time: internalEvent.Time, - Err: internalEvent.Err, - } - if !reflect.DeepEqual(gotEvent, wantEvent) { - t.Fatalf("translated legacy event = %+v, want %+v", gotEvent, wantEvent) - } - if gotContext == nil || gotContext.Value(contextKey{}) != "legacy-observer-context" { - t.Fatalf("legacy observer context = %v, want original workflow context", gotContext) - } -} - -// assertWorkflowEventShape pins the internal adapter input so adding a field -// requires an explicit decision about its legacy projection. -func assertWorkflowEventShape(t *testing.T) { - t.Helper() - wantFields := []string{ - "SchemaVersion", - "EventID", - "Kind", - "DispatchID", - "JobID", - "ChainID", - "BatchID", - "Attempt", - "JobType", - "JobKey", - "Queue", - "Duration", - "Time", - "Err", - } - eventType := reflect.TypeOf(workflow.Event{}) - if eventType.NumField() != len(wantFields) { - t.Fatalf("workflow.Event fields = %d, want %d; update the legacy observer adapter contract", eventType.NumField(), len(wantFields)) - } - for index, wantField := range wantFields { - if gotField := eventType.Field(index).Name; gotField != wantField { - t.Fatalf("workflow.Event field %d = %s, want %s; update the legacy observer adapter contract", index, gotField, wantField) - } - } -} diff --git a/bus/options_builder_coverage_test.go b/bus/options_builder_coverage_test.go deleted file mode 100644 index 158d2ad..0000000 --- a/bus/options_builder_coverage_test.go +++ /dev/null @@ -1,120 +0,0 @@ -package bus_test - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/goforj/queue" - "github.com/goforj/queue/bus" -) - -type storeStub struct { - createBatchErr error -} - -func (s *storeStub) CreateChain(context.Context, bus.ChainRecord) error { return nil } -func (s *storeStub) AdvanceChain(context.Context, string, string) (*bus.ChainNode, bool, error) { - return nil, false, nil -} -func (s *storeStub) FailChain(context.Context, string, error) error { return nil } -func (s *storeStub) GetChain(context.Context, string) (bus.ChainState, error) { - return bus.ChainState{}, bus.ErrNotFound -} -func (s *storeStub) CreateBatch(context.Context, bus.BatchRecord) error { return s.createBatchErr } -func (s *storeStub) MarkBatchJobStarted(context.Context, string, string) error { - return nil -} -func (s *storeStub) MarkBatchJobSucceeded(context.Context, string, string) (bus.BatchState, bool, error) { - return bus.BatchState{}, false, nil -} -func (s *storeStub) MarkBatchJobFailed(context.Context, string, string, error) (bus.BatchState, bool, error) { - return bus.BatchState{}, false, nil -} -func (s *storeStub) CancelBatch(context.Context, string) error { return nil } -func (s *storeStub) GetBatch(context.Context, string) (bus.BatchState, error) { - return bus.BatchState{}, bus.ErrNotFound -} -func (s *storeStub) MarkCallbackInvoked(context.Context, string) (bool, error) { return true, nil } -func (s *storeStub) Prune(context.Context, time.Time) error { return nil } - -func TestWithStore_OverrideAndNilGuard(t *testing.T) { - storeErr := errors.New("store create batch failure") - overrideStore := &storeStub{createBatchErr: storeErr} - - q := queue.NewFake() - b, err := bus.NewWithStore(q, bus.NewMemoryStore(), bus.WithStore(overrideStore)) - if err != nil { - t.Fatalf("new bus with store override: %v", err) - } - _, err = b.Batch(bus.NewJob("job:one", nil)).Dispatch(context.Background()) - if !errors.Is(err, storeErr) { - t.Fatalf("expected override store error, got %v", err) - } - - bNil, err := bus.NewWithStore(q, overrideStore, bus.WithStore(nil)) - if err != nil { - t.Fatalf("new bus with nil store option: %v", err) - } - _, err = bNil.Batch(bus.NewJob("job:one", nil)).Dispatch(context.Background()) - if !errors.Is(err, storeErr) { - t.Fatalf("expected base store to remain when WithStore(nil), got %v", err) - } -} - -func TestWithClockAndBatchBuilderMetadata(t *testing.T) { - q := queue.NewFake() - fixedNow := time.Date(2024, time.January, 15, 12, 30, 0, 0, time.UTC) - - var observed []bus.Event - b, err := bus.New( - q, - bus.WithClock(func() time.Time { return fixedNow }), - bus.WithObserver(bus.ObserverFunc(func(_ context.Context, e bus.Event) { observed = append(observed, e) })), - ) - if err != nil { - t.Fatalf("new bus: %v", err) - } - - batchID, err := b.Batch( - bus.NewJob("job:a", map[string]any{"id": 1}), - ).Name("nightly sweep"). - OnQueue("critical"). - AllowFailures(). - Progress(func(context.Context, bus.BatchState) error { return nil }). - Then(func(context.Context, bus.BatchState) error { return nil }). - Catch(func(context.Context, bus.BatchState, error) error { return nil }). - Finally(func(context.Context, bus.BatchState) error { return nil }). - Dispatch(context.Background()) - if err != nil { - t.Fatalf("dispatch batch: %v", err) - } - - state, err := b.FindBatch(context.Background(), batchID) - if err != nil { - t.Fatalf("find batch: %v", err) - } - if state.Name != "nightly sweep" { - t.Fatalf("expected batch name nightly sweep, got %q", state.Name) - } - if state.Queue != "critical" { - t.Fatalf("expected batch queue critical, got %q", state.Queue) - } - if !state.AllowFailed { - t.Fatal("expected allow_failed=true") - } - if !state.CreatedAt.Equal(fixedNow) { - t.Fatalf("expected created_at %v, got %v", fixedNow, state.CreatedAt) - } - - if len(observed) == 0 { - t.Fatal("expected emitted events") - } - if observed[0].Kind != bus.EventBatchStarted { - t.Fatalf("expected first event batch started, got %q", observed[0].Kind) - } - if !observed[0].Time.Equal(fixedNow) { - t.Fatalf("expected first event time %v, got %v", fixedNow, observed[0].Time) - } -} diff --git a/bus/source_compat_test.go b/bus/source_compat_test.go deleted file mode 100644 index 43ed328..0000000 --- a/bus/source_compat_test.go +++ /dev/null @@ -1,449 +0,0 @@ -package bus_test - -import ( - "context" - "database/sql" - "testing" - "time" - - "github.com/goforj/queue" - "github.com/goforj/queue/bus" - "github.com/goforj/queue/bus/driver/temporal" -) - -var ( - _ bus.Bus = (*sourceCompatBus)(nil) - _ bus.Store = (*sourceCompatStore)(nil) - _ bus.Observer = sourceCompatObserver{} - _ bus.Middleware = sourceCompatMiddleware{} - _ bus.ChainBuilder = (*sourceCompatChainBuilder)(nil) - _ bus.BatchBuilder = (*sourceCompatBatchBuilder)(nil) - _ queue.ChainBuilder = (*sourceCompatRootChainBuilder)(nil) - _ queue.BatchBuilder = (*sourceCompatRootBatchBuilder)(nil) - _ bus.Bus = (*temporal.Adapter)(nil) -) - -var ( - sourceCompatBusContextFromRoot bus.Context = queue.Message{SchemaVersion: 1} - sourceCompatRootMessageFromBus queue.Message = bus.Context{SchemaVersion: 1} - sourceCompatBusResultFromRoot bus.DispatchResult = queue.DispatchResult{DispatchID: "root"} - sourceCompatRootResultFromBus queue.DispatchResult = bus.DispatchResult{DispatchID: "bus"} - sourceCompatBusOptionsFromRoot bus.JobOptions = queue.StoredJobOptions{Queue: "root"} - sourceCompatRootOptionsFromBus queue.StoredJobOptions = bus.JobOptions{Queue: "bus"} - sourceCompatBusChainStateFromRoot bus.ChainState = queue.ChainState{ChainID: "root-chain"} - sourceCompatRootChainStateFromBus queue.ChainState = bus.ChainState{ChainID: "bus-chain"} - sourceCompatBusBatchStateFromRoot bus.BatchState = queue.BatchState{BatchID: "root-batch"} - sourceCompatRootBatchStateFromBus queue.BatchState = bus.BatchState{BatchID: "bus-batch"} - sourceCompatRootMiddleware queue.Middleware = sourceCompatMiddleware{} - sourceCompatBusMiddlewareFromRoot bus.Middleware = sourceCompatRootMiddleware - sourceCompatRootMiddlewareRoundTrip queue.Middleware = sourceCompatBusMiddlewareFromRoot - sourceCompatRootStore queue.WorkflowStore = &sourceCompatStore{} - sourceCompatBusStoreFromRoot bus.Store = sourceCompatRootStore - sourceCompatRootStoreRoundTrip queue.WorkflowStore = sourceCompatBusStoreFromRoot - sourceCompatRootMiddlewareFunc queue.MiddlewareFunc = bus.MiddlewareFunc(func(ctx context.Context, message bus.Context, next bus.Next) error { - return next(ctx, message) - }) - sourceCompatBusMiddlewareFunc bus.MiddlewareFunc = sourceCompatRootMiddlewareFunc -) - -type sourceCompatBus struct{} - -type sourceCompatJobOptions bus.JobOptions - -type sourceCompatDispatchResult bus.DispatchResult - -type sourceCompatChainRecord bus.ChainRecord - -type sourceCompatChainState bus.ChainState - -type sourceCompatBatchRecord bus.BatchRecord - -type sourceCompatBatchState bus.BatchState - -type sourceCompatSQLStoreConfig bus.SQLStoreConfig - -// Register accepts the legacy named bus handler contract. -func (*sourceCompatBus) Register(string, bus.Handler) {} - -// Dispatch returns a legacy dispatch result for a legacy job value. -func (*sourceCompatBus) Dispatch(context.Context, bus.Job) (bus.DispatchResult, error) { - return bus.DispatchResult{}, nil -} - -// Chain returns a custom builder with the legacy self-returning method set. -func (*sourceCompatBus) Chain(...bus.Job) bus.ChainBuilder { - return &sourceCompatChainBuilder{} -} - -// Batch returns a custom builder with the legacy self-returning method set. -func (*sourceCompatBus) Batch(...bus.Job) bus.BatchBuilder { - return &sourceCompatBatchBuilder{} -} - -// StartWorkers preserves the legacy lifecycle signature. -func (*sourceCompatBus) StartWorkers(context.Context) error { return nil } - -// Shutdown preserves the legacy lifecycle signature. -func (*sourceCompatBus) Shutdown(context.Context) error { return nil } - -// FindBatch returns the legacy batch state type. -func (*sourceCompatBus) FindBatch(context.Context, string) (bus.BatchState, error) { - return bus.BatchState{}, nil -} - -// FindChain returns the legacy chain state type. -func (*sourceCompatBus) FindChain(context.Context, string) (bus.ChainState, error) { - return bus.ChainState{}, nil -} - -// Prune preserves the legacy workflow retention signature. -func (*sourceCompatBus) Prune(context.Context, time.Time) error { return nil } - -type sourceCompatStore struct{} - -// CreateChain accepts the legacy chain record type. -func (*sourceCompatStore) CreateChain(context.Context, bus.ChainRecord) error { return nil } - -// AdvanceChain returns the legacy opaque chain node type. -func (*sourceCompatStore) AdvanceChain(context.Context, string, string) (*bus.ChainNode, bool, error) { - return nil, false, nil -} - -// FailChain preserves the legacy chain failure signature. -func (*sourceCompatStore) FailChain(context.Context, string, error) error { return nil } - -// GetChain returns the legacy chain state type. -func (*sourceCompatStore) GetChain(context.Context, string) (bus.ChainState, error) { - return bus.ChainState{}, nil -} - -// CreateBatch accepts the legacy batch record type. -func (*sourceCompatStore) CreateBatch(context.Context, bus.BatchRecord) error { return nil } - -// MarkBatchJobStarted preserves the legacy batch start signature. -func (*sourceCompatStore) MarkBatchJobStarted(context.Context, string, string) error { return nil } - -// MarkBatchJobSucceeded returns the legacy batch state and completion flag. -func (*sourceCompatStore) MarkBatchJobSucceeded(context.Context, string, string) (bus.BatchState, bool, error) { - return bus.BatchState{}, false, nil -} - -// MarkBatchJobFailed preserves the legacy failure-cause argument and result types. -func (*sourceCompatStore) MarkBatchJobFailed(context.Context, string, string, error) (bus.BatchState, bool, error) { - return bus.BatchState{}, false, nil -} - -// CancelBatch preserves the legacy batch cancellation signature. -func (*sourceCompatStore) CancelBatch(context.Context, string) error { return nil } - -// GetBatch returns the legacy batch state type. -func (*sourceCompatStore) GetBatch(context.Context, string) (bus.BatchState, error) { - return bus.BatchState{}, nil -} - -// MarkCallbackInvoked preserves the legacy callback idempotency signature. -func (*sourceCompatStore) MarkCallbackInvoked(context.Context, string) (bool, error) { - return true, nil -} - -// Prune preserves the legacy store retention signature. -func (*sourceCompatStore) Prune(context.Context, time.Time) error { return nil } - -type sourceCompatObserver struct{} - -// Observe accepts the keyed legacy event model without relying on its rejected unkeyed layout. -func (sourceCompatObserver) Observe(context.Context, bus.Event) {} - -type sourceCompatMiddleware struct{} - -// Handle accepts the legacy context and continuation types. -func (sourceCompatMiddleware) Handle(ctx context.Context, message bus.Context, next bus.Next) error { - return next(ctx, message) -} - -type sourceCompatChainBuilder struct{} - -// OnQueue returns the legacy chain builder interface. -func (builder *sourceCompatChainBuilder) OnQueue(string) bus.ChainBuilder { return builder } - -// Catch accepts the legacy chain state callback. -func (builder *sourceCompatChainBuilder) Catch(func(context.Context, bus.ChainState, error) error) bus.ChainBuilder { - return builder -} - -// Finally accepts the legacy terminal chain callback. -func (builder *sourceCompatChainBuilder) Finally(func(context.Context, bus.ChainState) error) bus.ChainBuilder { - return builder -} - -// Dispatch preserves the legacy chain dispatch signature. -func (*sourceCompatChainBuilder) Dispatch(context.Context) (string, error) { return "", nil } - -type sourceCompatBatchBuilder struct{} - -// Name returns the legacy batch builder interface. -func (builder *sourceCompatBatchBuilder) Name(string) bus.BatchBuilder { return builder } - -// OnQueue returns the legacy batch builder interface. -func (builder *sourceCompatBatchBuilder) OnQueue(string) bus.BatchBuilder { return builder } - -// AllowFailures returns the legacy batch builder interface. -func (builder *sourceCompatBatchBuilder) AllowFailures() bus.BatchBuilder { return builder } - -// Progress accepts the legacy batch progress callback. -func (builder *sourceCompatBatchBuilder) Progress(func(context.Context, bus.BatchState) error) bus.BatchBuilder { - return builder -} - -// Then accepts the legacy successful batch callback. -func (builder *sourceCompatBatchBuilder) Then(func(context.Context, bus.BatchState) error) bus.BatchBuilder { - return builder -} - -// Catch accepts the legacy failed batch callback. -func (builder *sourceCompatBatchBuilder) Catch(func(context.Context, bus.BatchState, error) error) bus.BatchBuilder { - return builder -} - -// Finally accepts the legacy terminal batch callback. -func (builder *sourceCompatBatchBuilder) Finally(func(context.Context, bus.BatchState) error) bus.BatchBuilder { - return builder -} - -// Dispatch preserves the legacy batch dispatch signature. -func (*sourceCompatBatchBuilder) Dispatch(context.Context) (string, error) { return "", nil } - -type sourceCompatRootChainBuilder struct{} - -// OnQueue returns the root chain builder interface. -func (builder *sourceCompatRootChainBuilder) OnQueue(string) queue.ChainBuilder { return builder } - -// Catch accepts the root chain state callback. -func (builder *sourceCompatRootChainBuilder) Catch(func(context.Context, queue.ChainState, error) error) queue.ChainBuilder { - return builder -} - -// Finally accepts the root terminal chain callback. -func (builder *sourceCompatRootChainBuilder) Finally(func(context.Context, queue.ChainState) error) queue.ChainBuilder { - return builder -} - -// Dispatch preserves the root chain dispatch signature. -func (*sourceCompatRootChainBuilder) Dispatch(context.Context) (string, error) { return "", nil } - -type sourceCompatRootBatchBuilder struct{} - -// Name returns the root batch builder interface. -func (builder *sourceCompatRootBatchBuilder) Name(string) queue.BatchBuilder { return builder } - -// OnQueue returns the root batch builder interface. -func (builder *sourceCompatRootBatchBuilder) OnQueue(string) queue.BatchBuilder { return builder } - -// AllowFailures returns the root batch builder interface. -func (builder *sourceCompatRootBatchBuilder) AllowFailures() queue.BatchBuilder { return builder } - -// Progress accepts the root batch progress callback. -func (builder *sourceCompatRootBatchBuilder) Progress(func(context.Context, queue.BatchState) error) queue.BatchBuilder { - return builder -} - -// Then accepts the root successful batch callback. -func (builder *sourceCompatRootBatchBuilder) Then(func(context.Context, queue.BatchState) error) queue.BatchBuilder { - return builder -} - -// Catch accepts the root failed batch callback. -func (builder *sourceCompatRootBatchBuilder) Catch(func(context.Context, queue.BatchState, error) error) queue.BatchBuilder { - return builder -} - -// Finally accepts the root terminal batch callback. -func (builder *sourceCompatRootBatchBuilder) Finally(func(context.Context, queue.BatchState) error) queue.BatchBuilder { - return builder -} - -// Dispatch preserves the root batch dispatch signature. -func (*sourceCompatRootBatchBuilder) Dispatch(context.Context) (string, error) { return "", nil } - -// TestBusV1SourceCompatibility freezes the external source forms retained by the deprecated forwarding facade. -func TestBusV1SourceCompatibility(t *testing.T) { - fixedTime := time.Unix(1_704_067_200, 123_000_000) - keyedOptions := bus.JobOptions{ - Queue: "critical", - Delay: time.Second, - Timeout: 2 * time.Second, - Retry: 3, - Backoff: 4 * time.Second, - UniqueFor: 5 * time.Second, - } - unkeyedOptions := bus.JobOptions(sourceCompatJobOptions{"bulk", 6 * time.Second, 7 * time.Second, 8, 9 * time.Second, 10 * time.Second}) - keyedJob := bus.Job{Type: "reports:keyed", Payload: map[string]int{"id": 1}, Options: keyedOptions} - unkeyedJob := bus.Job{"reports:unkeyed", map[string]int{"id": 2}, unkeyedOptions} - mutableJob := bus.NewJob("reports:mutable", nil) - mutableJob.Type = "reports:mutated" - mutableJob.Payload = []byte("payload") - mutableJob.Options = keyedOptions - mutableJob.Options.Queue = "mutated" - mutableJob.Options.Retry = 11 - if keyedJob.Type != "reports:keyed" || unkeyedJob.Type != "reports:unkeyed" || mutableJob.Type != "reports:mutated" || mutableJob.Options.Queue != "mutated" || mutableJob.Options.Retry != 11 { - t.Fatalf("legacy job source forms changed: keyed=%+v unkeyed=%+v mutable=%+v", keyedJob, unkeyedJob, mutableJob) - } - if unkeyedOptions.Queue != "bulk" || unkeyedOptions.Delay != 6*time.Second || unkeyedOptions.Timeout != 7*time.Second || unkeyedOptions.Retry != 8 || unkeyedOptions.Backoff != 9*time.Second || unkeyedOptions.UniqueFor != 10*time.Second { - t.Fatalf("legacy unkeyed job option order changed: %+v", unkeyedOptions) - } - storedJob := bus.StoredJob{Type: "reports:stored", Payload: []byte(`{"id":3}`), Options: keyedOptions} - storedNode := bus.ChainNode{NodeID: "stored-node", Job: storedJob} - var selectedOptions bus.JobOptions = storedNode.Job.Options - storedNode.Job.Options = selectedOptions - var rootStoredJob queue.StoredJob = storedNode.Job - var busStoredJob bus.StoredJob = rootStoredJob - if busStoredJob.Type != "reports:stored" || busStoredJob.Options.Queue != "critical" { - t.Fatalf("legacy stored job selectors changed: %+v", busStoredJob) - } - - keyedResult := bus.DispatchResult{DispatchID: "dispatch-keyed"} - unkeyedResult := bus.DispatchResult(sourceCompatDispatchResult{"dispatch-unkeyed"}) - if keyedResult.DispatchID != "dispatch-keyed" || unkeyedResult.DispatchID != "dispatch-unkeyed" { - t.Fatalf("legacy dispatch result source forms changed: keyed=%+v unkeyed=%+v", keyedResult, unkeyedResult) - } - - keyedChainRecord := bus.ChainRecord{ - ChainID: "chain-keyed", - DispatchID: "dispatch-keyed", - Queue: "critical", - Nodes: nil, - CreatedAt: fixedTime, - } - unkeyedChainRecord := bus.ChainRecord(sourceCompatChainRecord{"chain-unkeyed", "dispatch-unkeyed", "bulk", nil, fixedTime}) - keyedChainState := bus.ChainState{ - ChainID: "chain-state-keyed", - DispatchID: "dispatch-state-keyed", - Queue: "critical", - Nodes: nil, - NextIndex: 2, - Completed: true, - Failed: false, - Failure: "", - CreatedAt: fixedTime, - UpdatedAt: fixedTime.Add(time.Second), - } - unkeyedChainState := bus.ChainState(sourceCompatChainState{"chain-state-unkeyed", "dispatch-state-unkeyed", "bulk", nil, 1, false, true, "failed", fixedTime, fixedTime.Add(2 * time.Second)}) - if keyedChainRecord.ChainID != "chain-keyed" || unkeyedChainRecord.Queue != "bulk" || keyedChainState.NextIndex != 2 || !unkeyedChainState.Failed || unkeyedChainState.Failure != "failed" { - t.Fatalf("legacy chain source forms changed: records=%+v/%+v states=%+v/%+v", keyedChainRecord, unkeyedChainRecord, keyedChainState, unkeyedChainState) - } - - keyedBatchRecord := bus.BatchRecord{ - BatchID: "batch-keyed", - DispatchID: "batch-dispatch-keyed", - Name: "keyed batch", - Queue: "critical", - AllowFailed: true, - Jobs: nil, - CreatedAt: fixedTime, - } - unkeyedBatchRecord := bus.BatchRecord(sourceCompatBatchRecord{"batch-unkeyed", "batch-dispatch-unkeyed", "unkeyed batch", "bulk", false, nil, fixedTime}) - keyedBatchState := bus.BatchState{ - BatchID: "batch-state-keyed", - DispatchID: "batch-state-dispatch-keyed", - Name: "keyed state", - Queue: "critical", - AllowFailed: true, - Total: 4, - Pending: 1, - Processed: 3, - Failed: 1, - Cancelled: false, - Completed: true, - CreatedAt: fixedTime, - UpdatedAt: fixedTime.Add(time.Second), - } - unkeyedBatchState := bus.BatchState(sourceCompatBatchState{"batch-state-unkeyed", "batch-state-dispatch-unkeyed", "unkeyed state", "bulk", false, 5, 2, 3, 1, true, true, fixedTime, fixedTime.Add(2 * time.Second)}) - if keyedBatchRecord.BatchID != "batch-keyed" || unkeyedBatchRecord.Name != "unkeyed batch" || keyedBatchState.Processed != 3 || !unkeyedBatchState.Cancelled || unkeyedBatchState.Total != 5 { - t.Fatalf("legacy batch source forms changed: records=%+v/%+v states=%+v/%+v", keyedBatchRecord, unkeyedBatchRecord, keyedBatchState, unkeyedBatchState) - } - - keyedSQLConfig := bus.SQLStoreConfig{DB: (*sql.DB)(nil), DriverName: "sqlite", DSN: "file:keyed", AutoMigrate: true} - unkeyedSQLConfig := bus.SQLStoreConfig(sourceCompatSQLStoreConfig{nil, "sqlite", "file:unkeyed", true}) - if keyedSQLConfig.DriverName != "sqlite" || keyedSQLConfig.DSN != "file:keyed" || !keyedSQLConfig.AutoMigrate || unkeyedSQLConfig.DSN != "file:unkeyed" || !unkeyedSQLConfig.AutoMigrate { - t.Fatalf("legacy SQL store config source forms changed: keyed=%+v unkeyed=%+v", keyedSQLConfig, unkeyedSQLConfig) - } - - options := []bus.Option{ - nil, - bus.WithObserver(sourceCompatObserver{}), - bus.WithStore(&sourceCompatStore{}), - bus.WithClock(func() time.Time { return fixedTime }), - bus.WithMiddleware(sourceCompatMiddleware{}), - } - if len(options) != 5 || options[0] != nil || options[1] == nil || options[2] == nil || options[3] == nil || options[4] == nil { - t.Fatalf("legacy option slice changed: %+v", options) - } - - fake := bus.NewFake() - var concreteFake *bus.Fake = fake - if _, err := concreteFake.Dispatch(context.Background(), keyedJob); err != nil { - t.Fatalf("dispatch through legacy fake: %v", err) - } - if _, err := concreteFake.Chain(keyedJob, unkeyedJob).Dispatch(context.Background()); err != nil { - t.Fatalf("chain through legacy fake: %v", err) - } - if _, err := concreteFake.Batch(keyedJob, unkeyedJob).Dispatch(context.Background()); err != nil { - t.Fatalf("batch through legacy fake: %v", err) - } - keyedSpec := bus.BatchSpec{JobTypes: []string{"reports:keyed", "reports:unkeyed"}} - unkeyedSpec := bus.BatchSpec{[]string{"reports:keyed", "reports:unkeyed"}} - if len(keyedSpec.JobTypes) != 2 || len(unkeyedSpec.JobTypes) != 2 { - t.Fatalf("legacy batch spec source forms changed: keyed=%+v unkeyed=%+v", keyedSpec, unkeyedSpec) - } - concreteFake.AssertDispatched(t, "reports:keyed") - concreteFake.AssertChained(t, keyedSpec.JobTypes) - concreteFake.AssertBatched(t, func(spec bus.BatchSpec) bool { - return len(spec.JobTypes) == 2 && spec.JobTypes[0] == "reports:keyed" && spec.JobTypes[1] == "reports:unkeyed" - }) - - temporalAdapter, err := temporal.New(temporal.Config{}) - if err != nil { - t.Fatalf("construct temporal compatibility adapter: %v", err) - } - var temporalBus bus.Bus = temporalAdapter - if temporalBus == nil { - t.Fatal("temporal adapter no longer satisfies bus.Bus") - } - - _ = []any{ - sourceCompatBusContextFromRoot, - sourceCompatRootMessageFromBus, - sourceCompatBusResultFromRoot, - sourceCompatRootResultFromBus, - sourceCompatBusOptionsFromRoot, - sourceCompatRootOptionsFromBus, - sourceCompatBusChainStateFromRoot, - sourceCompatRootChainStateFromBus, - sourceCompatBusBatchStateFromRoot, - sourceCompatRootBatchStateFromBus, - sourceCompatRootMiddlewareRoundTrip, - sourceCompatRootStoreRoundTrip, - sourceCompatBusMiddlewareFunc, - } -} - -// TestBuilderInterfacesRemainSourceDistinct pins the legacy self-returning method sets. -func TestBuilderInterfacesRemainSourceDistinct(t *testing.T) { - var legacyChain bus.ChainBuilder = &sourceCompatChainBuilder{} - var rootChain queue.ChainBuilder = &sourceCompatRootChainBuilder{} - if _, ok := any(legacyChain).(queue.ChainBuilder); ok { - t.Fatal("legacy chain builder unexpectedly satisfies the root self-returning contract") - } - if _, ok := any(rootChain).(bus.ChainBuilder); ok { - t.Fatal("root chain builder unexpectedly satisfies the legacy self-returning contract") - } - - var legacyBatch bus.BatchBuilder = &sourceCompatBatchBuilder{} - var rootBatch queue.BatchBuilder = &sourceCompatRootBatchBuilder{} - if _, ok := any(legacyBatch).(queue.BatchBuilder); ok { - t.Fatal("legacy batch builder unexpectedly satisfies the root self-returning contract") - } - if _, ok := any(rootBatch).(bus.BatchBuilder); ok { - t.Fatal("root batch builder unexpectedly satisfies the legacy self-returning contract") - } -} diff --git a/bus/store.go b/bus/store.go deleted file mode 100644 index e21ac7b..0000000 --- a/bus/store.go +++ /dev/null @@ -1,96 +0,0 @@ -package bus - -import "github.com/goforj/queue" - -// StoredJob is the stable logical-job shape persisted inside workflow records. -// -// Deprecated: use queue.StoredJob. -type StoredJob = queue.StoredJob - -// ChainNode is one persisted chain step. -// -// Deprecated: use queue.ChainNode. -type ChainNode = queue.ChainNode - -// ChainRecord is the persisted representation used to create a chain. -// -// Deprecated: use queue.ChainRecord. -type ChainRecord = queue.ChainRecord - -// ChainState is the persisted view of a chain workflow. -// -// Deprecated: use queue.ChainState. -type ChainState = queue.ChainState - -// BatchJob is one persisted batch member. -// -// Deprecated: use queue.BatchJob. -type BatchJob = queue.BatchJob - -// BatchJobOutcome identifies the durable result that first settled one batch member. -// -// Deprecated: use queue.BatchJobOutcome. -type BatchJobOutcome = queue.BatchJobOutcome - -const ( - // BatchJobSucceeded records successful member settlement. - // - // Deprecated: use queue.BatchJobSucceeded. - BatchJobSucceeded = queue.BatchJobSucceeded - // BatchJobFailed records failed member settlement. - // - // Deprecated: use queue.BatchJobFailed. - BatchJobFailed = queue.BatchJobFailed -) - -// BatchRecord is the persisted representation used to create a batch. -// -// Deprecated: use queue.BatchRecord. -type BatchRecord = queue.BatchRecord - -// BatchState is the persisted view of a batch workflow. -// -// Deprecated: use queue.BatchState. -type BatchState = queue.BatchState - -// Store persists chain, batch, and callback state. -// -// Deprecated: use queue.WorkflowStore. -type Store = queue.WorkflowStore - -// WorkflowOutcomeStore adds first-writer ownership to the compatibility store contract. -// -// Deprecated: use queue.WorkflowOutcomeStore. -type WorkflowOutcomeStore = queue.WorkflowOutcomeStore - -// SQLStoreConfig configures the SQL-backed workflow store. -// -// Deprecated: use queue.SQLStoreConfig. -type SQLStoreConfig = queue.SQLStoreConfig - -// ErrNotFound indicates a workflow state record is not present. -// -// Deprecated: use queue.ErrWorkflowNotFound. -var ErrNotFound = queue.ErrWorkflowNotFound - -// NewMemoryStore creates an in-memory workflow state store. -// -// Deprecated: use queue.NewMemoryStore. -func NewMemoryStore() Store { - return queue.NewMemoryStore() -} - -// NewSQLStore creates a SQL-backed workflow state store. -// -// Deprecated: use queue.NewSQLStore. -func NewSQLStore(cfg SQLStoreConfig) (Store, error) { - return queue.NewSQLStore(cfg) -} - -// NewSQLStoreWithManagedSchema creates a SQL-backed workflow state store -// without executing schema DDL. -// -// Deprecated: use queue.NewSQLStoreWithManagedSchema. -func NewSQLStoreWithManagedSchema(cfg SQLStoreConfig) (Store, error) { - return queue.NewSQLStoreWithManagedSchema(cfg) -} diff --git a/bus/store_managed_schema_test.go b/bus/store_managed_schema_test.go deleted file mode 100644 index f9b0b07..0000000 --- a/bus/store_managed_schema_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package bus - -import ( - "context" - "database/sql" - "errors" - "path/filepath" - "testing" - - _ "modernc.org/sqlite" -) - -// TestNewSQLStoreWithManagedSchemaForwardsWithoutDDL proves the deprecated bus -// constructor retains the root caller-managed schema behavior. -func TestNewSQLStoreWithManagedSchemaForwardsWithoutDDL(t *testing.T) { - ctx := context.Background() - db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "bus-managed-empty.db")) - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - t.Cleanup(func() { _ = db.Close() }) - - store, err := NewSQLStoreWithManagedSchema(SQLStoreConfig{DB: db, DriverName: "sqlite"}) - if err != nil { - t.Fatalf("new managed-schema store: %v", err) - } - if _, err := store.GetChain(ctx, "missing"); err == nil || errors.Is(err, ErrNotFound) { - t.Fatalf("unprovisioned managed schema error = %v", err) - } - var tableCount int - if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name LIKE 'bus_%'`).Scan(&tableCount); err != nil { - t.Fatalf("count workflow tables: %v", err) - } - if tableCount != 0 { - t.Fatalf("deprecated managed-schema constructor created %d workflow tables", tableCount) - } -} diff --git a/bus/store_sql_compat_test.go b/bus/store_sql_compat_test.go deleted file mode 100644 index f9165a0..0000000 --- a/bus/store_sql_compat_test.go +++ /dev/null @@ -1,401 +0,0 @@ -package bus - -import ( - "context" - "database/sql" - "errors" - "fmt" - "os" - "path/filepath" - "slices" - "strings" - "testing" - "time" - - _ "modernc.org/sqlite" -) - -const legacyV1NodesJSON = `[{"NodeID":"legacy-node-1","Job":{"type":"reports:build","payload":"eyJpZCI6MX0=","options":{"Queue":"critical","Delay":2000000000,"Timeout":15000000000,"Retry":4,"Backoff":500000000,"UniqueFor":30000000000}}},{"NodeID":"legacy-node-2","Job":{"type":"reports:notify","payload":"bnVsbA==","options":{"Queue":"critical","Delay":0,"Timeout":0,"Retry":0,"Backoff":0,"UniqueFor":0}}}]` - -// TestSQLStoreV1PersistedDataCompatibility proves the current store can read and safely mutate the frozen v1 SQLite layout. -func TestSQLStoreV1PersistedDataCompatibility(t *testing.T) { - const ( - legacyCreatedMS = int64(1704067200123) - legacyUpdatedMS = int64(1704067201123) - pruneCutoffMS = int64(1705000000000) - ) - - ctx := context.Background() - db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "workflow-v1.db")) - if err != nil { - t.Fatalf("open compatibility database: %v", err) - } - t.Cleanup(func() { - if closeErr := db.Close(); closeErr != nil { - t.Errorf("close compatibility database: %v", closeErr) - } - }) - loadLegacySQLStoreFixture(t, ctx, db) - seedLegacyDualTerminalChain(t, ctx, db) - - store, err := NewSQLStore(SQLStoreConfig{DB: db, DriverName: "sqlite"}) - if err != nil { - t.Fatalf("construct store over v1 database: %v", err) - } - - activeChain, err := store.GetChain(ctx, "compat-chain-mutate") - if err != nil { - t.Fatalf("read v1 active chain: %v", err) - } - if activeChain.DispatchID != "compat-dispatch-mutate" || activeChain.Queue != "critical" || activeChain.NextIndex != 0 || activeChain.Completed || activeChain.Failed { - t.Fatalf("active chain state changed: %+v", activeChain) - } - if activeChain.CreatedAt.UnixMilli() != legacyCreatedMS || activeChain.UpdatedAt.UnixMilli() != legacyUpdatedMS { - t.Fatalf("active chain timestamps changed: created=%d updated=%d", activeChain.CreatedAt.UnixMilli(), activeChain.UpdatedAt.UnixMilli()) - } - if len(activeChain.Nodes) != 2 { - t.Fatalf("active chain node count=%d, want 2", len(activeChain.Nodes)) - } - firstJob := activeChain.Nodes[0].Job - if activeChain.Nodes[0].NodeID != "legacy-node-1" || firstJob.Type != "reports:build" || string(firstJob.Payload) != `{"id":1}` { - t.Fatalf("first legacy node did not decode: %+v", activeChain.Nodes[0]) - } - if firstJob.Options.Queue != "critical" || firstJob.Options.Delay != 2*time.Second || firstJob.Options.Timeout != 15*time.Second || firstJob.Options.Retry != 4 || firstJob.Options.Backoff != 500*time.Millisecond || firstJob.Options.UniqueFor != 30*time.Second { - t.Fatalf("legacy nested job options changed: %+v", firstJob.Options) - } - secondJob := activeChain.Nodes[1].Job - if activeChain.Nodes[1].NodeID != "legacy-node-2" || secondJob.Type != "reports:notify" || string(secondJob.Payload) != "null" { - t.Fatalf("second legacy node did not decode: %+v", activeChain.Nodes[1]) - } - - completedChain, err := store.GetChain(ctx, "compat-chain-completed-old") - if err != nil { - t.Fatalf("read v1 completed chain: %v", err) - } - if !completedChain.Completed || completedChain.Failed || completedChain.Failure != "" || completedChain.NextIndex != 2 || completedChain.UpdatedAt.UnixMilli() != 1704067205000 { - t.Fatalf("completed chain state changed: %+v", completedChain) - } - dualTerminalChain, err := store.GetChain(ctx, "compat-chain-dual-terminal-old") - if err != nil { - t.Fatalf("read injected dual-terminal chain: %v", err) - } - if !dualTerminalChain.Completed || !dualTerminalChain.Failed || dualTerminalChain.Failure != "late legacy failure" || dualTerminalChain.NextIndex != 2 || dualTerminalChain.UpdatedAt.UnixMilli() != 1704067205000 { - t.Fatalf("dual-terminal chain state changed: %+v", dualTerminalChain) - } - failedChain, err := store.GetChain(ctx, "compat-chain-failed-old") - if err != nil { - t.Fatalf("read v1 failed chain: %v", err) - } - if failedChain.Completed || !failedChain.Failed || failedChain.Failure != "legacy failure" || failedChain.NextIndex != 1 { - t.Fatalf("failed chain state changed: %+v", failedChain) - } - - activeBatch, err := store.GetBatch(ctx, "compat-batch-mutate") - if err != nil { - t.Fatalf("read v1 active batch: %v", err) - } - if activeBatch.Name != "legacy mutable batch" || activeBatch.Queue != "bulk" || !activeBatch.AllowFailed || activeBatch.Total != 2 || activeBatch.Pending != 2 || activeBatch.Processed != 0 || activeBatch.Failed != 0 || activeBatch.Cancelled || activeBatch.Completed { - t.Fatalf("active batch state changed: %+v", activeBatch) - } - if activeBatch.CreatedAt.UnixMilli() != legacyCreatedMS || activeBatch.UpdatedAt.UnixMilli() != legacyUpdatedMS { - t.Fatalf("active batch timestamps changed: created=%d updated=%d", activeBatch.CreatedAt.UnixMilli(), activeBatch.UpdatedAt.UnixMilli()) - } - terminalBatch, err := store.GetBatch(ctx, "compat-batch-terminal-old") - if err != nil { - t.Fatalf("read v1 terminal batch: %v", err) - } - if !terminalBatch.Completed || terminalBatch.Cancelled || terminalBatch.Total != 2 || terminalBatch.Pending != 0 || terminalBatch.Processed != 2 || terminalBatch.Failed != 1 { - t.Fatalf("terminal batch state changed: %+v", terminalBatch) - } - - assertLegacySQLStoreSchema(t, ctx, db) - assertLegacyNodesJSON(t, ctx, db, "compat-chain-mutate") - - existingCallback, err := store.MarkCallbackInvoked(ctx, "chain_finally:compat-chain-completed-old") - if err != nil { - t.Fatalf("claim existing v1 callback marker: %v", err) - } - if existingCallback { - t.Fatal("existing v1 callback marker was claimed twice") - } - newCallback, err := store.MarkCallbackInvoked(ctx, "batch_finally:compat-batch-mutate") - if err != nil { - t.Fatalf("claim new callback marker: %v", err) - } - if !newCallback { - t.Fatal("new callback marker was not claimed") - } - duplicateCallback, err := store.MarkCallbackInvoked(ctx, "batch_finally:compat-batch-mutate") - if err != nil { - t.Fatalf("claim duplicate callback marker: %v", err) - } - if duplicateCallback { - t.Fatal("new callback marker was claimed twice") - } - - next, done, err := store.AdvanceChain(ctx, "compat-chain-mutate", "legacy-node-1") - if err != nil { - t.Fatalf("advance v1 chain: %v", err) - } - if done || next == nil || next.NodeID != "legacy-node-2" || next.Job.Type != "reports:notify" || string(next.Job.Payload) != "null" { - t.Fatalf("first v1 chain advance returned done=%v next=%+v", done, next) - } - duplicateNext, duplicateDone, err := store.AdvanceChain(ctx, "compat-chain-mutate", "legacy-node-1") - if err != nil { - t.Fatalf("repeat v1 chain advance: %v", err) - } - if duplicateDone || duplicateNext == nil || duplicateNext.NodeID != "legacy-node-2" { - t.Fatalf("duplicate v1 chain advance returned done=%v next=%+v", duplicateDone, duplicateNext) - } - next, done, err = store.AdvanceChain(ctx, "compat-chain-mutate", "legacy-node-2") - if err != nil { - t.Fatalf("complete v1 chain: %v", err) - } - if !done || next != nil { - t.Fatalf("completed v1 chain returned done=%v next=%+v", done, next) - } - mutatedChain, err := store.GetChain(ctx, "compat-chain-mutate") - if err != nil { - t.Fatalf("read mutated v1 chain: %v", err) - } - if !mutatedChain.Completed || mutatedChain.Failed || mutatedChain.NextIndex != 2 || mutatedChain.CreatedAt.UnixMilli() != legacyCreatedMS || mutatedChain.UpdatedAt.UnixMilli() <= legacyUpdatedMS { - t.Fatalf("mutated v1 chain state is inconsistent: %+v", mutatedChain) - } - assertLegacyNodesJSON(t, ctx, db, "compat-chain-mutate") - assertLegacySQLCount(t, ctx, db, `SELECT COUNT(*) FROM bus_chain_completed_nodes WHERE chain_id=?`, 2, "compat-chain-mutate") - - if err := store.MarkBatchJobStarted(ctx, "compat-batch-mutate", "legacy-batch-job-1"); err != nil { - t.Fatalf("start v1 batch job: %v", err) - } - batchAfterSuccess, batchDone, err := store.MarkBatchJobSucceeded(ctx, "compat-batch-mutate", "legacy-batch-job-1") - if err != nil { - t.Fatalf("complete v1 batch job: %v", err) - } - if batchDone || batchAfterSuccess.Pending != 1 || batchAfterSuccess.Processed != 1 || batchAfterSuccess.Failed != 0 { - t.Fatalf("v1 batch state after success is inconsistent: done=%v state=%+v", batchDone, batchAfterSuccess) - } - duplicateBatch, duplicateBatchDone, err := store.MarkBatchJobSucceeded(ctx, "compat-batch-mutate", "legacy-batch-job-1") - if err != nil { - t.Fatalf("repeat v1 batch completion: %v", err) - } - if duplicateBatchDone || duplicateBatch.Pending != 1 || duplicateBatch.Processed != 1 || duplicateBatch.Failed != 0 { - t.Fatalf("duplicate v1 batch completion changed counters: done=%v state=%+v", duplicateBatchDone, duplicateBatch) - } - mutatedBatch, batchDone, err := store.MarkBatchJobFailed(ctx, "compat-batch-mutate", "legacy-batch-job-2", errors.New("legacy compatible failure")) - if err != nil { - t.Fatalf("fail v1 batch job: %v", err) - } - if !batchDone || !mutatedBatch.Completed || mutatedBatch.Cancelled || mutatedBatch.Pending != 0 || mutatedBatch.Processed != 2 || mutatedBatch.Failed != 1 || mutatedBatch.CreatedAt.UnixMilli() != legacyCreatedMS || mutatedBatch.UpdatedAt.UnixMilli() <= legacyUpdatedMS { - t.Fatalf("mutated v1 batch state is inconsistent: done=%v state=%+v", batchDone, mutatedBatch) - } - assertLegacySQLCount(t, ctx, db, `SELECT COUNT(*) FROM bus_batch_jobs WHERE batch_id=? AND started=1 AND done=1`, 2, "compat-batch-mutate") - assertLegacySQLCount(t, ctx, db, `SELECT COUNT(*) FROM bus_batch_jobs WHERE batch_id=? AND failed=1`, 1, "compat-batch-mutate") - - if err := store.Prune(ctx, time.UnixMilli(pruneCutoffMS)); err != nil { - t.Fatalf("prune v1 persisted state: %v", err) - } - for _, chainID := range []string{"compat-chain-completed-old", "compat-chain-dual-terminal-old", "compat-chain-failed-old"} { - if _, findErr := store.GetChain(ctx, chainID); !errors.Is(findErr, ErrNotFound) { - t.Fatalf("old terminal chain %q survived prune: %v", chainID, findErr) - } - } - for _, chainID := range []string{"compat-chain-mutate", "compat-chain-active-old", "compat-chain-completed-recent"} { - if _, findErr := store.GetChain(ctx, chainID); findErr != nil { - t.Fatalf("retained chain %q was lost during prune: %v", chainID, findErr) - } - } - if _, findErr := store.GetBatch(ctx, "compat-batch-terminal-old"); !errors.Is(findErr, ErrNotFound) { - t.Fatalf("old terminal batch survived prune: %v", findErr) - } - for _, batchID := range []string{"compat-batch-mutate", "compat-batch-active-old", "compat-batch-terminal-recent"} { - if _, findErr := store.GetBatch(ctx, batchID); findErr != nil { - t.Fatalf("retained batch %q was lost during prune: %v", batchID, findErr) - } - } - - assertLegacySQLCount(t, ctx, db, `SELECT COUNT(*) FROM bus_chains`, 3) - assertLegacySQLCount(t, ctx, db, `SELECT COUNT(*) FROM bus_chain_completed_nodes`, 5) - assertLegacySQLCount(t, ctx, db, `SELECT COUNT(*) FROM bus_batches`, 3) - assertLegacySQLCount(t, ctx, db, `SELECT COUNT(*) FROM bus_batch_jobs`, 4) - assertLegacySQLCount(t, ctx, db, `SELECT COUNT(*) FROM bus_callback_invocations`, 2) - assertLegacySQLCount(t, ctx, db, `SELECT COUNT(*) FROM bus_callback_invocations WHERE callback_key=?`, 0, "chain_finally:compat-chain-completed-old") - assertLegacySQLCount(t, ctx, db, `SELECT COUNT(*) FROM bus_callback_invocations WHERE callback_key=?`, 1, "chain_finally:compat-chain-completed-recent") - assertLegacySQLCount(t, ctx, db, `SELECT COUNT(*) FROM bus_callback_invocations WHERE callback_key=?`, 1, "batch_finally:compat-batch-mutate") - assertLegacyNodesJSON(t, ctx, db, "compat-chain-active-old") - assertLegacySQLStoreSchema(t, ctx, db) -} - -// loadLegacySQLStoreFixture loads literal v1 SQL in one transaction so setup cannot leave a partially seeded compatibility database. -func loadLegacySQLStoreFixture(t *testing.T, ctx context.Context, db *sql.DB) { - t.Helper() - fixturePath := filepath.Join("testdata", "compat", "workflow", "v1", "sqlite.sql") - fixture, err := os.ReadFile(fixturePath) - if err != nil { - t.Fatalf("read compatibility fixture: %v", err) - } - tx, err := db.BeginTx(ctx, nil) - if err != nil { - t.Fatalf("begin compatibility fixture transaction: %v", err) - } - defer func() { _ = tx.Rollback() }() - for _, rawStatement := range strings.Split(string(fixture), ";") { - statement := strings.TrimSpace(rawStatement) - if statement == "" { - continue - } - if _, err := tx.ExecContext(ctx, statement); err != nil { - t.Fatalf("execute compatibility fixture statement: %v", err) - } - } - if err := tx.Commit(); err != nil { - t.Fatalf("commit compatibility fixture: %v", err) - } -} - -// seedLegacyDualTerminalChain adds a post-fixture anomaly without rewriting -// the frozen v1 compatibility baseline used to detect historical drift. -func seedLegacyDualTerminalChain(t *testing.T, ctx context.Context, db *sql.DB) { - t.Helper() - tx, err := db.BeginTx(ctx, nil) - if err != nil { - t.Fatalf("begin dual-terminal chain setup: %v", err) - } - defer func() { _ = tx.Rollback() }() - result, err := tx.ExecContext(ctx, `INSERT INTO bus_chains - (chain_id, dispatch_id, queue_name, nodes_json, next_index, completed, failed, failure, created_at_ms, updated_at_ms) - SELECT 'compat-chain-dual-terminal-old', 'compat-dispatch-dual-terminal-old', queue_name, nodes_json, next_index, completed, 1, 'late legacy failure', created_at_ms, updated_at_ms - FROM bus_chains WHERE chain_id='compat-chain-completed-old'`) - if err != nil { - t.Fatalf("insert dual-terminal chain: %v", err) - } - inserted, err := result.RowsAffected() - if err != nil { - t.Fatalf("count inserted dual-terminal chains: %v", err) - } - if inserted != 1 { - t.Fatalf("inserted dual-terminal chains=%d, want 1", inserted) - } - result, err = tx.ExecContext(ctx, `INSERT INTO bus_chain_completed_nodes (chain_id, node_id, created_at_ms) - SELECT 'compat-chain-dual-terminal-old', node_id, created_at_ms - FROM bus_chain_completed_nodes WHERE chain_id='compat-chain-completed-old'`) - if err != nil { - t.Fatalf("insert dual-terminal chain nodes: %v", err) - } - inserted, err = result.RowsAffected() - if err != nil { - t.Fatalf("count inserted dual-terminal chain nodes: %v", err) - } - if inserted != 2 { - t.Fatalf("inserted dual-terminal chain nodes=%d, want 2", inserted) - } - if err := tx.Commit(); err != nil { - t.Fatalf("commit dual-terminal chain setup: %v", err) - } -} - -// assertLegacySQLStoreSchema verifies v1 columns remain unchanged while the -// additive transition-receipt table is installed alongside them. -func assertLegacySQLStoreSchema(t *testing.T, ctx context.Context, db *sql.DB) { - t.Helper() - wantTables := []string{ - "bus_batch_jobs", - "bus_batches", - "bus_callback_invocations", - "bus_chain_completed_nodes", - "bus_chains", - "bus_workflow_transition_receipts", - } - wantColumns := map[string][]string{ - "bus_batch_jobs": {"batch_id", "job_id", "started", "done", "failed"}, - "bus_batches": {"batch_id", "dispatch_id", "name", "queue_name", "allow_failed", "total_jobs", "pending_jobs", "processed_jobs", "failed_jobs", "cancelled", "completed", "created_at_ms", "updated_at_ms"}, - "bus_callback_invocations": {"callback_key", "created_at_ms"}, - "bus_chain_completed_nodes": {"chain_id", "node_id", "created_at_ms"}, - "bus_chains": {"chain_id", "dispatch_id", "queue_name", "nodes_json", "next_index", "completed", "failed", "failure", "created_at_ms", "updated_at_ms"}, - "bus_workflow_transition_receipts": {"workflow_kind", "receipt_version", "event_schema_version", "workflow_id", "member_id", "workflow_dispatch_id", "workflow_created_at_ms", "outcome", "owner_delivery_id", "owner_attempt", "job_dispatch_id", "job_id", "job_fingerprint", "aggregate_completed", "aggregate_cancelled", "created_at_ms"}, - } - - rows, err := db.QueryContext(ctx, `SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'bus_%' ORDER BY name`) - if err != nil { - t.Fatalf("query compatibility tables: %v", err) - } - var gotTables []string - for rows.Next() { - var table string - if err := rows.Scan(&table); err != nil { - _ = rows.Close() - t.Fatalf("scan compatibility table: %v", err) - } - gotTables = append(gotTables, table) - } - if err := rows.Close(); err != nil { - t.Fatalf("close compatibility table rows: %v", err) - } - if err := rows.Err(); err != nil { - t.Fatalf("iterate compatibility tables: %v", err) - } - if !slices.Equal(gotTables, wantTables) { - t.Fatalf("compatibility tables=%v, want %v", gotTables, wantTables) - } - - for _, table := range wantTables { - columns, err := legacySQLColumnNames(ctx, db, table) - if err != nil { - t.Fatalf("query columns for %s: %v", table, err) - } - if !slices.Equal(columns, wantColumns[table]) { - t.Fatalf("compatibility columns for %s=%v, want %v", table, columns, wantColumns[table]) - } - } -} - -// legacySQLColumnNames returns SQLite column names in declaration order for one frozen fixture table. -func legacySQLColumnNames(ctx context.Context, db *sql.DB, table string) ([]string, error) { - rows, err := db.QueryContext(ctx, fmt.Sprintf("PRAGMA table_info(%q)", table)) - if err != nil { - return nil, err - } - defer rows.Close() - var columns []string - for rows.Next() { - var ( - columnID int - name string - columnType string - notNull int - defaultValue any - primaryKey int - ) - if err := rows.Scan(&columnID, &name, &columnType, ¬Null, &defaultValue, &primaryKey); err != nil { - return nil, err - } - columns = append(columns, name) - } - if err := rows.Err(); err != nil { - return nil, err - } - return columns, nil -} - -// assertLegacyNodesJSON verifies state mutations never rewrite the persisted v1 node envelope. -func assertLegacyNodesJSON(t *testing.T, ctx context.Context, db *sql.DB, chainID string) { - t.Helper() - var nodesJSON string - if err := db.QueryRowContext(ctx, `SELECT nodes_json FROM bus_chains WHERE chain_id=?`, chainID).Scan(&nodesJSON); err != nil { - t.Fatalf("read nodes_json for %s: %v", chainID, err) - } - if nodesJSON != legacyV1NodesJSON { - t.Fatalf("nodes_json for %s changed:\n got: %s\nwant: %s", chainID, nodesJSON, legacyV1NodesJSON) - } -} - -// assertLegacySQLCount verifies a compatibility row set has the exact expected cardinality. -func assertLegacySQLCount(t *testing.T, ctx context.Context, db *sql.DB, query string, want int, args ...any) { - t.Helper() - var got int - if err := db.QueryRowContext(ctx, query, args...).Scan(&got); err != nil { - t.Fatalf("query compatibility row count: %v", err) - } - if got != want { - t.Fatalf("compatibility row count=%d, want %d for %q", got, want, query) - } -} diff --git a/bus/test_runtime_helper_test.go b/bus/test_runtime_helper_test.go deleted file mode 100644 index 3b2e76a..0000000 --- a/bus/test_runtime_helper_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package bus_test - -import ( - "fmt" - - "github.com/goforj/queue" - "github.com/goforj/queue/busruntime" - "github.com/goforj/queue/internal/testbridge" -) - -type busTestRuntime interface { - busruntime.Runtime - Dispatch(job any) error -} - -func newBusTestRuntime(cfg queue.Config) (busTestRuntime, error) { - q, err := queue.New(cfg) - if err != nil { - return nil, err - } - raw, err := testbridge.RuntimeFromQueue(q) - if err != nil { - return nil, err - } - rt, ok := raw.(busTestRuntime) - if !ok { - return nil, fmt.Errorf("unexpected runtime type %T", raw) - } - return rt, nil -} diff --git a/bus/testdata/compat/workflow/v1/sqlite.sql b/bus/testdata/compat/workflow/v1/sqlite.sql deleted file mode 100644 index 421f614..0000000 --- a/bus/testdata/compat/workflow/v1/sqlite.sql +++ /dev/null @@ -1,89 +0,0 @@ -CREATE TABLE bus_chains ( - chain_id TEXT PRIMARY KEY, - dispatch_id TEXT NOT NULL, - queue_name TEXT NOT NULL, - nodes_json BLOB NOT NULL, - next_index INTEGER NOT NULL, - completed INTEGER NOT NULL, - failed INTEGER NOT NULL, - failure TEXT NOT NULL, - created_at_ms BIGINT NOT NULL, - updated_at_ms BIGINT NOT NULL -); - -CREATE TABLE bus_chain_completed_nodes ( - chain_id TEXT NOT NULL, - node_id TEXT NOT NULL, - created_at_ms BIGINT NOT NULL, - PRIMARY KEY (chain_id, node_id) -); - -CREATE TABLE bus_batches ( - batch_id TEXT PRIMARY KEY, - dispatch_id TEXT NOT NULL, - name TEXT NOT NULL, - queue_name TEXT NOT NULL, - allow_failed INTEGER NOT NULL, - total_jobs INTEGER NOT NULL, - pending_jobs INTEGER NOT NULL, - processed_jobs INTEGER NOT NULL, - failed_jobs INTEGER NOT NULL, - cancelled INTEGER NOT NULL, - completed INTEGER NOT NULL, - created_at_ms BIGINT NOT NULL, - updated_at_ms BIGINT NOT NULL -); - -CREATE TABLE bus_batch_jobs ( - batch_id TEXT NOT NULL, - job_id TEXT NOT NULL, - started INTEGER NOT NULL, - done INTEGER NOT NULL, - failed INTEGER NOT NULL, - PRIMARY KEY (batch_id, job_id) -); - -CREATE TABLE bus_callback_invocations ( - callback_key TEXT PRIMARY KEY, - created_at_ms BIGINT NOT NULL -); - -INSERT INTO bus_chains - (chain_id, dispatch_id, queue_name, nodes_json, next_index, completed, failed, failure, created_at_ms, updated_at_ms) -VALUES - ('compat-chain-mutate', 'compat-dispatch-mutate', 'critical', '[{"NodeID":"legacy-node-1","Job":{"type":"reports:build","payload":"eyJpZCI6MX0=","options":{"Queue":"critical","Delay":2000000000,"Timeout":15000000000,"Retry":4,"Backoff":500000000,"UniqueFor":30000000000}}},{"NodeID":"legacy-node-2","Job":{"type":"reports:notify","payload":"bnVsbA==","options":{"Queue":"critical","Delay":0,"Timeout":0,"Retry":0,"Backoff":0,"UniqueFor":0}}}]', 0, 0, 0, '', 1704067200123, 1704067201123), - ('compat-chain-active-old', 'compat-dispatch-active-old', 'default', '[{"NodeID":"legacy-node-1","Job":{"type":"reports:build","payload":"eyJpZCI6MX0=","options":{"Queue":"critical","Delay":2000000000,"Timeout":15000000000,"Retry":4,"Backoff":500000000,"UniqueFor":30000000000}}},{"NodeID":"legacy-node-2","Job":{"type":"reports:notify","payload":"bnVsbA==","options":{"Queue":"critical","Delay":0,"Timeout":0,"Retry":0,"Backoff":0,"UniqueFor":0}}}]', 1, 0, 0, '', 1704067200123, 1704067205000), - ('compat-chain-completed-old', 'compat-dispatch-completed-old', 'default', '[{"NodeID":"legacy-node-1","Job":{"type":"reports:build","payload":"eyJpZCI6MX0=","options":{"Queue":"critical","Delay":2000000000,"Timeout":15000000000,"Retry":4,"Backoff":500000000,"UniqueFor":30000000000}}},{"NodeID":"legacy-node-2","Job":{"type":"reports:notify","payload":"bnVsbA==","options":{"Queue":"critical","Delay":0,"Timeout":0,"Retry":0,"Backoff":0,"UniqueFor":0}}}]', 2, 1, 0, '', 1704067200123, 1704067205000), - ('compat-chain-failed-old', 'compat-dispatch-failed-old', 'default', '[{"NodeID":"legacy-node-1","Job":{"type":"reports:build","payload":"eyJpZCI6MX0=","options":{"Queue":"critical","Delay":2000000000,"Timeout":15000000000,"Retry":4,"Backoff":500000000,"UniqueFor":30000000000}}},{"NodeID":"legacy-node-2","Job":{"type":"reports:notify","payload":"bnVsbA==","options":{"Queue":"critical","Delay":0,"Timeout":0,"Retry":0,"Backoff":0,"UniqueFor":0}}}]', 1, 0, 1, 'legacy failure', 1704067200123, 1704067205000), - ('compat-chain-completed-recent', 'compat-dispatch-completed-recent', 'critical', '[{"NodeID":"legacy-node-1","Job":{"type":"reports:build","payload":"eyJpZCI6MX0=","options":{"Queue":"critical","Delay":2000000000,"Timeout":15000000000,"Retry":4,"Backoff":500000000,"UniqueFor":30000000000}}},{"NodeID":"legacy-node-2","Job":{"type":"reports:notify","payload":"bnVsbA==","options":{"Queue":"critical","Delay":0,"Timeout":0,"Retry":0,"Backoff":0,"UniqueFor":0}}}]', 2, 1, 0, '', 1706000000123, 1706000001123); - -INSERT INTO bus_chain_completed_nodes (chain_id, node_id, created_at_ms) -VALUES - ('compat-chain-active-old', 'legacy-node-1', 1704067204000), - ('compat-chain-completed-old', 'legacy-node-1', 1704067203000), - ('compat-chain-completed-old', 'legacy-node-2', 1704067204000), - ('compat-chain-failed-old', 'legacy-node-1', 1704067203000), - ('compat-chain-completed-recent', 'legacy-node-1', 1706000000123), - ('compat-chain-completed-recent', 'legacy-node-2', 1706000001123); - -INSERT INTO bus_batches - (batch_id, dispatch_id, name, queue_name, allow_failed, total_jobs, pending_jobs, processed_jobs, failed_jobs, cancelled, completed, created_at_ms, updated_at_ms) -VALUES - ('compat-batch-mutate', 'compat-batch-dispatch-mutate', 'legacy mutable batch', 'bulk', 1, 2, 2, 0, 0, 0, 0, 1704067200123, 1704067201123), - ('compat-batch-active-old', 'compat-batch-dispatch-active-old', 'legacy active batch', 'default', 0, 1, 1, 0, 0, 0, 0, 1704067200123, 1704067205000), - ('compat-batch-terminal-old', 'compat-batch-dispatch-terminal-old', 'legacy terminal batch', 'bulk', 1, 2, 0, 2, 1, 0, 1, 1704067200123, 1704067205000), - ('compat-batch-terminal-recent', 'compat-batch-dispatch-terminal-recent', 'recent terminal batch', 'critical', 0, 1, 0, 1, 0, 0, 1, 1706000000123, 1706000001123); - -INSERT INTO bus_batch_jobs (batch_id, job_id, started, done, failed) -VALUES - ('compat-batch-mutate', 'legacy-batch-job-1', 0, 0, 0), - ('compat-batch-mutate', 'legacy-batch-job-2', 0, 0, 0), - ('compat-batch-active-old', 'legacy-active-job-1', 0, 0, 0), - ('compat-batch-terminal-old', 'legacy-terminal-job-1', 1, 1, 0), - ('compat-batch-terminal-old', 'legacy-terminal-job-2', 1, 1, 1), - ('compat-batch-terminal-recent', 'legacy-recent-job-1', 1, 1, 0); - -INSERT INTO bus_callback_invocations (callback_key, created_at_ms) -VALUES - ('chain_finally:compat-chain-completed-old', 1704067205000), - ('chain_finally:compat-chain-completed-recent', 1706000001123); diff --git a/bus/testhooks_integration.go b/bus/testhooks_integration.go deleted file mode 100644 index e06485c..0000000 --- a/bus/testhooks_integration.go +++ /dev/null @@ -1,74 +0,0 @@ -//go:build integration - -package bus - -import ( - "context" - "encoding/json" - "fmt" - - "github.com/goforj/queue/busruntime" - "github.com/goforj/queue/internal/workflow" -) - -type integrationTestInboundJob struct { - payload []byte -} - -// Bind decodes one integration delivery into the engine envelope. -func (j integrationTestInboundJob) Bind(dst any) error { - return json.Unmarshal(j.payload, dst) -} - -// PayloadBytes returns a copy of the integration delivery payload. -func (j integrationTestInboundJob) PayloadBytes() []byte { - return append([]byte(nil), j.payload...) -} - -// IntegrationTestRuntime is a minimal in-memory runtime used by integration tests -// that need to dispatch physical workflow deliveries directly. -type IntegrationTestRuntime struct { - handlers map[string]busruntime.Handler -} - -// NewIntegrationTestRuntime creates an in-memory raw runtime for integration fixtures. -func NewIntegrationTestRuntime() *IntegrationTestRuntime { - return &IntegrationTestRuntime{handlers: make(map[string]busruntime.Handler)} -} - -// BusRegister records a physical workflow handler by delivery type. -func (r *IntegrationTestRuntime) BusRegister(jobType string, handler busruntime.Handler) { - if r.handlers == nil { - r.handlers = make(map[string]busruntime.Handler) - } - r.handlers[jobType] = handler -} - -// BusDispatch invokes the physical workflow handler synchronously. -func (r *IntegrationTestRuntime) BusDispatch(ctx context.Context, jobType string, payload []byte, _ busruntime.JobOptions) error { - h, ok := r.handlers[jobType] - if !ok || h == nil { - return fmt.Errorf("handler not registered for %q", jobType) - } - return h(ctx, integrationTestInboundJob{payload: append([]byte(nil), payload...)}) -} - -// StartWorkers is inert because the integration runtime dispatches synchronously. -func (r *IntegrationTestRuntime) StartWorkers(context.Context) error { return nil } - -// Shutdown is inert because the integration runtime owns no asynchronous resources. -func (r *IntegrationTestRuntime) Shutdown(context.Context) error { return nil } - -// DispatchJSON encodes a literal integration envelope before physical dispatch. -func (r *IntegrationTestRuntime) DispatchJSON(ctx context.Context, jobType string, payload any) error { - b, err := json.Marshal(payload) - if err != nil { - return err - } - return r.BusDispatch(ctx, jobType, b, busruntime.JobOptions{}) -} - -// InternalCallbackJobTypeForIntegration returns the version-one callback delivery name. -func InternalCallbackJobTypeForIntegration() string { - return workflow.CallbackDeliveryType -} diff --git a/bus/testhooks_integration_test.go b/bus/testhooks_integration_test.go deleted file mode 100644 index ce8145f..0000000 --- a/bus/testhooks_integration_test.go +++ /dev/null @@ -1,90 +0,0 @@ -//go:build integration - -package bus - -import ( - "context" - "errors" - "testing" - - "github.com/goforj/queue/busruntime" - "github.com/goforj/queue/internal/workflow" -) - -// integrationHookPayload gives both direct bytes and JSON dispatch a concrete bind target. -type integrationHookPayload struct { - Value int `json:"value"` -} - -// integrationHookMarshalFailure makes the integration hook's encoding failure deterministic. -type integrationHookMarshalFailure struct { - err error -} - -// MarshalJSON returns the configured error so DispatchJSON's encoding boundary remains observable. -func (p integrationHookMarshalFailure) MarshalJSON() ([]byte, error) { - return nil, p.err -} - -// TestIntegrationTestRuntimeExercisesPhysicalDispatchHooks verifies the -// integration-only runtime preserves registration, payload, and error behavior. -func TestIntegrationTestRuntimeExercisesPhysicalDispatchHooks(t *testing.T) { - runtime := NewIntegrationTestRuntime() - if err := runtime.StartWorkers(context.Background()); err != nil { - t.Fatalf("start integration runtime: %v", err) - } - if err := runtime.Shutdown(context.Background()); err != nil { - t.Fatalf("shutdown integration runtime: %v", err) - } - if err := runtime.BusDispatch(context.Background(), "missing", nil, busruntime.JobOptions{}); err == nil { - t.Fatal("missing integration handler was accepted") - } - runtime.BusRegister("nil-handler", nil) - if err := runtime.BusDispatch(context.Background(), "nil-handler", nil, busruntime.JobOptions{}); err == nil { - t.Fatal("nil integration handler was accepted") - } - - var zeroValueRuntime IntegrationTestRuntime - var ( - delivered busruntime.InboundJob - got integrationHookPayload - ) - zeroValueRuntime.BusRegister("integration:payload", func(_ context.Context, job busruntime.InboundJob) error { - delivered = job - first := job.PayloadBytes() - first[0] = '!' - if string(job.PayloadBytes()) != `{"value":7}` { - return errors.New("integration payload bytes were not isolated") - } - if err := job.Bind(&got); err != nil { - return err - } - return nil - }) - rawPayload := []byte(`{"value":7}`) - if err := zeroValueRuntime.BusDispatch(context.Background(), "integration:payload", rawPayload, busruntime.JobOptions{}); err != nil { - t.Fatalf("dispatch integration bytes: %v", err) - } - if string(rawPayload) != `{"value":7}` || got.Value != 7 { - t.Fatalf("raw integration payload/bound value = %q/%+v", rawPayload, got) - } - rawPayload[0] = '!' - if string(delivered.PayloadBytes()) != `{"value":7}` { - t.Fatal("integration delivery retained the caller's mutable payload") - } - got = integrationHookPayload{} - if err := zeroValueRuntime.DispatchJSON(context.Background(), "integration:payload", integrationHookPayload{Value: 7}); err != nil { - t.Fatalf("dispatch integration JSON: %v", err) - } - if got.Value != 7 { - t.Fatalf("bound integration payload = %+v", got) - } - - marshalErr := errors.New("integration payload encoding failed") - if err := zeroValueRuntime.DispatchJSON(context.Background(), "integration:payload", integrationHookMarshalFailure{err: marshalErr}); !errors.Is(err, marshalErr) { - t.Fatalf("DispatchJSON encoding error = %v, want %v", err, marshalErr) - } - if got := InternalCallbackJobTypeForIntegration(); got != workflow.CallbackDeliveryType { - t.Fatalf("callback delivery type = %q, want %q", got, workflow.CallbackDeliveryType) - } -} diff --git a/bus/types.go b/bus/types.go deleted file mode 100644 index 318057b..0000000 --- a/bus/types.go +++ /dev/null @@ -1,100 +0,0 @@ -package bus - -import ( - "context" - "time" - - "github.com/goforj/queue" -) - -// Handler processes one legacy workflow message. -// -// Deprecated: register handlers on queue.Queue. -type Handler func(ctx context.Context, message Context) error - -// Job is the legacy workflow dispatch DTO. -// -// Deprecated: use queue.Job. This type remains distinct because its public -// fields and deferred JSON encoding are part of the compatibility contract. -type Job struct { - Type string - Payload any - Options JobOptions -} - -// NewJob creates a typed legacy workflow job with optional fluent options. -// -// Deprecated: use queue.NewJob and its Payload method. -// @group Constructors -func NewJob(jobType string, payload any) Job { - return Job{Type: jobType, Payload: payload} -} - -// OnQueue sets the target queue for this job. -// -// Deprecated: use queue.Job.OnQueue. -// @group Job -func (j Job) OnQueue(name string) Job { - j.Options.Queue = name - return j -} - -// Delay defers job execution. -// -// Deprecated: use queue.Job.Delay. -// @group Job -func (j Job) Delay(delay time.Duration) Job { - j.Options.Delay = delay - return j -} - -// Timeout sets the execution timeout for this job. -// -// Deprecated: use queue.Job.Timeout. -// @group Job -func (j Job) Timeout(timeout time.Duration) Job { - j.Options.Timeout = timeout - return j -} - -// Retry sets the maximum retry count for this job. -// -// Deprecated: use queue.Job.Retry. -// @group Job -func (j Job) Retry(max int) Job { - j.Options.Retry = max - return j -} - -// Backoff sets retry backoff for this job. -// -// Deprecated: use queue.Job.Backoff. -// @group Job -func (j Job) Backoff(backoff time.Duration) Job { - j.Options.Backoff = backoff - return j -} - -// UniqueFor sets the deduplication TTL for this job. -// -// Deprecated: use queue.Job.UniqueFor. -// @group Job -func (j Job) UniqueFor(ttl time.Duration) Job { - j.Options.UniqueFor = ttl - return j -} - -// JobOptions contains the legacy workflow delivery options. -// -// Deprecated: configure a queue.Job through its fluent methods. -type JobOptions = queue.StoredJobOptions - -// DispatchResult describes an accepted workflow dispatch. -// -// Deprecated: use queue.DispatchResult. -type DispatchResult = queue.DispatchResult - -// Context contains a delivered workflow message and its correlation metadata. -// -// Deprecated: use queue.Message. -type Context = queue.Message diff --git a/bus/wire_compat_test.go b/bus/wire_compat_test.go deleted file mode 100644 index fb18741..0000000 --- a/bus/wire_compat_test.go +++ /dev/null @@ -1,304 +0,0 @@ -package bus_test - -import ( - "context" - "encoding/json" - "errors" - "reflect" - "regexp" - "testing" - "time" - - "github.com/goforj/queue/bus" - "github.com/goforj/queue/busruntime" -) - -var legacyWorkflowIDPattern = regexp.MustCompile(`\b(?:dsp|job|chn|bat|n)_[0-9a-f]{16}\b`) - -type legacyWireCall struct { - jobType string - payload []byte - options busruntime.JobOptions -} - -type legacyWireRuntime struct { - handlers map[string]busruntime.Handler - calls []legacyWireCall - execute bool -} - -// newLegacyWireRuntime creates a transport recorder that can optionally execute registered workflow deliveries inline. -func newLegacyWireRuntime(execute bool) *legacyWireRuntime { - return &legacyWireRuntime{ - handlers: make(map[string]busruntime.Handler), - execute: execute, - } -} - -// BusRegister retains the exact physical handler names selected by the workflow engine. -func (r *legacyWireRuntime) BusRegister(jobType string, handler busruntime.Handler) { - r.handlers[jobType] = handler -} - -// BusDispatch records bytes before optional execution so the fixture observes the transport boundary. -func (r *legacyWireRuntime) BusDispatch(ctx context.Context, jobType string, payload []byte, options busruntime.JobOptions) error { - r.calls = append(r.calls, legacyWireCall{ - jobType: jobType, - payload: append([]byte(nil), payload...), - options: options, - }) - if !r.execute { - return nil - } - handler := r.handlers[jobType] - if handler == nil { - return errors.New("legacy wire runtime handler is not registered") - } - return handler(ctx, legacyInboundJob{payload: payload}) -} - -// StartWorkers is inert because compatibility fixtures execute only at the SPI boundary. -func (r *legacyWireRuntime) StartWorkers(context.Context) error { return nil } - -// Shutdown is inert because compatibility fixtures own no asynchronous resources. -func (r *legacyWireRuntime) Shutdown(context.Context) error { return nil } - -type legacyInboundJob struct { - payload []byte -} - -// Bind decodes the recorded workflow delivery exactly as a physical worker would. -func (j legacyInboundJob) Bind(dst any) error { - return json.Unmarshal(j.payload, dst) -} - -// PayloadBytes returns an isolated copy so handlers cannot mutate recorded compatibility evidence. -func (j legacyInboundJob) PayloadBytes() []byte { - return append([]byte(nil), j.payload...) -} - -type frozenV1Envelope struct { - SchemaVersion int `json:"schema_version"` - DispatchID string `json:"dispatch_id"` - Kind string `json:"kind"` - JobID string `json:"job_id"` - ChainID string `json:"chain_id"` - BatchID string `json:"batch_id"` - NodeID string `json:"node_id"` - Attempt int `json:"attempt"` - Job frozenV1Job `json:"job"` - CallbackKind string `json:"callback_kind"` - Error string `json:"error"` -} - -type frozenV1Job struct { - Type string `json:"type"` - Payload []byte `json:"payload"` - Options frozenV1JobOption `json:"options"` -} - -type frozenV1JobOption struct { - Queue string - Delay time.Duration - Timeout time.Duration - Retry int - Backoff time.Duration - UniqueFor time.Duration -} - -type fixedJSONPayload struct{} - -// MarshalJSON pins deferred legacy payload encoding independently of the payload's Go representation. -func (fixedJSONPayload) MarshalJSON() ([]byte, error) { - return []byte(`{"custom":true}`), nil -} - -type failingJSONPayload struct{} - -// MarshalJSON proves legacy payload errors still occur at Dispatch rather than NewJob. -func (failingJSONPayload) MarshalJSON() ([]byte, error) { - return nil, errors.New("compat marshal failure") -} - -// TestLegacyDirectWorkflowWireV1 freezes the physical type, JSON field order, option casing, and transport options. -func TestLegacyDirectWorkflowWireV1(t *testing.T) { - runtime := newLegacyWireRuntime(false) - workflow, err := bus.New(runtime) - if err != nil { - t.Fatalf("new bus: %v", err) - } - job := bus.NewJob("compat:job", map[string]int{"id": 7}). - OnQueue("critical"). - Delay(2 * time.Second). - Timeout(3 * time.Second). - Retry(4). - Backoff(500 * time.Millisecond). - UniqueFor(30 * time.Second) - if _, err := workflow.Dispatch(context.Background(), job); err != nil { - t.Fatalf("dispatch: %v", err) - } - if len(runtime.calls) != 1 { - t.Fatalf("physical dispatch count = %d, want 1", len(runtime.calls)) - } - call := runtime.calls[0] - if call.jobType != "bus:job" { - t.Fatalf("physical type = %q, want bus:job", call.jobType) - } - gotJSON := legacyWorkflowIDPattern.ReplaceAllString(string(call.payload), "ID") - wantJSON := `{"schema_version":1,"dispatch_id":"ID","kind":"job","job_id":"ID","attempt":0,"job":{"type":"compat:job","payload":"eyJpZCI6N30=","options":{"Queue":"critical","Delay":2000000000,"Timeout":3000000000,"Retry":4,"Backoff":500000000,"UniqueFor":30000000000}}}` - if gotJSON != wantJSON { - t.Fatalf("workflow envelope changed:\n got: %s\nwant: %s", gotJSON, wantJSON) - } - wantOptions := busruntime.JobOptions{ - Queue: "critical", - Delay: 2 * time.Second, - Timeout: 3 * time.Second, - Retry: 4, - Backoff: 500 * time.Millisecond, - UniqueFor: 30 * time.Second, - } - if !reflect.DeepEqual(call.options, wantOptions) { - t.Fatalf("transport options = %+v, want %+v", call.options, wantOptions) - } -} - -// TestLegacyPayloadEncodingV1 preserves the compatibility DTO's deferred json.Marshal semantics. -func TestLegacyPayloadEncodingV1(t *testing.T) { - tests := []struct { - name string - payload any - want string - }{ - {name: "nil", payload: nil, want: "null"}, - {name: "map", payload: map[string]bool{"ready": true}, want: `{"ready":true}`}, - {name: "string", payload: "raw", want: `"raw"`}, - {name: "bytes", payload: []byte{0, 1, 2}, want: `"AAEC"`}, - {name: "raw message", payload: json.RawMessage(`{"raw":true}`), want: `{"raw":true}`}, - {name: "custom marshaler", payload: fixedJSONPayload{}, want: `{"custom":true}`}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - runtime := newLegacyWireRuntime(false) - workflow, err := bus.New(runtime) - if err != nil { - t.Fatalf("new bus: %v", err) - } - if _, err := workflow.Dispatch(context.Background(), bus.NewJob("compat:payload", tt.payload)); err != nil { - t.Fatalf("dispatch: %v", err) - } - var envelope frozenV1Envelope - if err := json.Unmarshal(runtime.calls[0].payload, &envelope); err != nil { - t.Fatalf("decode envelope: %v", err) - } - if string(envelope.Job.Payload) != tt.want { - t.Fatalf("encoded payload = %q, want %q", envelope.Job.Payload, tt.want) - } - }) - } - - runtime := newLegacyWireRuntime(false) - workflow, err := bus.New(runtime) - if err != nil { - t.Fatalf("new bus: %v", err) - } - job := bus.NewJob("compat:payload", failingJSONPayload{}) - if len(runtime.calls) != 0 { - t.Fatal("NewJob unexpectedly encoded or dispatched the payload") - } - if _, err := workflow.Dispatch(context.Background(), job); err == nil || err.Error() != "json: error calling MarshalJSON for type bus_test.failingJSONPayload: compat marshal failure" { - t.Fatalf("dispatch error = %v, want deferred marshal failure", err) - } - if len(runtime.calls) != 0 { - t.Fatal("marshal failure reached the physical queue") - } -} - -// TestLegacyWorkflowDeliveryNamesV1 pins every physical orchestration route and callback kind. -func TestLegacyWorkflowDeliveryNamesV1(t *testing.T) { - tests := []struct { - name string - dispatch func(bus.Bus) error - wantTypes []string - wantCallbacks []string - }{ - { - name: "chain success", - dispatch: func(workflow bus.Bus) error { - workflow.Register("compat:ok", func(context.Context, bus.Context) error { return nil }) - _, err := workflow.Chain(bus.NewJob("compat:ok", nil)). - Finally(func(context.Context, bus.ChainState) error { return nil }). - Dispatch(context.Background()) - return err - }, - wantTypes: []string{"bus:chain:node", "bus:callback"}, - wantCallbacks: []string{"", "chain_finally"}, - }, - { - name: "chain failure", - dispatch: func(workflow bus.Bus) error { - workflow.Register("compat:fail", func(context.Context, bus.Context) error { return errors.New("chain failed") }) - _, err := workflow.Chain(bus.NewJob("compat:fail", nil)). - Catch(func(context.Context, bus.ChainState, error) error { return nil }). - Finally(func(context.Context, bus.ChainState) error { return nil }). - Dispatch(context.Background()) - return err - }, - wantTypes: []string{"bus:chain:node", "bus:callback", "bus:callback"}, - wantCallbacks: []string{"", "chain_catch", "chain_finally"}, - }, - { - name: "batch success", - dispatch: func(workflow bus.Bus) error { - workflow.Register("compat:ok", func(context.Context, bus.Context) error { return nil }) - _, err := workflow.Batch(bus.NewJob("compat:ok", nil)). - Then(func(context.Context, bus.BatchState) error { return nil }). - Finally(func(context.Context, bus.BatchState) error { return nil }). - Dispatch(context.Background()) - return err - }, - wantTypes: []string{"bus:batch:job", "bus:callback", "bus:callback"}, - wantCallbacks: []string{"", "batch_then", "batch_finally"}, - }, - { - name: "batch failure", - dispatch: func(workflow bus.Bus) error { - workflow.Register("compat:fail", func(context.Context, bus.Context) error { return errors.New("batch failed") }) - _, err := workflow.Batch(bus.NewJob("compat:fail", nil)). - Catch(func(context.Context, bus.BatchState, error) error { return nil }). - Finally(func(context.Context, bus.BatchState) error { return nil }). - Dispatch(context.Background()) - return err - }, - wantTypes: []string{"bus:batch:job", "bus:callback", "bus:callback"}, - wantCallbacks: []string{"", "batch_catch", "batch_finally"}, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - runtime := newLegacyWireRuntime(true) - workflow, err := bus.New(runtime) - if err != nil { - t.Fatalf("new bus: %v", err) - } - _ = tt.dispatch(workflow) - if len(runtime.calls) != len(tt.wantTypes) { - t.Fatalf("physical dispatch count = %d, want %d", len(runtime.calls), len(tt.wantTypes)) - } - for i, call := range runtime.calls { - if call.jobType != tt.wantTypes[i] { - t.Fatalf("physical type[%d] = %q, want %q", i, call.jobType, tt.wantTypes[i]) - } - var envelope frozenV1Envelope - if err := json.Unmarshal(call.payload, &envelope); err != nil { - t.Fatalf("decode envelope[%d]: %v", i, err) - } - if envelope.SchemaVersion != 1 { - t.Fatalf("schema version[%d] = %d, want 1", i, envelope.SchemaVersion) - } - if envelope.CallbackKind != tt.wantCallbacks[i] { - t.Fatalf("callback kind[%d] = %q, want %q", i, envelope.CallbackKind, tt.wantCallbacks[i]) - } - } - }) - } -} diff --git a/bus/workflow_adapter_behavior_test.go b/bus/workflow_adapter_behavior_test.go deleted file mode 100644 index 6396f33..0000000 --- a/bus/workflow_adapter_behavior_test.go +++ /dev/null @@ -1,190 +0,0 @@ -package bus - -import ( - "context" - "encoding/json" - "errors" - "testing" - - "github.com/goforj/queue" - "github.com/goforj/queue/busruntime" -) - -// adapterBranchInboundJob keeps the raw-runtime test at the same serialized -// boundary used by real queue adapters. -type adapterBranchInboundJob struct { - payload []byte -} - -// Bind decodes one physical delivery for the workflow engine. -func (j adapterBranchInboundJob) Bind(dst any) error { - return json.Unmarshal(j.payload, dst) -} - -// PayloadBytes returns an isolated view of the physical delivery. -func (j adapterBranchInboundJob) PayloadBytes() []byte { - return append([]byte(nil), j.payload...) -} - -// adapterBranchRuntime executes registered deliveries synchronously so callback -// conversion is observed rather than merely retained in process-local state. -type adapterBranchRuntime struct { - handlers map[string]busruntime.Handler -} - -// BusRegister records a workflow delivery handler. -func (r *adapterBranchRuntime) BusRegister(jobType string, handler busruntime.Handler) { - if r.handlers == nil { - r.handlers = make(map[string]busruntime.Handler) - } - r.handlers[jobType] = handler -} - -// BusDispatch invokes the registered delivery at the serialized runtime seam. -func (r *adapterBranchRuntime) BusDispatch(ctx context.Context, jobType string, payload []byte, _ busruntime.JobOptions) error { - handler := r.handlers[jobType] - if handler == nil { - return errors.New("adapter branch handler is not registered") - } - return handler(ctx, adapterBranchInboundJob{payload: append([]byte(nil), payload...)}) -} - -// StartWorkers is inert because this test runtime executes synchronously. -func (r *adapterBranchRuntime) StartWorkers(context.Context) error { - return nil -} - -// Shutdown is inert because this test runtime owns no asynchronous work. -func (r *adapterBranchRuntime) Shutdown(context.Context) error { - return nil -} - -// adapterBranchStore records compatibility-store calls whose fallback methods -// are bypassed when the additive atomic store capability is present. -type adapterBranchStore struct { - Store - failChainID string - failCause error - failErr error - - successBatchID string - successJobID string - successState queue.BatchState - successDone bool - successErr error - - failureBatchID string - failureJobID string - failureCause error - failureState queue.BatchState - failureDone bool - failureErr error -} - -// FailChain records the cause without changing its identity. -func (s *adapterBranchStore) FailChain(_ context.Context, chainID string, cause error) error { - s.failChainID = chainID - s.failCause = cause - return s.failErr -} - -// MarkBatchJobSucceeded returns configured legacy aggregate state. -func (s *adapterBranchStore) MarkBatchJobSucceeded(_ context.Context, batchID, jobID string) (queue.BatchState, bool, error) { - s.successBatchID = batchID - s.successJobID = jobID - return s.successState, s.successDone, s.successErr -} - -// MarkBatchJobFailed records the delivery-local cause and returns configured state. -func (s *adapterBranchStore) MarkBatchJobFailed(_ context.Context, batchID, jobID string, cause error) (queue.BatchState, bool, error) { - s.failureBatchID = batchID - s.failureJobID = jobID - s.failureCause = cause - return s.failureState, s.failureDone, s.failureErr -} - -// TestRawRuntimeAdapterRejectsUnsupportedInputs preserves actionable errors at -// both compatibility construction boundaries. -func TestRawRuntimeAdapterRejectsUnsupportedInputs(t *testing.T) { - if compatibility, err := NewWithStore((*queue.Queue)(nil), NewMemoryStore()); compatibility != nil || err == nil || err.Error() != "queue is required" { - t.Fatalf("typed nil queue construction = bus:%v err:%v, want nil/queue is required", compatibility, err) - } - if compatibility, err := New(struct{}{}); compatibility != nil || err == nil || err.Error() != "queue does not support bus runtime adapter" { - t.Fatalf("unsupported runtime construction = bus:%v err:%v", compatibility, err) - } -} - -// TestRawRuntimeBatchProgressConvertsCommittedState proves a non-nil legacy -// progress callback observes the canonical engine state after member settlement. -func TestRawRuntimeBatchProgressConvertsCommittedState(t *testing.T) { - runtime := &adapterBranchRuntime{} - compatibility, err := New(runtime) - if err != nil { - t.Fatalf("new raw runtime adapter: %v", err) - } - compatibility.Register("adapter:batch-progress", func(context.Context, Context) error { return nil }) - - var ( - progressCalls int - progressState BatchState - ) - batchID, err := compatibility.Batch(NewJob("adapter:batch-progress", map[string]int{"id": 7})). - Progress(func(_ context.Context, state BatchState) error { - progressCalls++ - progressState = state - return nil - }). - Dispatch(context.Background()) - if err != nil { - t.Fatalf("dispatch batch: %v", err) - } - if progressCalls != 1 || progressState.BatchID != batchID || !progressState.Completed || progressState.Pending != 0 || progressState.Processed != 1 { - t.Fatalf("progress calls/state = %d/%+v, want one completed member", progressCalls, progressState) - } -} - -// TestWorkflowAdapterFallbackPreservesNilShapesAndOutcomes verifies legacy -// stores retain nil collection identity, state conversion, and error identity. -func TestWorkflowAdapterFallbackPreservesNilShapesAndOutcomes(t *testing.T) { - if cloneStoredPayload(nil) != nil { - t.Fatal("nil stored payload became a non-nil slice") - } - if toQueueBatchJobs(nil) != nil { - t.Fatal("nil batch jobs became a non-nil slice") - } - if toWorkflowMiddlewares(nil) != nil { - t.Fatal("nil middleware list became a non-nil slice") - } - - failCause := errors.New("chain failed") - failErr := errors.New("chain store unavailable") - successErr := errors.New("success readback unavailable") - failureCause := errors.New("member failed") - failureErr := errors.New("failure readback unavailable") - store := &adapterBranchStore{ - failErr: failErr, - successState: queue.BatchState{BatchID: "batch-success", Processed: 1, Completed: true}, - successDone: true, - successErr: successErr, - failureState: queue.BatchState{BatchID: "batch-failure", Processed: 1, Failed: 1, Completed: true}, - failureDone: true, - failureErr: failureErr, - } - adapter := workflowStoreAdapter{store: store} - - if err := adapter.FailChain(context.Background(), "chain-1", failCause); !errors.Is(err, failErr) || store.failChainID != "chain-1" || store.failCause != failCause { - t.Fatalf("fail chain = id:%q cause:%v err:%v", store.failChainID, store.failCause, err) - } - success, done, err := adapter.MarkBatchJobSucceeded(context.Background(), "batch-success", "job-success") - if !errors.Is(err, successErr) || !done || success.BatchID != "batch-success" || !success.Completed || store.successBatchID != "batch-success" || store.successJobID != "job-success" { - t.Fatalf("successful member conversion = state:%+v done:%t err:%v store:%q/%q", success, done, err, store.successBatchID, store.successJobID) - } - failure, done, err := adapter.MarkBatchJobFailed(context.Background(), "batch-failure", "job-failure", failureCause) - if !errors.Is(err, failureErr) || !done || failure.BatchID != "batch-failure" || failure.Failed != 1 || !failure.Completed || store.failureBatchID != "batch-failure" || store.failureJobID != "job-failure" || store.failureCause != failureCause { - t.Fatalf("failed member conversion = state:%+v done:%t err:%v store:%q/%q/%v", failure, done, err, store.failureBatchID, store.failureJobID, store.failureCause) - } - - if _, ok := toWorkflowStore(store).(workflowOutcomeStoreAdapter); ok { - t.Fatal("legacy store unexpectedly advertised atomic outcome ownership") - } -} diff --git a/bus/workflow_adapters.go b/bus/workflow_adapters.go deleted file mode 100644 index d0f60c7..0000000 --- a/bus/workflow_adapters.go +++ /dev/null @@ -1,368 +0,0 @@ -package bus - -import ( - "context" - "time" - - "github.com/goforj/queue" - "github.com/goforj/queue/internal/workflow" -) - -// toQueueMessage converts the private engine context into the root-owned message model. -func toQueueMessage(message workflow.Context) queue.Message { - converted := queue.NewMessage(message.JobType, message.PayloadBytes()) - converted.SchemaVersion = message.SchemaVersion - converted.DispatchID = message.DispatchID - converted.JobID = message.JobID - converted.ChainID = message.ChainID - converted.BatchID = message.BatchID - converted.Attempt = message.Attempt - return converted -} - -// toWorkflowContext converts a root-owned message back into the private engine context. -func toWorkflowContext(message queue.Message) workflow.Context { - return workflow.NewContext( - message.SchemaVersion, - message.DispatchID, - message.JobID, - message.ChainID, - message.BatchID, - message.Attempt, - message.JobType, - message.PayloadBytes(), - ) -} - -// toQueueDispatchResult converts an engine receipt into the root-owned result model. -func toQueueDispatchResult(result workflow.DispatchResult) queue.DispatchResult { - return queue.DispatchResult{DispatchID: result.DispatchID} -} - -// toWorkflowStoredJobOptions converts root-owned delivery policy into the engine model. -func toWorkflowStoredJobOptions(options queue.StoredJobOptions) workflow.JobOptions { - return workflow.JobOptions{ - Queue: options.Queue, - Delay: options.Delay, - Timeout: options.Timeout, - Retry: options.Retry, - Backoff: options.Backoff, - UniqueFor: options.UniqueFor, - } -} - -// toQueueStoredJobOptions converts engine delivery policy into the root-owned model. -func toQueueStoredJobOptions(options workflow.JobOptions) queue.StoredJobOptions { - return queue.StoredJobOptions{ - Queue: options.Queue, - Delay: options.Delay, - Timeout: options.Timeout, - Retry: options.Retry, - Backoff: options.Backoff, - UniqueFor: options.UniqueFor, - } -} - -// cloneStoredPayload isolates mutable persisted payload bytes without changing nil slices. -func cloneStoredPayload(payload []byte) []byte { - if payload == nil { - return nil - } - cloned := make([]byte, len(payload)) - copy(cloned, payload) - return cloned -} - -// toWorkflowStoredJob converts one root-owned persisted job into the engine model. -func toWorkflowStoredJob(job queue.StoredJob) workflow.StoredJob { - return workflow.StoredJob{ - Type: job.Type, - Payload: cloneStoredPayload(job.Payload), - Options: toWorkflowStoredJobOptions(job.Options), - } -} - -// toQueueStoredJob converts one engine persisted job into the root-owned model. -func toQueueStoredJob(job workflow.StoredJob) queue.StoredJob { - return queue.StoredJob{ - Type: job.Type, - Payload: cloneStoredPayload(job.Payload), - Options: toQueueStoredJobOptions(job.Options), - } -} - -// toWorkflowChainNode converts one root-owned chain node into the engine model. -func toWorkflowChainNode(node queue.ChainNode) workflow.ChainNode { - return workflow.ChainNode{NodeID: node.NodeID, Job: toWorkflowStoredJob(node.Job)} -} - -// toQueueChainNode converts one engine chain node into the root-owned model. -func toQueueChainNode(node workflow.ChainNode) queue.ChainNode { - return queue.ChainNode{NodeID: node.NodeID, Job: toQueueStoredJob(node.Job)} -} - -// toWorkflowChainNodes converts a root-owned node slice while retaining nil slices. -func toWorkflowChainNodes(nodes []queue.ChainNode) []workflow.ChainNode { - if nodes == nil { - return nil - } - converted := make([]workflow.ChainNode, len(nodes)) - for i, node := range nodes { - converted[i] = toWorkflowChainNode(node) - } - return converted -} - -// toQueueChainNodes converts an engine node slice while retaining nil slices. -func toQueueChainNodes(nodes []workflow.ChainNode) []queue.ChainNode { - if nodes == nil { - return nil - } - converted := make([]queue.ChainNode, len(nodes)) - for i, node := range nodes { - converted[i] = toQueueChainNode(node) - } - return converted -} - -// toQueueChainRecord converts engine chain creation state for a root-owned store. -func toQueueChainRecord(record workflow.ChainRecord) queue.ChainRecord { - return queue.ChainRecord{ - ChainID: record.ChainID, - DispatchID: record.DispatchID, - Queue: record.Queue, - Nodes: toQueueChainNodes(record.Nodes), - CreatedAt: record.CreatedAt, - } -} - -// toWorkflowChainState converts root-owned chain state into the engine model. -func toWorkflowChainState(state queue.ChainState) workflow.ChainState { - return workflow.ChainState{ - ChainID: state.ChainID, - DispatchID: state.DispatchID, - Queue: state.Queue, - Nodes: toWorkflowChainNodes(state.Nodes), - NextIndex: state.NextIndex, - Completed: state.Completed, - Failed: state.Failed, - Failure: state.Failure, - CreatedAt: state.CreatedAt, - UpdatedAt: state.UpdatedAt, - } -} - -// toQueueChainState converts engine chain state into the root-owned model. -func toQueueChainState(state workflow.ChainState) queue.ChainState { - return queue.ChainState{ - ChainID: state.ChainID, - DispatchID: state.DispatchID, - Queue: state.Queue, - Nodes: toQueueChainNodes(state.Nodes), - NextIndex: state.NextIndex, - Completed: state.Completed, - Failed: state.Failed, - Failure: state.Failure, - CreatedAt: state.CreatedAt, - UpdatedAt: state.UpdatedAt, - } -} - -// toQueueBatchJob converts one engine batch member into the root-owned model. -func toQueueBatchJob(job workflow.BatchJob) queue.BatchJob { - return queue.BatchJob{JobID: job.JobID, Job: toQueueStoredJob(job.Job)} -} - -// toQueueBatchJobs converts an engine member slice while retaining nil slices. -func toQueueBatchJobs(jobs []workflow.BatchJob) []queue.BatchJob { - if jobs == nil { - return nil - } - converted := make([]queue.BatchJob, len(jobs)) - for i, job := range jobs { - converted[i] = toQueueBatchJob(job) - } - return converted -} - -// toQueueBatchRecord converts engine batch creation state for a root-owned store. -func toQueueBatchRecord(record workflow.BatchRecord) queue.BatchRecord { - return queue.BatchRecord{ - BatchID: record.BatchID, - DispatchID: record.DispatchID, - Name: record.Name, - Queue: record.Queue, - AllowFailed: record.AllowFailed, - Jobs: toQueueBatchJobs(record.Jobs), - CreatedAt: record.CreatedAt, - } -} - -// toWorkflowBatchState converts root-owned aggregate state into the engine model. -func toWorkflowBatchState(state queue.BatchState) workflow.BatchState { - return workflow.BatchState{ - BatchID: state.BatchID, - DispatchID: state.DispatchID, - Name: state.Name, - Queue: state.Queue, - AllowFailed: state.AllowFailed, - Total: state.Total, - Pending: state.Pending, - Processed: state.Processed, - Failed: state.Failed, - Cancelled: state.Cancelled, - Completed: state.Completed, - CreatedAt: state.CreatedAt, - UpdatedAt: state.UpdatedAt, - } -} - -// toQueueBatchState converts engine aggregate state into the root-owned model. -func toQueueBatchState(state workflow.BatchState) queue.BatchState { - return queue.BatchState{ - BatchID: state.BatchID, - DispatchID: state.DispatchID, - Name: state.Name, - Queue: state.Queue, - AllowFailed: state.AllowFailed, - Total: state.Total, - Pending: state.Pending, - Processed: state.Processed, - Failed: state.Failed, - Cancelled: state.Cancelled, - Completed: state.Completed, - CreatedAt: state.CreatedAt, - UpdatedAt: state.UpdatedAt, - } -} - -type workflowMiddlewareAdapter struct { - middleware Middleware -} - -var _ workflow.Middleware = workflowMiddlewareAdapter{} - -// Handle preserves middleware message replacement while crossing the private engine boundary. -func (a workflowMiddlewareAdapter) Handle(ctx context.Context, message workflow.Context, next workflow.Next) error { - return a.middleware.Handle(ctx, toQueueMessage(message), func(nextContext context.Context, nextMessage queue.Message) error { - return next(nextContext, toWorkflowContext(nextMessage)) - }) -} - -// toWorkflowMiddlewares converts root-owned middleware into private engine adapters. -func toWorkflowMiddlewares(middlewares []Middleware) []workflow.Middleware { - if middlewares == nil { - return nil - } - converted := make([]workflow.Middleware, 0, len(middlewares)) - for _, middleware := range middlewares { - if middleware != nil { - converted = append(converted, workflowMiddlewareAdapter{middleware: middleware}) - } - } - return converted -} - -type workflowStoreAdapter struct { - store Store -} - -var _ workflow.Store = workflowStoreAdapter{} - -type workflowOutcomeStoreAdapter struct { - workflowStoreAdapter - atomic queue.WorkflowOutcomeStore -} - -// FailChainNode converts an atomic root-store result back into the engine model. -func (a workflowOutcomeStoreAdapter) FailChainNode(ctx context.Context, chainID, nodeID string, cause error) (workflow.ChainState, bool, error) { - state, owned, err := a.atomic.FailChainNode(ctx, chainID, nodeID, cause) - return toWorkflowChainState(state), owned, err -} - -// SettleBatchJob converts an atomic root-store result back into the engine model. -func (a workflowOutcomeStoreAdapter) SettleBatchJob(ctx context.Context, batchID, jobID string, outcome workflow.BatchJobOutcome, cause error) (workflow.BatchState, bool, error) { - state, owned, err := a.atomic.SettleBatchJob(ctx, batchID, jobID, queue.BatchJobOutcome(outcome), cause) - return toWorkflowBatchState(state), owned, err -} - -// toWorkflowStore wraps a root-owned store for the retained raw-runtime route. -func toWorkflowStore(store Store) workflow.Store { - if store == nil { - return nil - } - adapter := workflowStoreAdapter{store: store} - if atomic, ok := store.(queue.WorkflowOutcomeStore); ok { - return workflowOutcomeStoreAdapter{workflowStoreAdapter: adapter, atomic: atomic} - } - return adapter -} - -// CreateChain converts the engine record before invoking the root-owned store. -func (a workflowStoreAdapter) CreateChain(ctx context.Context, record workflow.ChainRecord) error { - return a.store.CreateChain(ctx, toQueueChainRecord(record)) -} - -// AdvanceChain converts the optional root-owned next node back into the engine model. -func (a workflowStoreAdapter) AdvanceChain(ctx context.Context, chainID string, completedNode string) (*workflow.ChainNode, bool, error) { - node, done, err := a.store.AdvanceChain(ctx, chainID, completedNode) - if node == nil { - return nil, done, err - } - converted := toWorkflowChainNode(*node) - return &converted, done, err -} - -// FailChain forwards the terminal cause without changing its error identity. -func (a workflowStoreAdapter) FailChain(ctx context.Context, chainID string, cause error) error { - return a.store.FailChain(ctx, chainID, cause) -} - -// GetChain converts root-owned state back into the engine model. -func (a workflowStoreAdapter) GetChain(ctx context.Context, chainID string) (workflow.ChainState, error) { - state, err := a.store.GetChain(ctx, chainID) - return toWorkflowChainState(state), err -} - -// CreateBatch converts the engine record before invoking the root-owned store. -func (a workflowStoreAdapter) CreateBatch(ctx context.Context, record workflow.BatchRecord) error { - return a.store.CreateBatch(ctx, toQueueBatchRecord(record)) -} - -// MarkBatchJobStarted forwards the retry-safe member-start mutation. -func (a workflowStoreAdapter) MarkBatchJobStarted(ctx context.Context, batchID, jobID string) error { - return a.store.MarkBatchJobStarted(ctx, batchID, jobID) -} - -// MarkBatchJobSucceeded converts the resulting root-owned state back into the engine model. -func (a workflowStoreAdapter) MarkBatchJobSucceeded(ctx context.Context, batchID, jobID string) (workflow.BatchState, bool, error) { - state, done, err := a.store.MarkBatchJobSucceeded(ctx, batchID, jobID) - return toWorkflowBatchState(state), done, err -} - -// MarkBatchJobFailed preserves the failure cause and converts the resulting state. -func (a workflowStoreAdapter) MarkBatchJobFailed(ctx context.Context, batchID, jobID string, cause error) (workflow.BatchState, bool, error) { - state, done, err := a.store.MarkBatchJobFailed(ctx, batchID, jobID, cause) - return toWorkflowBatchState(state), done, err -} - -// CancelBatch forwards aggregate cancellation to the root-owned store. -func (a workflowStoreAdapter) CancelBatch(ctx context.Context, batchID string) error { - return a.store.CancelBatch(ctx, batchID) -} - -// GetBatch converts root-owned aggregate state back into the engine model. -func (a workflowStoreAdapter) GetBatch(ctx context.Context, batchID string) (workflow.BatchState, error) { - state, err := a.store.GetBatch(ctx, batchID) - return toWorkflowBatchState(state), err -} - -// MarkCallbackInvoked forwards the atomic callback claim unchanged. -func (a workflowStoreAdapter) MarkCallbackInvoked(ctx context.Context, key string) (bool, error) { - return a.store.MarkCallbackInvoked(ctx, key) -} - -// Prune forwards workflow retention to the root-owned store. -func (a workflowStoreAdapter) Prune(ctx context.Context, before time.Time) error { - return a.store.Prune(ctx, before) -} diff --git a/bus/workflow_adapters_test.go b/bus/workflow_adapters_test.go deleted file mode 100644 index 55acaf4..0000000 --- a/bus/workflow_adapters_test.go +++ /dev/null @@ -1,290 +0,0 @@ -package bus - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/goforj/queue" - "github.com/goforj/queue/internal/workflow" -) - -type workflowAdapterStoreStub struct { - Store - advanceNode *queue.ChainNode - advanceDone bool - advanceErr error - cancelBatchID string - cancelBatchErr error -} - -type workflowOutcomeAdapterStoreStub struct { - *workflowAdapterStoreStub - chainState queue.ChainState - chainOwned bool - chainErr error - batchState queue.BatchState - batchOwned bool - batchErr error - batchOutcome queue.BatchJobOutcome -} - -// FailChainNode returns the configured root outcome for adapter conversion. -func (s *workflowOutcomeAdapterStoreStub) FailChainNode(context.Context, string, string, error) (queue.ChainState, bool, error) { - return s.chainState, s.chainOwned, s.chainErr -} - -// SettleBatchJob records the converted outcome and returns configured state. -func (s *workflowOutcomeAdapterStoreStub) SettleBatchJob(_ context.Context, _, _ string, outcome queue.BatchJobOutcome, _ error) (queue.BatchState, bool, error) { - s.batchOutcome = outcome - return s.batchState, s.batchOwned, s.batchErr -} - -// AdvanceChain returns the configured successor and outcome for adapter boundary tests. -func (s *workflowAdapterStoreStub) AdvanceChain(context.Context, string, string) (*queue.ChainNode, bool, error) { - return s.advanceNode, s.advanceDone, s.advanceErr -} - -// CancelBatch records the aggregate identifier and returns the configured error. -func (s *workflowAdapterStoreStub) CancelBatch(_ context.Context, batchID string) error { - s.cancelBatchID = batchID - return s.cancelBatchErr -} - -// TestWorkflowMessageAdaptersPreserveMetadataAndPayload pins both raw-route directions. -func TestWorkflowMessageAdaptersPreserveMetadataAndPayload(t *testing.T) { - payload := []byte(`{"id":7}`) - engineMessage := workflow.NewContext( - 1, - "dispatch-1", - "job-1", - "chain-1", - "batch-1", - 3, - "reports:build", - payload, - ) - payload[0] = '!' - - rootMessage := toQueueMessage(engineMessage) - if rootMessage.SchemaVersion != 1 || rootMessage.DispatchID != "dispatch-1" || rootMessage.JobID != "job-1" || rootMessage.ChainID != "chain-1" || rootMessage.BatchID != "batch-1" || rootMessage.Attempt != 3 || rootMessage.JobType != "reports:build" { - t.Fatalf("root message metadata changed: %+v", rootMessage) - } - if got := string(rootMessage.PayloadBytes()); got != `{"id":7}` { - t.Fatalf("root message payload = %q, want preserved JSON", got) - } - - rootMessage.JobType = "reports:replace" - roundTrip := toWorkflowContext(rootMessage) - if roundTrip.JobType != "reports:replace" || roundTrip.DispatchID != "dispatch-1" || roundTrip.Attempt != 3 { - t.Fatalf("engine message round trip changed: %+v", roundTrip) - } - returnedPayload := roundTrip.PayloadBytes() - returnedPayload[0] = '?' - if got := string(roundTrip.PayloadBytes()); got != `{"id":7}` { - t.Fatalf("engine message payload was not isolated: %q", got) - } -} - -// TestWorkflowMiddlewareAdapterPreservesMessageReplacement verifies the continuation crosses both physical models. -func TestWorkflowMiddlewareAdapterPreservesMessageReplacement(t *testing.T) { - adapter := workflowMiddlewareAdapter{middleware: MiddlewareFunc(func(ctx context.Context, _ Context, next Next) error { - replacement := queue.NewMessage("reports:replacement", []byte(`{"replacement":true}`)) - replacement.SchemaVersion = 1 - replacement.DispatchID = "dispatch-replacement" - return next(ctx, replacement) - })} - - var received workflow.Context - err := adapter.Handle(context.Background(), workflow.NewContext(1, "dispatch-original", "job-1", "", "", 0, "reports:original", []byte(`null`)), func(_ context.Context, message workflow.Context) error { - received = message - return nil - }) - if err != nil { - t.Fatalf("handle middleware: %v", err) - } - if received.JobType != "reports:replacement" || received.DispatchID != "dispatch-replacement" || string(received.PayloadBytes()) != `{"replacement":true}` { - t.Fatalf("replacement message changed across adapter: %+v payload=%q", received, received.PayloadBytes()) - } -} - -// TestWorkflowRecordAdaptersPreservePhysicalShapes verifies nested jobs, policy, times, and byte ownership. -func TestWorkflowRecordAdaptersPreservePhysicalShapes(t *testing.T) { - createdAt := time.Unix(1_704_067_200, 123_000_000) - engineChain := workflow.ChainRecord{ - ChainID: "chain-1", - DispatchID: "dispatch-1", - Queue: "critical", - Nodes: []workflow.ChainNode{{ - NodeID: "node-1", - Job: workflow.StoredJob{ - Type: "reports:build", - Payload: []byte(`{"id":7}`), - Options: workflow.JobOptions{Queue: "critical", Delay: time.Second, Timeout: 2 * time.Second, Retry: 3, Backoff: 4 * time.Second, UniqueFor: 5 * time.Second}, - }, - }}, - CreatedAt: createdAt, - } - rootChain := toQueueChainRecord(engineChain) - if rootChain.ChainID != "chain-1" || rootChain.DispatchID != "dispatch-1" || rootChain.Queue != "critical" || !rootChain.CreatedAt.Equal(createdAt) || len(rootChain.Nodes) != 1 { - t.Fatalf("root chain record changed: %+v", rootChain) - } - job := rootChain.Nodes[0].Job - if job.Type != "reports:build" || string(job.Payload) != `{"id":7}` || job.Options.Delay != time.Second || job.Options.Timeout != 2*time.Second || job.Options.Retry != 3 || job.Options.Backoff != 4*time.Second || job.Options.UniqueFor != 5*time.Second { - t.Fatalf("root stored job changed: %+v", job) - } - rootChain.Nodes[0].Job.Payload[0] = '!' - if got := string(engineChain.Nodes[0].Job.Payload); got != `{"id":7}` { - t.Fatalf("engine payload aliased root payload: %q", got) - } - - rootBatch := queue.BatchState{ - BatchID: "batch-1", - DispatchID: "dispatch-2", - Name: "nightly", - Queue: "bulk", - AllowFailed: true, - Total: 8, - Pending: 3, - Processed: 5, - Failed: 2, - Cancelled: false, - Completed: false, - CreatedAt: createdAt, - UpdatedAt: createdAt.Add(time.Minute), - } - engineBatch := toWorkflowBatchState(rootBatch) - if engineBatch.BatchID != rootBatch.BatchID || engineBatch.DispatchID != rootBatch.DispatchID || engineBatch.Name != rootBatch.Name || engineBatch.Queue != rootBatch.Queue || engineBatch.AllowFailed != rootBatch.AllowFailed || engineBatch.Total != rootBatch.Total || engineBatch.Pending != rootBatch.Pending || engineBatch.Processed != rootBatch.Processed || engineBatch.Failed != rootBatch.Failed || engineBatch.Cancelled != rootBatch.Cancelled || engineBatch.Completed != rootBatch.Completed || !engineBatch.CreatedAt.Equal(rootBatch.CreatedAt) || !engineBatch.UpdatedAt.Equal(rootBatch.UpdatedAt) { - t.Fatalf("engine batch state changed: %+v", engineBatch) - } -} - -// TestWorkflowStoreAdapterAdvanceChainCoversSuccessorBranches pins optional-node conversion and error identity. -func TestWorkflowStoreAdapterAdvanceChainCoversSuccessorBranches(t *testing.T) { - sentinel := errors.New("advance chain failed") - tests := []struct { - name string - node *queue.ChainNode - done bool - err error - wantNode bool - }{ - {name: "nil successor", done: true}, - { - name: "converted successor", - node: &queue.ChainNode{ - NodeID: "node-2", - Job: queue.StoredJob{ - Type: "reports:publish", - Payload: []byte(`{"id":8}`), - Options: queue.StoredJobOptions{ - Queue: "critical", - Delay: time.Second, - Timeout: 2 * time.Second, - Retry: 3, - Backoff: 4 * time.Second, - UniqueFor: 5 * time.Second, - }, - }, - }, - wantNode: true, - }, - { - name: "successor with store error", - node: &queue.ChainNode{ - NodeID: "node-error", - Job: queue.StoredJob{Type: "reports:error", Payload: []byte(`null`)}, - }, - err: sentinel, - wantNode: true, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - store := &workflowAdapterStoreStub{ - advanceNode: test.node, - advanceDone: test.done, - advanceErr: test.err, - } - adapted := toWorkflowStore(store) - node, done, err := adapted.AdvanceChain(context.Background(), "chain-1", "node-1") - if done != test.done { - t.Fatalf("done = %t, want %t", done, test.done) - } - if err != test.err { - t.Fatalf("error = %v, want exact identity %v", err, test.err) - } - if (node != nil) != test.wantNode { - t.Fatalf("successor = %+v, want present=%t", node, test.wantNode) - } - if node == nil { - return - } - if node.NodeID != test.node.NodeID || node.Job.Type != test.node.Job.Type || string(node.Job.Payload) != string(test.node.Job.Payload) { - t.Fatalf("converted successor = %+v, want %+v", node, test.node) - } - if node.Job.Options.Queue != test.node.Job.Options.Queue || node.Job.Options.Delay != test.node.Job.Options.Delay || node.Job.Options.Timeout != test.node.Job.Options.Timeout || node.Job.Options.Retry != test.node.Job.Options.Retry || node.Job.Options.Backoff != test.node.Job.Options.Backoff || node.Job.Options.UniqueFor != test.node.Job.Options.UniqueFor { - t.Fatalf("converted successor options = %+v, want %+v", node.Job.Options, test.node.Job.Options) - } - if len(node.Job.Payload) > 0 { - node.Job.Payload[0] = '!' - if test.node.Job.Payload[0] == '!' { - t.Fatal("converted successor payload aliases the physical store value") - } - } - }) - } - - if got := toWorkflowStore(nil); got != nil { - t.Fatalf("nil store adapted to %T, want nil", got) - } -} - -// TestWorkflowStoreAdapterCancelBatchPreservesIDAndError verifies cancellation forwarding without error wrapping. -func TestWorkflowStoreAdapterCancelBatchPreservesIDAndError(t *testing.T) { - sentinel := errors.New("cancel batch failed") - store := &workflowAdapterStoreStub{cancelBatchErr: sentinel} - adapted := toWorkflowStore(store) - - err := adapted.CancelBatch(context.Background(), "batch-7") - if err != sentinel { - t.Fatalf("cancel error = %v, want exact identity %v", err, sentinel) - } - if store.cancelBatchID != "batch-7" { - t.Fatalf("cancelled batch = %q, want batch-7", store.cancelBatchID) - } -} - -// TestWorkflowOutcomeStoreAdapterPreservesOwnership proves the deprecated raw -// route forwards the one canonical root capability instead of redefining it. -func TestWorkflowOutcomeStoreAdapterPreservesOwnership(t *testing.T) { - chainErr := errors.New("chain outcome failed") - batchErr := errors.New("batch outcome failed") - store := &workflowOutcomeAdapterStoreStub{ - workflowAdapterStoreStub: &workflowAdapterStoreStub{}, - chainState: queue.ChainState{ChainID: "chain-outcome", Failed: true}, - chainOwned: true, - chainErr: chainErr, - batchState: queue.BatchState{BatchID: "batch-outcome", Processed: 1}, - batchOwned: false, - batchErr: batchErr, - } - adapted := toWorkflowStore(store) - outcomes, ok := adapted.(interface { - FailChainNode(context.Context, string, string, error) (workflow.ChainState, bool, error) - SettleBatchJob(context.Context, string, string, workflow.BatchJobOutcome, error) (workflow.BatchState, bool, error) - }) - if !ok { - t.Fatalf("capable store adapted as %T without outcome capability", adapted) - } - chainState, owned, err := outcomes.FailChainNode(context.Background(), "chain-outcome", "node-outcome", chainErr) - if err != chainErr || !owned || chainState.ChainID != "chain-outcome" || !chainState.Failed { - t.Fatalf("chain outcome = state:%+v owned:%t err:%v", chainState, owned, err) - } - batchState, owned, err := outcomes.SettleBatchJob(context.Background(), "batch-outcome", "job-outcome", workflow.BatchJobFailed, batchErr) - if err != batchErr || owned || batchState.BatchID != "batch-outcome" || store.batchOutcome != queue.BatchJobFailed { - t.Fatalf("batch outcome = state:%+v owned:%t err:%v stored:%q", batchState, owned, err, store.batchOutcome) - } -} diff --git a/docs/bus-design.md b/docs/bus-design.md deleted file mode 100644 index e76388b..0000000 --- a/docs/bus-design.md +++ /dev/null @@ -1,400 +0,0 @@ -# Workflow Architecture (Historical Bus Design) - -> **Status:** This is the original bus design record, retained to explain the -> version-one wire and API constraints. It is not the current architecture or -> implementation roadmap; use [`docs/plan.md`](./plan.md) for both. - -## Current Ownership - -- `*queue.Queue` is the sole canonical application facade for dispatch, handlers, middleware, chains, batches, workflow state, stores, and observation. -- `internal/workflow.Engine` owns orchestration implementation. It depends on the neutral `busruntime.Runtime` transport seam and does not import root `queue`, public `bus`, or `queuecore`. -- Public messages, results, middleware, persisted workflow records, and store contracts are physical root `queue` types. Private adapters translate them at the engine boundary so GoDoc, reflection, generators, and custom stores never expose `internal/workflow` as their apparent owner. -- Public `bus` is a deprecated compatibility package. `bus.New(existingQueue)` returns an option-free adapter over that queue's existing engine; it does not register a second engine. Construction options and `NewWithStore` are rejected for an already-built queue and must instead be supplied through root queue options. -- The legacy raw-`busruntime.Runtime` construction route remains temporarily supported for integrations and preserves its observer, store, clock, and middleware options. -- `bus.Job` remains a boundary DTO because its public fields, composite literals, deferred JSON encoding, and raw string/byte semantics cannot alias `queue.Job` compatibly. `bus.JobOptions` is a source-compatible alias of the root persisted-options shape, and the facade converts the job once into the canonical root path. -- The self-returning `bus.ChainBuilder` and `bus.BatchBuilder` interfaces remain physical deprecated contracts. Keeping them distinct avoids breaking downstream type switches and custom implementations; their adapters still delegate every operation to the canonical engine. -- The legacy `bus.Event` observer shape remains only at that compatibility boundary. Root `queue.Observer` is the canonical event model and the internal engine has one event producer. -- `queue.FakeQueue` owns the only fake state and runs chain/batch construction through the production workflow engine and memory-store contract. Deprecated `bus.Fake` and `queuefake.Fake` values are typed compatibility views over that same concurrency-safe recorder; they do not own independent dispatch, builder, or assertion models. -- Version-one physical names and JSON envelopes remain readable compatibility contracts despite the historical prefix. Root direct dispatch now uses the application job type and payload; `bus:job` remains registered for old backlog, reserved-name collisions, the migration option, and the raw-runtime compatibility route. Chain, batch, and callback deliveries retain `bus:chain:node`, `bus:batch:job`, and `bus:callback`. - -## Compatibility Migration - -Ordinary source forms remain supported: custom `bus.Bus`, store, middleware, observer, and builder implementations; keyed and unkeyed legacy DTO literals; the Temporal adapter; and the legacy fake all compile against the facade. The following runtime/tooling identity migration is intentional: - -- compatible `bus` message, result, middleware, workflow-record, and store aliases now have the root package identity `github.com/goforj/queue`; -- `bus.Job`, `bus.Event`, `bus.Observer`, `bus.Bus`, `bus.Option`, `bus.ChainBuilder`, and `bus.BatchBuilder` retain their legacy `github.com/goforj/queue/bus` identity; -- code that keys behavior on `%T`, `reflect.Type.PkgPath`, gob/interface registration names, generated registries, dependency-injection keys, or a custom type-sensitive persistence format must map the applicable old `bus` names to the root `queue` names. - -One configuration and runtime-behavior incompatibility is intentional: every option-free `bus.New(existingQueue)` facade now shares the root queue's handler registry, store, observer, middleware, and lifecycle instead of constructing independent state over the same physical runtime. Code that deliberately relied on isolated root and bus state must use distinct queue runtimes; ordinary callers should register and configure one root queue and treat `bus` only as a compatibility view. `bus.New(existingQueue, nonNilOption...)` and `bus.NewWithStore(existingQueue, ...)` now return `bus.ErrQueueOptionsUnsupported` because those options cannot configure only the shared view. Supply `queue.WithObserver`, `queue.WithStore`, `queue.WithClock`, and `queue.WithMiddleware` when constructing the root queue, then call `bus.New(existingQueue)` without options. The retained raw-`busruntime.Runtime` route continues to accept legacy bus options. - -Fake runtime behavior is also intentionally corrected: abandoned builders no longer satisfy chain or batch assertions, invalid or canceled dispatches remain absent, builder options are retained, returned workflow IDs identify lookup state, effective default queues are assertion-visible, and Reset clears direct plus workflow records from every compatibility view. Recording fakes accept closure callbacks for fluent compatibility but do not retain them in fake runtime state or execute them. Tests that asserted the old constant `fake-chain` or `fake-batch` identifiers must instead treat returned IDs as opaque and may use `FindChain` or `FindBatch`; tests that deliberately depended on separate queue/workflow direct histories must migrate to the unified direct assertions. Constructor signatures, the zero value, value copies after initialization, and physical `bus.Fake`/`bus.BatchSpec` identities remain source-compatible; configuration, persisted data, wire formats, operations, and the minimum Go version are unchanged. - -The type-identity migration itself did not change wire or persistence contracts. The later direct-delivery cutover deliberately does; see [Direct Delivery Migration](direct-delivery-migration.md) for the exact wire, SQL, runtime, and rollout boundary. Literal legacy-wire and legacy-SQL fixtures continue to guard backward reading. - -The remainder of this document describes the superseded proposal. Examples that -construct or configure `bus` directly should not be treated as current guidance. - -This document defines a `bus` package for GoForj that composes on top of `github.com/goforj/queue` and provides workflow orchestration primitives: dispatch, chain, batch, callbacks, middleware, events, and test fakes. - -`bus` is not a pub/sub transport. Cross-service event streaming is out of scope for this package. - -## Product Goal - -Build orchestration as an additive layer: - -- Keep direct queue usage unchanged for simple jobs: - - `q.Register(...)` - - `q.StartWorkers(ctx)` - - `q.Dispatch(...)` -- Add a `bus` facade for workflow orchestration: - - chain jobs in order - - batch jobs in parallel - - callbacks (`Then`, `Catch`, `Finally`, `Progress`) - - bus-level lifecycle events/observer - - Laravel-style testing fakes/assertions - -## Hard Constraints - -- Bus must use existing `queue.Queue` as execution substrate. -- Bus must not expose broker subscribe APIs. -- Bus internals must be deterministic and idempotent under retries. -- Bus remains backend-portable (no backend-specific orchestration logic in core). - -## Package Shape - -Proposed package (in this repository): `github.com/goforj/queue/bus` - -Files: - -- `bus.go`: constructors + facade -- `job.go`: job model + envelope mapping -- `registry.go`: handler registry -- `chain.go`: chain builder + progression -- `batch.go`: batch builder + lifecycle -- `middleware.go`: middleware contracts + pipeline -- `events.go`: bus event model + observer API -- `store.go`: store interfaces + records -- `store_memory.go`: in-memory store -- `store_sql.go`: SQL store -- `fake.go`: fake bus + assertions - -## Public API (Proposed) - -```go -package bus - -type Bus interface { - Register(jobType string, handler Handler) - - Dispatch(ctx context.Context, job Job) (DispatchResult, error) - Chain(jobs ...Job) ChainBuilder - Batch(jobs ...Job) BatchBuilder - - StartWorkers(ctx context.Context) error - Shutdown(ctx context.Context) error - - FindBatch(ctx context.Context, batchID string) (BatchState, error) - FindChain(ctx context.Context, chainID string) (ChainState, error) - Prune(ctx context.Context, before time.Time) error -} - -type Handler func(ctx context.Context, j Context) error - -type Job struct { - Type string - Payload any - Options JobOptions -} - -type JobOptions struct { - Queue string - Delay time.Duration - Timeout time.Duration - Retry int - Backoff time.Duration - UniqueFor time.Duration -} -``` - -Constructors: - -```go -func New(q any, opts ...Option) (Bus, error) -func NewWithStore(q any, store Store, opts ...Option) (Bus, error) -func NewFake() *Fake -``` - -Options: - -- `WithObserver(observer Observer)` -- `WithStore(store Store)` -- `WithClock(func() time.Time)` -- `WithMiddleware(middlewares ...Middleware)` - -## Ergonomic Examples - -### Single dispatch - -```go -b, _ := bus.New(q) -b.Register("monitor:poll", handleMonitorPoll) -_, _ = b.Dispatch(ctx, - bus.NewJob("monitor:poll", EndpointPayload{URL: "https://goforj.dev/health"}). - OnQueue("monitor-critical"). - Retry(3). - Backoff(500*time.Millisecond), -) -``` - -### Chain - -```go -chainID, _ := b.Chain( - bus.NewJob("monitor:poll", target), - bus.NewJob("monitor:downsample", target), - bus.NewJob("monitor:alert", target), -).OnQueue("monitor-critical"). - Catch(func(ctx context.Context, st ChainState, err error) error { return nil }). - Finally(func(ctx context.Context, st ChainState) error { return nil }). - Dispatch(ctx) -_ = chainID -``` - -### Batch - -```go -batchID, _ := b.Batch(jobs...). - Name("Monitor Sweep"). - OnQueue("monitor-scan"). - AllowFailures(). - Progress(func(ctx context.Context, st BatchState) error { return nil }). - Then(func(ctx context.Context, st BatchState) error { return nil }). - Catch(func(ctx context.Context, st BatchState, err error) error { return nil }). - Finally(func(ctx context.Context, st BatchState) error { return nil }). - Dispatch(ctx) -_ = batchID -``` - -### Middleware - -```go -b, _ := bus.New( - q, - bus.WithMiddleware( - bus.SkipWhen{ - Predicate: func(ctx context.Context, jc bus.Context) bool { - return jc.JobType == "monitor:downsample" - }, - }, - bus.FailOnError{}, - ), -) -``` - -## Queue Integration Contract - -Bus registers reserved internal job types on the underlying queue: - -- `bus:job` -- `bus:chain:node` -- `bus:batch:job` -- `bus:callback` - -Envelope includes `schema_version` (starting at `1`). - -Execution flow: - -1. User dispatches job/chain/batch via Bus. -2. Bus stores orchestration metadata (when required). -3. Bus enqueues internal envelope job(s) via `q.WithContext(ctx).Dispatch(...)`. -4. Queue workers execute bus internal handlers. -5. Bus executes registered user handler and updates orchestration state. -6. Bus enqueues next node(s)/callback jobs as needed. - -## Event Model (In-Process) - -Bus emits internal lifecycle events: - -- Dispatch: `DispatchStarted`, `DispatchSucceeded`, `DispatchFailed` -- Job: `JobStarted`, `JobSucceeded`, `JobFailed` -- Chain: `ChainStarted`, `ChainAdvanced`, `ChainCompleted`, `ChainFailed` -- Batch: `BatchStarted`, `BatchProgressed`, `BatchCompleted`, `BatchFailed`, `BatchCancelled` -- Callback: `CallbackStarted`, `CallbackSucceeded`, `CallbackFailed` - -Observer API: - -```go -type Observer interface { Observe(context.Context, Event) } -type ObserverFunc func(context.Context, Event) -func MultiObserver(observers ...Observer) Observer -``` - -Event fields (minimum): - -- `schema_version` -- `event_id` -- IDs: `job_id`, `chain_id`, `batch_id`, `attempt` -- `job_type`, `queue` -- `occurred_at`, `duration` -- `error` (optional) - -The observer event schema and the workflow-envelope protocol are separate version domains even though both currently start at `1`. Envelope `schema_version` governs internal workflow dispatch decoding. `Event.SchemaVersion` governs the shared queue/worker/workflow observer contract, and transition-receipt `event_schema_version` pins only that observer contract. - -## State Model and Store - -```go -type WorkflowStore interface { - CreateChain(ctx context.Context, rec ChainRecord) error - AdvanceChain(ctx context.Context, chainID string, completedNode string) (next *ChainNode, done bool, err error) - FailChain(ctx context.Context, chainID string, cause error) error - GetChain(ctx context.Context, chainID string) (ChainState, error) - - CreateBatch(ctx context.Context, rec BatchRecord) error - MarkBatchJobStarted(ctx context.Context, batchID, jobID string) error - MarkBatchJobSucceeded(ctx context.Context, batchID, jobID string) (BatchState, done bool, err error) - MarkBatchJobFailed(ctx context.Context, batchID, jobID string, cause error) (BatchState, done bool, err error) - CancelBatch(ctx context.Context, batchID string) error - GetBatch(ctx context.Context, batchID string) (BatchState, error) - - MarkCallbackInvoked(ctx context.Context, key string) (bool, error) - Prune(ctx context.Context, before time.Time) error -} -``` - -Stores that execute across competing workers can add the compatible outcome capability: - -```go -type WorkflowOutcomeStore interface { - WorkflowStore - FailChainNode(ctx context.Context, chainID, nodeID string, cause error) (ChainState, bool, error) - SettleBatchJob(ctx context.Context, batchID, jobID string, outcome BatchJobOutcome, cause error) (BatchState, bool, error) -} -``` - -Implementations: - -- `MemoryStore` (local/test default) -- `SQLStore` (recommended production) - -Workflow creation requires a non-empty workflow ID, at least one chain node or batch member, and a non-empty unique ID for every node or member. Builders already produce records with those properties; applications that call `WorkflowStore` directly must do the same. The built-in memory store snapshots chain nodes and payload bytes during creation and returns isolated copies, so mutating an input record, successor, or `ChainState` does not mutate persisted state. - -Both implementations claim a chain node or batch member before changing its parent state. SQL performs that claim and an arithmetic parent update in one transaction, so duplicate delivery cannot advance twice and concurrent batch members cannot overwrite one another's counters. The same concurrency contract runs against SQLite, MySQL, and PostgreSQL. - -Built-in stores also implement the additive `WorkflowOutcomeStore` capability. It gives successful and failed deliveries of one chain node or batch member a single first-writer settlement boundary. A contradictory late delivery is acknowledged without changing the committed outcome or aggregate counters; it emits no losing logical job/workflow fact, advances no application progress, and invokes no callback. Chain transitions compare the persisted node order and `NextIndex`; batch transitions report whether the requested outcome category owns the already-claimed member. The established batch schema and `BatchState` do not retain a per-member failure cause, so the `cause` argument remains delivery-local metadata rather than part of first-writer ownership. Persisted chain failures retain their first authoritative cause; built-in `FailChain` is now a no-op for an already-terminal chain instead of overwriting that cause. The base `WorkflowStore` remains source-compatible for established custom stores, but a custom implementation must add `WorkflowOutcomeStore` to provide the public atomic contradictory-category guarantee across processes. - -The built-in engine store has a narrower private contract as well. Its `claimedNow` result means only that the current store call performed the transition; it is response-local and is not durable owner proof. For durable ownership, memory and SQL built-ins record an immutable transition receipt containing its receipt format version, reconstructed observer-event schema version, workflow incarnation, member outcome, physical delivery generation and attempt, and correlated job identity. SQL writes that receipt in the same transaction as the workflow mutation; memory retains it only for the life of the process. Both version fields are currently `1`. A runtime fails recovery closed when either `receipt_version` or `event_schema_version` is unsupported: it returns an uncommitted error and neither acknowledges the delivery, executes application code, marks application state committed, nor reconstructs facts from a format it does not understand. Logical receipt validation requires a complete valid persisted owner, including a nonnegative owner attempt; nonempty current dispatch/`JobID`; matching workflow kind/ID/member/incarnation and owner dispatch; and an immutable job fingerprint match. The current attempt is only physical provenance and may differ from the owner or be negative. Chain duplicates may also carry a different physical `JobID`; batch `JobID` remains its logical member key and must match. A logically valid receipt proves the application transition and suppresses handler replay. Reconstructing successful member or aggregate facts additionally requires exact recovered generation, current attempt, and physical `JobID` ownership. Queue provenance alone cannot prove either boundary. - -After the current delivery both claims a built-in transition and obtains its receipt, the engine marks application state committed on that delivery's settlement boundary. This is a provenance handoff, not queue settlement: if later workflow infrastructure requires same-attempt redelivery, SQL retains the current generation as the receipt owner instead of continuing to carry an older recovered generation. A numbered application retry still clears the link. Focused settlement, SQL-token, and chain post-transition tests cover this handoff, while real SQLite, MySQL, and PostgreSQL tests now cover receipt-backed terminal-chain recovery after forced finalization failure. - -A logically valid failed chain receipt uses the authoritative `ChainState.Failure` to return a permanent physical outcome across exact, different, or legacy recovered-generation provenance and across different attempts or physical `JobID`s. An empty persisted cause returns a permanent diagnostic rather than success. Recovery does not re-run the handler, Catch/Finally callbacks, or occurrence-based `JobFailed`/`ChainFailed` facts. Invalid receipt/event version, incomplete owner, logical dispatch/job-content mismatch, workflow incarnation, outcome, or aggregate flags return an uncommitted error before application code; physical nonownership alone does not. A receipt-absent legacy built-in row and application-defined/decorated stores retain the compatibility fallback, which may execute the handler once to preserve terminal physical classification while still suppressing duplicate facts and callbacks. Failure-receipt insertion and parent failure are one SQL transaction, and a receipt-insert fault rolls both back. A real SQLite queue fixture forces the initial archive plus multiple recovery archives to fail, verifies first-cause and generation lineage survive at attempt zero, and then reaches `dead` at attempt one with the persisted cause and only one application/workflow failure occurrence. Equivalent MySQL/PostgreSQL failed-chain finalization fixtures remain open. - -For batches, the terminal member's transaction also records aggregate completion ownership. Built-in memory settlement holds one mutex; MySQL and PostgreSQL lock the parent row after the member compare-and-swap, so only the first false-to-true terminal transition can create the aggregate-owner receipt. Real twelve-member, twelve-worker fail-fast races on both server dialects prove one aggregate receipt and one failed/cancelled terminal fact pair while every member retains its own receipt. Because batch `JobID` is the logical member key, it must match for replay suppression. A valid duplicate with a different attempt, recovered generation, or legacy provenance still suppresses its handler; successful duplicates settle without facts, while failed duplicates return a generic permanent cause because the original application cause is not persisted. A SQL aggregate row is valid only as a completed transition; cancellation must own failure; and an aggregate row that names the requested member must match that member receipt's workflow incarnation, complete owner, and outcome. Recovery also checks those flags against live terminal state. Missing or contradictory proof fails uncommitted before handlers, callbacks, state-commit signaling, or facts. `BatchCompleted` may be reconstructed for a batch of any size only when this validated aggregate receipt names the exact recovered generation, current attempt, and `JobID`; completed aggregate state without that exact fact owner is never sufficient. `TestSQLStoreBatchAggregateOwnershipMismatchFailsClosed`, `TestSQLStoreBatchAggregateIncarnationMismatchFailsClosed`, and `TestBatchRecoveryRejectsInvalidAggregateReceiptShape` pin these corruption boundaries. `TestDatabaseIntegration_SQLite/sqlite_terminal_batch_completion_recovers_from_completing_member` proves the two-member recovery case without executing either handler again or attributing completion to the earlier member. The SQL delivery reaches its terminal `dead` archive on failed recovery rather than being acknowledged as success, and no fabricated failure or member fact is published. The normal `queue.WithStore(queue.NewSQLStore(...))` path unwraps the built-in store so this private contract remains available, and an option-free `bus.New(existingQueue)` shares that same engine. Application-defined stores, decorators around built-ins, and the retained raw-runtime bus route expose only public store capabilities. They remain source-compatible and can retain first-writer outcome-category semantics through `WorkflowOutcomeStore`, but they do not receive the exact built-in generation/receipt or `claimedNow` guarantees. - -Any recovered predecessor whose validated durable state proves success checks live `NextIndex` and re-dispatches the immediate successor while it has not progressed, without re-running the predecessor. Exact recovered generation, attempt, and physical `JobID` ownership can also reconstruct the predecessor's deferred facts. A missing receipt, an application-defined/decorated store without receipt capability, or a logically valid receipt with different/legacy generation, different attempt, or different physical `JobID` restores only the live continuation; it emits no predecessor facts or callbacks. A supported success receipt is logically validated before this liveness fallback: it cannot own cancellation, and its completion flag must exactly match whether the predecessor is final. Corruption returns an uncommitted outcome with no dispatch or effects. A successor enqueue rejection is likewise uncommitted so recovery can try again. This repairs definite enqueue rejection and legacy/custom liveness, but it is deliberately at-least-once: the predecessor row cannot distinguish a missing successor from one already queued but not yet reflected in workflow state, so recovery may enqueue a duplicate. Once the successor has progressed or the chain is terminal, recovery does not dispatch it again. - -Transition receipts are not observer or continuation outboxes. They neither prove callback delivery nor retain `Progress` closures, successor-enqueue acceptance, batch fan-out, or deferred observer invocation after queue settlement. After a recovered SQL delivery exhausts finalization retries, the driver makes a fenced best-effort repair that restores its inherited receipt lineage on the same attempt and returns it to `pending` with a bounded delay. Real SQLite success, failed-chain, and failed-batch scenarios force multiple recovery finalization failures before later settlement and prove no handler replay while the repair succeeds. The repair cannot cover a failed repair transaction, physical commit/readback ambiguity, or a row already removed by successful queue settlement. Durable continuation intents and a settlement outbox remain roadmap work. - -MySQL key validation follows the capacities discovered from every identity column used by a workflow and its receipt. A wholly fresh auto-schema uses 255-byte workflow/member and receipt identities plus 512-byte callback keys. When only the receipt table is missing beside established state, ordinary startup derives its `workflow_id` width as the larger of the effective chain and batch ID capacities and its `member_id` width as the larger of the chain-node and batch-job capacities. A real upgrade fixture widens legacy state to 512 bytes, drops only the receipt table, and proves startup recreates it at 512/512 while accepting identities above the fresh defaults. Existing tables are never altered: a pre-existing receipt instead intersects the accepted capacities discovered from the complete live schema. An incompatible existing receipt therefore requires a quiescent managed migration and a new store instance. If the derived three-column primary key exceeds the server's indexed-key budget, creation fails with the derived widths and schema-first guidance; it does not silently narrow established identities. Operators must precreate a compatible indexed receipt schema or explicitly migrate supported identity limits and existing data before rollout. - -Caller-managed workflow identity columns, including `bus_workflow_transition_receipts.workflow_id` and `.member_id`, must use `VARBINARY`; `VARCHAR`, `TEXT`, and fixed-width `BINARY` are rejected because they do not provide the same byte-exact round-trip contract. `queue.NewSQLStore` retains the legacy behavior of enabling schema creation even when compatibility field `SQLStoreConfig.AutoMigrate` is false; use `queue.NewSQLStoreWithManagedSchema` only after provisioning every required table and both receipt version columns. Before upgrading an incompatible schema, quiesce workflow writers, audit case- or padding-equivalent keys for collisions, convert all identity columns and align their receipt widths in one maintenance window, then restart workers with a new store instance. This is a MySQL persisted-schema, runtime-behavior, and operational rollout concern, not a source/API, configuration-file, wire, or minimum-Go-version change. Real MySQL and PostgreSQL tests exercise fresh auto-created receipt tables, serialized aggregate ownership, and receipt-backed recovery. On rollback, quiesce new workers and leave the receipt table in place for a later re-upgrade; old binaries ignore it, while dropping it discards transition provenance. Managed-schema migration and physical commit/readback ambiguity when a post-commit receipt read cannot reach the database remain open. Conservative chain re-dispatch does not make successor enqueue exactly-once, and batch fan-out still requires persisted dispatch-intent work. - -## Failure, Idempotency, Retry Ownership - -Chain: - -- strict order -- fail-fast on first node failure -- `Catch` once -- `Finally` once - -Batch: - -- jobs execute independently -- if `AllowFailures=false`, first failure cancels batch -- if `AllowFailures=true`, remaining jobs continue -- `Catch` once on first failure -- `Finally` once on terminal state - -Idempotency keys: - -- `dispatch:` -- `chain_advance::` -- `callback::` - -Retry ownership: - -- Queue owns transport retry timing. -- Bus owns orchestration state transitions. -- Bus does not run independent retry loops by default. - -Callback failure policy: - -- Commit terminal workflow state first. -- Execute callback as `bus:callback` job. -- Callback failure emits event and retries with capped attempts. -- Callback failure does not roll back terminal workflow state. - -Retention: - -- Default 7-day retention for completed/cancelled/failed orchestration records. -- Per-workflow override supported. -- Prune API required (`SQLStore` command in phase 2+). - -## Testing Surface - -`bus.Fake` assertions: - -- `AssertNothingDispatched(t)` -- `AssertDispatched(t, jobType)` -- `AssertDispatchedOn(t, queue, jobType)` -- `AssertDispatchedTimes(t, jobType, n)` -- `AssertNotDispatched(t, jobType)` -- `AssertCount(t, n)` -- `AssertChained(t, expected []string)` -- `AssertBatched(t, predicate func(BatchSpec) bool)` -- `AssertBatchCount(t, n)` -- `AssertNothingBatched(t)` - -## Bus Driver Strategy - -`bus` runtime backends (initial proposal): - -| Runtime | Role | Phase | -|:--|:--|:--| -| `queue` (existing queue drivers) | Default execution runtime for bus envelopes | 1 | -| `temporal` | Optional orchestration runtime adapter | 3 | - -Notes: - -- Phase 1 and 2 should run entirely on existing queue runtime. -- Temporal adapter is optional and should be a separate package (`bus/driver/temporal`). - -## Rollout - -### Phase 1 - -- Bus facade + registry -- single dispatch + chain -- in-process observer events -- fake assertions -- memory store - -### Phase 2 - -- batch + callbacks -- SQL store + pruning -- find APIs - -### Phase 3 - -- middleware library -- temporal runtime adapter -- richer test helpers - -## Finalized Decisions - -1. Use jobs `monitor:poll`, `monitor:downsample`, `monitor:alert` in examples. -2. Include `schema_version` in bus envelopes/events. -3. `MemoryStore` for local/tests, `SQLStore` for production. -4. Keep bus worker lifecycle queue-owned (`StartWorkers` remains queue-owned). -5. Keep bus scoped to orchestration only in this repository. diff --git a/docs/bus-implementation-checklist.md b/docs/bus-implementation-checklist.md deleted file mode 100644 index 875760f..0000000 --- a/docs/bus-implementation-checklist.md +++ /dev/null @@ -1,168 +0,0 @@ -# Bus Implementation Checklist - -> **Status:** Historical checklist for the original independent `bus` -> implementation. [`docs/plan.md`](./plan.md) is the current source of truth. The engine now lives -> in `internal/workflow`, root `queue` owns the application surface, and public -> `bus` is a deprecated forwarding/raw-runtime compatibility facade. - -## Scope - -- Build full `bus` design in this repository. -- Keep `bus` orchestration-only. -- Do not add pub/sub transport concerns into `bus`. - -## Work Items - -### Core API and Skeleton - -- [x] Add `bus` package with facade and constructors. -- [x] Define `Bus`, `Job`, `JobOptions`, `Handler`, `Context`, `Observer`. -- [x] Add `WithObserver`, `WithStore`, `WithClock` options. -- [x] Add internal envelope model with `schema_version=1`. - -Acceptance: - -- `go test ./...` passes. -- Public API compiles from examples in `docs/bus-design.md`. - -### Queue Runtime Integration - -- [x] Register internal job types: - - `bus:job` - - `bus:chain:node` - - `bus:batch:job` - - `bus:callback` -- [x] Route all execution through underlying `queue.Queue`. -- [x] Keep worker lifecycle queue-owned (`StartWorkers`, `Shutdown` pass-through). - -Acceptance: - -- Queue-backed unit/integration tests verify envelopes execute through queue handlers. - -### Handler Registry and Dispatch - -- [x] Implement `Register(jobType, handler)`. -- [x] Implement `Dispatch(ctx, job)` end-to-end. -- [x] Emit dispatch and job lifecycle events. - -Acceptance: - -- Dispatch success/failure event assertions pass. -- Unknown job type fails deterministically with clear error. - -### Chain Orchestration - -- [x] Implement `Chain(...).Dispatch(ctx)`. -- [x] Execute chain nodes strictly in order. -- [x] Implement `Catch` (once) and `Finally` (once). -- [x] Stop chain on first failure. - -Acceptance: - -- Ordered execution test. -- Fail-fast test. -- `Catch` and `Finally` once-only tests. -- Duplicate node completion does not advance chain twice. - -### Batch Orchestration - -- [x] Implement `Batch(...).Dispatch(ctx)`. -- [x] Track `total`, `pending`, `processed`, `failed`. -- [x] Implement `AllowFailures` behavior. -- [x] Implement `Progress`, `Then`, `Catch`, `Finally`. - -Acceptance: - -- Progress math tests. -- First-failure cancellation test when `AllowFailures=false`. -- Continuation test when `AllowFailures=true`. -- Callback once-only tests under retries. - -### Store Implementations - -- [x] Implement `MemoryStore`. -- [x] Implement `SQLStore`. -- [x] Add `FindChain` and `FindBatch`. -- [x] Add prune API and SQL prune command path. - -Acceptance: - -- Store contract tests run against both stores. -- Restart/retry safety tests pass with SQL store. - -### Idempotency and Retry Semantics - -- [x] Enforce idempotency keys: - - `dispatch:` - - `chain_advance::` - - `callback::` -- [x] Keep queue as retry owner for transport timing. -- [x] Ensure callback failure does not roll back terminal workflow state. - -Acceptance: - -- Duplicate dispatch test. -- Duplicate chain-advance test. -- Duplicate callback job test. -- Retry behavior tests prove no duplicated orchestration transitions. - -### Middleware - -- [x] Add middleware pipeline contracts. -- [x] Implement deterministic middleware order. -- [x] Add built-ins: - - `RetryPolicy` - - `FailOnError` - - `SkipWhen` - - `WithoutOverlapping` - - `RateLimit` - -Acceptance: - -- Middleware order test. -- Middleware short-circuit test. -- Error propagation and retry interaction tests. - -### Testing Helpers - -- [x] Implement `bus.Fake`. -- [x] Add assertions: - - `AssertNothingDispatched` - - `AssertDispatched` - - `AssertDispatchedOn` - - `AssertDispatchedTimes` - - `AssertNotDispatched` - - `AssertCount` - - `AssertChained` - - `AssertBatched` - - `AssertBatchCount` - - `AssertNothingBatched` - -Acceptance: - -- Fake contract tests cover each assertion method. - -### Temporal Adapter (Optional Runtime) - -- [x] Create `bus/driver/temporal` as separate adapter package. -- [x] Map bus primitives to Temporal workflows/activities. -- [x] Keep Temporal types out of core `bus` API. - -Acceptance: - -- Adapter compiles behind feature flag/build target. -- Adapter integration tests cover chain, batch, callback behavior. - -## Global Test Gates - -- [x] `go test ./...` -- [x] `go test -race ./...` -- [ ] `go test -tags integration ./integration/...` -- [x] Duplicate-delivery/idempotency scenarios included in integration suite. - -## Merge Criteria - -- [ ] Bus README/examples added. -- [ ] API docs updated. -- [ ] No queue API regressions. -- [ ] CI green across unit/race/integration. diff --git a/docs/direct-delivery-migration.md b/docs/direct-delivery-migration.md index 753723c..d13f672 100644 --- a/docs/direct-delivery-migration.md +++ b/docs/direct-delivery-migration.md @@ -24,7 +24,7 @@ Drivers carry the record using their native transport boundary: Missing metadata is valid for legacy and low-level deliveries. Version 1 is trusted. Malformed or unknown versions never block application delivery and never supply correlation IDs; workers fall back to the physical application identity or decode a supported version-one workflow envelope. -Chains, batches, and ephemeral callbacks continue to use the version-one workflow envelope because their durable state transitions require orchestration fields. The raw `bus.New(busruntime.Runtime)` compatibility route also retains its exact version-one `bus:job` bytes. New workers keep all four legacy handlers registered, so old backlog remains readable. +Chains, batches, and ephemeral callbacks continue to use the version-one workflow envelope because their durable state transitions require orchestration fields. New workers keep all four legacy handlers registered, so old backlog remains readable. Application job types equal to `bus:job`, `bus:chain:node`, `bus:batch:job`, or `bus:callback` continue through the legacy direct envelope. This prevents an application registration from replacing a reserved workflow handler. diff --git a/docs/events.md b/docs/events.md index 873589b..ad16ac6 100644 --- a/docs/events.md +++ b/docs/events.md @@ -1,6 +1,6 @@ # Queue Events Contract -This document defines the root application facade's unified observability contract emitted through `queue.Observer`. `Event.Layer` distinguishes queue, worker, and workflow facts without requiring separate observer models on the normal `*queue.Queue` path. The deprecated `bus` package retains its legacy event shape only as an adapter at the compatibility boundary; it no longer owns a second event producer or orchestration engine. +This document defines the root application facade's unified observability contract emitted through `queue.Observer`. `Event.Layer` distinguishes queue, worker, and workflow facts without requiring separate observer models on the normal `*queue.Queue` path. ## Goals @@ -150,16 +150,11 @@ Built-in `EventProcessArchived` support: - Additive changes are allowed (new event kinds, new optional fields). - Breaking changes require an explicit compatibility release and migration guide; after v1 they require a major version bump. -## Unified observer migration - -The observer collapse is an explicit pre-v1 compatibility boundary: +## Unified observer contract - `queue.WithObserver` accepts `queue.Observer` and receives queue, worker, and workflow layers. -- `queue.WorkflowEvent`, `queue.WorkflowEventKind`, `queue.WorkflowObserver`, and `queue.WorkflowObserverFunc` are deprecated aliases of the root event model. -- Code that used unkeyed `queue.Event` or `bus.Event` literals must switch to keyed literals because the envelopes now include correlation fields. -- Adapt custom `bus.Observer` implementations with `queue.ObserverFunc` when constructing a root queue. `bus.WithObserver` remains supported only on the retained raw-`busruntime.Runtime` construction route; an already-built `*queue.Queue` must receive observation options when it is constructed. - Sinks that only need logical job, chain, batch, and callback transitions can return early unless `event.Layer == queue.EventLayerWorkflow`. -- Legacy `queue.WorkflowObserver` and `bus.Observer` sinks also received `EventDispatchStarted`, `EventDispatchSucceeded`, and `EventDispatchFailed`. Those dispatch facts deliberately belong to `EventLayerQueue` in the unified model because they describe public queue acceptance, not a committed workflow transition. To retain the full legacy scope, accept the workflow layer plus those three event kinds: +- Public dispatch lifecycle facts deliberately belong to `EventLayerQueue` because they describe public queue acceptance, not a committed workflow transition. A sink that needs both workflow transitions and dispatch lifecycle facts can accept them explicitly: ```go if event.Layer != queue.EventLayerWorkflow { diff --git a/docs/examplegen/main.go b/docs/examplegen/main.go index c6cdb80..644131d 100644 --- a/docs/examplegen/main.go +++ b/docs/examplegen/main.go @@ -53,12 +53,6 @@ func run() error { importPath string } targets := []target{{dir: root, importPath: modPath}} - if st, err := os.Stat(filepath.Join(root, "queuefake")); err == nil && st.IsDir() { - targets = append(targets, target{ - dir: filepath.Join(root, "queuefake"), - importPath: modPath + "/queuefake", - }) - } for _, target := range targets { fset := token.NewFileSet() @@ -577,12 +571,6 @@ func writeMain(base string, fd *FuncDoc, moduleImportPath, importPath string) er if strings.Contains(ex.Code, "queue.") && importPath != moduleImportPath { imports[moduleImportPath] = true } - if strings.Contains(ex.Code, "queuefake.") && importPath != moduleImportPath+"/queuefake" { - imports[moduleImportPath+"/queuefake"] = true - } - if strings.Contains(ex.Code, "bus.") { - imports[moduleImportPath+"/bus"] = true - } if strings.Contains(ex.Code, "redisqueue.") { imports[moduleImportPath+"/driver/redisqueue"] = true } diff --git a/docs/ga-readiness.md b/docs/ga-readiness.md index 52e2551..e44e4cd 100644 --- a/docs/ga-readiness.md +++ b/docs/ga-readiness.md @@ -192,7 +192,7 @@ What is not yet sufficient for a GA claim: - README/API cleanup: - `Testing` group hidden from API index - README presents the queue-first constructor path only (no public runtime constructor path) - - README testing guidance no longer requires `bus.NewFake()` + - README testing guidance uses the canonical `queue.NewFake()` API ## 7. Coverage and Test Debt (should complete) diff --git a/docs/metrics-contract.md b/docs/metrics-contract.md index c431bd5..f6f100d 100644 --- a/docs/metrics-contract.md +++ b/docs/metrics-contract.md @@ -2,7 +2,7 @@ This document defines the baseline observability contract for `queue` before GA. -For the normal root facade, it covers one event stream: `queue.Event` values delivered to the `queue.Observer` installed with `queue.WithObserver(...)`. `Event.Layer` identifies whether a fact came from queueing, worker execution, or workflow orchestration. The deprecated `bus` package translates the same internal producer into its frozen legacy event shape only for compatibility consumers; it no longer owns a second event stream. +For the normal root facade, it covers one event stream: `queue.Event` values delivered to the `queue.Observer` installed with `queue.WithObserver(...)`. `Event.Layer` identifies whether a fact came from queueing, worker execution, or workflow orchestration. This is a baseline contract. Before GA, pin a version and treat field/label changes as compatibility-impacting. @@ -23,8 +23,6 @@ Source: - `queue.ObserverFunc` - `queue.WithObserver(...)` -`queue.Config.Observer` is a deprecated compatibility path into the same stream. - Queue, worker, and workflow events carry the same applicable dispatch/job/chain/batch correlation IDs. Internal envelope IDs are excluded from `Event.JobKey`, so telemetry grouping follows the logical application job rather than a one-off wrapper delivery. `Event.JobKey` remains an observability field rather than a persisted uniqueness key; both contracts resolve the same logical job type and payload, while uniqueness additionally includes version and effective queue framing. Event kind type: @@ -86,9 +84,7 @@ Event kind type: - `queue.EventKind` -The deprecated `WorkflowEvent`, `WorkflowEventKind`, `WorkflowObserver`, and `WorkflowObserverFunc` names are aliases of the canonical root model rather than a second stream. - -Public dispatch lifecycle facts are intentionally queue-layer events because they bracket queue acceptance. They are not logical workflow transitions, even when a workflow dispatch produced them. A sink migrated from the legacy workflow observer contract must accept `dispatch_started`, `dispatch_succeeded`, and `dispatch_failed` in addition to `EventLayerWorkflow` events to retain its former scope. A filter that accepts only `EventLayerWorkflow` is the narrower job, chain, batch, and callback stream. +Public dispatch lifecycle facts are intentionally queue-layer events because they bracket queue acceptance. They are not logical workflow transitions, even when a workflow dispatch produced them. A filter that accepts only `EventLayerWorkflow` is the narrower job, chain, batch, and callback stream. Current workflow event kinds (via internal orchestration engine) include: @@ -191,6 +187,6 @@ After GA (target): ## Cross-References - `observability.go` (canonical `queue.Event`, event layers, kinds, and observer helpers) -- `runtime.go` (one `queue.WithObserver(...)` attachment path and deprecated workflow aliases) +- `runtime.go` (one `queue.WithObserver(...)` attachment path) - `docs/ops-alerts.md` (dashboard/alert baseline) - `docs/runbooks/` (incident response) diff --git a/docs/plan.md b/docs/plan.md index 793991c..acabe7e 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -6,11 +6,11 @@ Last updated: 2026-07-20 Baseline: `origin/main` at `18a7647` -Working branch: `refactor/unify-queue-workflow` +Working branch: `refactor/remove-legacy-api` ## Goal -Make `queue` a coherent, dependable queue and workflow library with one normal application model, explicit ownership between delivery and orchestration, truthful backend guarantees, compatibility-conscious evolution, and validation that covers every module and supported deployment shape. +Make `queue` a coherent, dependable queue and workflow library with one normal application model, explicit ownership between delivery and orchestration, truthful backend guarantees, and validation that covers every module and supported deployment shape. This is the living execution plan. Keep it current as work lands. A task is complete only when its acceptance criteria and applicable validation pass. @@ -19,8 +19,8 @@ This is the living execution plan. Keep it current as work lands. A task is comp - Work from the highest-priority unblocked item in the current milestone. - Add a regression test before or with every correctness fix. - Exercise changes through the public `*queue.Queue` path, not only private runtimes or driver internals. -- Preserve source/API, configuration, persisted-data, runtime, operational, and minimum-Go-version compatibility by default. -- Record any necessary incompatibility in the decision log before implementation, including why compatibility cannot be preserved and how users migrate. +- Preserve compatibility by default, except for an explicitly approved breaking cleanup that removes the retired `bus`, `queuefake`, legacy observer aliases, and legacy configuration shims. +- Record every incompatibility in the decision log with its migration path. - Treat generators, GoDoc examples, and templates as authoritative; regenerate checked-in documentation and verify a second generation produces no diff. - Validate every affected Go module independently. Workspace success alone is insufficient. - Preserve intentional sibling `replace` directives used for repository testing. @@ -50,7 +50,7 @@ The following direction is accepted and governs implementation order: - There is one `queue.Observer` receiving one extensible `queue.Event` model for dispatch, enqueue, attempt, queue-control, chain, batch, and continuation facts. - Observation is best-effort telemetry and never controls retries, workflow transitions, or business continuations. - Reliable workflow continuations are named `Job` values persisted and dispatched through the queue. Function callbacks may remain only as explicitly ephemeral compatibility helpers. -- `bus` becomes a deprecated compatibility facade over the canonical queue model. Compatibility declarations may remain while callers migrate, but the package must not retain a second orchestration engine, store implementation, event producer, or lifecycle owner. Its independent fake remains explicit debt under M5-07. +- No public compatibility facade remains. Applications use the root `queue` package directly for dispatch, workflows, observation, stores, and fakes. - Internal delivery and workflow components may remain separate, but that separation is an implementation detail with explicit outcome contracts rather than duplicated application APIs. The target application experience is intentionally small: @@ -118,7 +118,7 @@ Exact field evolution remains compatibility-sensitive. Existing fields and event ## Compatibility Guardrails - Preserve the root `Queue`, `Job`, `Message`, handler, and builder APIs wherever viable. -- Prefer forwarding aliases and deprecation periods over immediate removal of public `bus` APIs. +- The approved legacy cleanup removed the public `bus` and `queuefake` packages rather than retaining forwarding aliases. Applications now migrate directly to root `queue` APIs. - Do not silently change the meaning of an existing option. Where semantics are currently broken or inconsistent, document the corrected contract and add focused compatibility tests. - Version internal transport/workflow envelopes before changing their persisted or wire representation. - Define mixed-version producer/worker behavior for every envelope change. @@ -220,7 +220,7 @@ Status: Accepted Required before: M2 public model consolidation -Decision: use one root `queue.Observer` and one root `queue.Event` superset. `queue.WithObserver` observes both delivery and workflow facts. Existing root `WorkflowObserver`, `WorkflowObserverFunc`, `WorkflowEvent`, and `WorkflowEventKind` names become deprecated aliases or adapters. The compatibility `bus` package translates to its legacy event representation for a deprecation period instead of retaining a second event producer. +Decision: use one root `queue.Observer` and one root `queue.Event` superset. `queue.WithObserver` observes both delivery and workflow facts. The former root workflow observer aliases and the public `bus` event adapter were removed in the approved breaking cleanup, so there is no second event producer or observer contract. ### D-007: Workflow Continuations @@ -244,12 +244,12 @@ Status: Accepted Required before: publishing the unified observer release -Decision: the observer collapse is an intentional source/API and runtime-behavior compatibility boundary in the next pre-v1 feature release. `WithObserver` accepts the canonical root `Observer`, legacy root workflow names alias that root model, and one observer receives all layers. Preserving the exact old type identity would require retaining the second public event contract or accepting an untyped option, both of which conflict with the requested collapse and reliable compile-time contracts. +Decision: the observer collapse is an intentional source/API boundary. `WithObserver` accepts the canonical root `Observer`, and one observer receives all layers. The legacy root workflow names and `bus` observer contract were removed because preserving their type identities would retain a second public event model. Migration requirements: -- replace unkeyed `queue.Event` and `bus.Event` literals with keyed literals because the event envelopes gained correlation fields; -- adapt custom `bus.Observer` implementations with `queue.ObserverFunc`, or keep raw-runtime legacy bus consumers on `bus.WithObserver`; an existing `*queue.Queue` must receive its observer at root construction; +- replace unkeyed event literals with keyed `queue.Event` literals because the event envelope gained correlation fields; +- adapt custom observer implementations with `queue.ObserverFunc` and configure an existing `*queue.Queue` with `queue.WithObserver` at construction; - filter `Event.Layer` when an existing workflow observer should retain workflow-only volume; - make observer-owned mutable state concurrency-safe because dispatchers and workers may call the same observer concurrently. @@ -261,17 +261,16 @@ Status: Accepted Required before: M2 public API consolidation -Decision: root `queue` owns the canonical application model. The orchestration implementation moves behind `internal/workflow`, root composes that internal engine directly, and public `bus` becomes a forwarding compatibility package. Do not create another public workflow-model package. Preserve the legacy raw-`busruntime.Runtime` construction path temporarily while normal `bus.New(*queue.Queue)` calls route to the root facade. +Decision: root `queue` owns the canonical application model. The orchestration implementation lives behind `internal/workflow`, and root composes that engine directly. There is no public workflow-model or facade package. -The migration is staged to avoid an import cycle and an all-at-once source break: +The implementation was staged to avoid an import cycle while preserving one public model: 1. extract the existing bus engine behind `internal/workflow` without changing event names, JSON envelopes, SQL schemas, stores, retry behavior, or public type identity; 2. switch root production code from public `bus` imports to the internal engine and add an import-direction guard; 3. define canonical root `Message`, dispatch/state, middleware, store, and builder contracts one model at a time; -4. turn compatible `bus` declarations into deprecated aliases/adapters, retaining legacy composite-literal fields until a separately approved compatibility boundary; -5. route option-free `bus.New(*queue.Queue)` through root and reject construction-only options explicitly instead of constructing another independently configured engine. +4. remove the retired facade, aliases, and configuration shims after root ownership and canonical coverage are established. -The extraction itself is source/API, configuration, persisted-data, runtime-behavior, operational, wire, and minimum-Go-version neutral. The facade conversion deliberately changes runtime behavior for every `bus.New(*queue.Queue)`: compatibility views now share the root handler registry, store, observer, middleware, and lifecycle instead of constructing independent state over the same physical runtime. Code that needs isolation must use distinct runtimes. It also changes configuration and runtime behavior for option-bearing `bus.New(*queue.Queue, ...)` and `bus.NewWithStore(*queue.Queue, ...)`: those calls now return `bus.ErrQueueOptionsUnsupported` because options cannot apply only to a shared view. Callers migrate those options to root queue construction and then use option-free `bus.New(existingQueue)`; the raw-`busruntime.Runtime` route retains its legacy options. Later alias conversions require focused source-compatibility fixtures before landing. +The extraction is configuration, persisted-data, runtime-behavior, operational, wire, and minimum-Go-version neutral. The final cleanup is a deliberate source/API break: migrate `bus` imports and configuration to root `queue`, `queuefake.New()` to `queue.NewFake()`, `Config.Observer` to `queue.WithObserver(...)`, and Redis `ServerLogger` to `DriverBaseConfig.Logger`. ## Milestone M0: Restore a Trustworthy Baseline @@ -344,11 +343,11 @@ Objective: introduce a stable internal architecture while preserving the root ap - [ ] **M2-02 — Create a domain-neutral core/SPI package.** Drivers depend on this package rather than importing root helpers through re-export and global hook layers. - [ ] **M2-03 — Introduce adapters alongside existing drivers.** Migrate one local and one durable driver first, keeping compatibility tests on both paths. - [ ] **M2-04 — Remove the mutable runtime hook bridge.** Retire `any`-based global initialization only after every driver uses the new SPI. -- [x] **M2-05 — Consolidate the Job model.** Root `queue.Job` is the sole canonical public application specification, `queue.Message` is the delivered handler model, and `queue.StoredJob` is the persisted workflow model. Direct dispatch freezes exact root payload bytes instead of passing through the private fluent workflow DTO. `bus.Job` remains only as the documented source-compatible boundary DTO whose deferred JSON conversion occurs once at facade dispatch. +- [x] **M2-05 — Consolidate the Job model.** Root `queue.Job` is the sole public application specification, `queue.Message` is the delivered handler model, and `queue.StoredJob` is the persisted workflow model. Direct dispatch freezes exact root payload bytes instead of passing through the private fluent workflow DTO. - [ ] **M2-06 — Version the envelope.** Define schema evolution, unknown-version behavior, mixed producer/worker deployments, and rollback. -- [x] **M2-07 — Resolve the public `bus` direction.** Implemented D-003 and D-010 in bounded slices: extracted `internal/workflow`, removed root production imports of public `bus`, established physical root models, then made `bus` a deprecated forwarding facade with source- and wire-compatibility fixtures. One internal engine now owns orchestration; root composes it directly through physical root messages, middleware, workflow records, and stores; and `bus.New(existingQueue)` wraps that exact engine without registering another. Legacy `bus.Job`, `Event`, `Observer`, `Bus`, `Option`, and self-returning builder interfaces remain physical boundary contracts, while compatible model names forward to root. Literal protocol, transport-boundary, legacy SQLite, source, construction, package-identity, import-direction, adapter, and deferred-encoding fixtures pin the migration. The full module, race, generated-documentation, local/SQLite, Redis, and NATS validation matrix passes. +- [x] **M2-07 — Resolve public ownership.** Extracted `internal/workflow`, removed root production imports of public facades, established physical root models, and removed the retired `bus` package. One internal engine owns orchestration while root composes it through physical root messages, middleware, workflow records, and stores. Canonical root contracts cover direct dispatch, chains, batches, events, and backend execution. - [ ] **M2-08 — Separate producer and worker lifecycle.** Model start, running, draining, stopped, and failed states without `sync.Once` poisoning. -- [x] **M2-09 — Collapse root observers.** One root observer receives delivery and workflow events through a shared sink without duplicate execution events. The legacy public `bus` observer is now translated only at the deprecated raw-runtime compatibility boundary. +- [x] **M2-09 — Collapse root observers.** One root observer receives delivery and workflow events through a shared sink without duplicate execution events. Legacy observer aliases and facade adapters are removed. - [x] **M2-10 — Stop enveloping direct jobs as workflows.** Root dispatch now sends the application type and exact payload with versioned out-of-payload correlation. One engine executor still owns middleware, handler lookup, logical events, attempt classification, and settlement deferral. Every backend round-trips supported metadata, old version-one envelopes remain readable, reserved protocol-name applications retain the legacy route, raw-runtime `bus` bytes remain frozen, and `WithLegacyDirectEnvelope` supports a safe workers-first rollout. Direct/envelope uniqueness parity remains on the existing `v1` key. The additive SQL column, asymmetric mixed-worker boundary, exact payload correction, and rollback procedure are documented in `docs/direct-delivery-migration.md`. Exit criteria: @@ -407,7 +406,7 @@ Objective: operational surfaces describe real state consistently and testing API - [ ] **M5-04 — Make stats semantically comparable.** Define pending, scheduled, retry, active, processed, failed, and throughput windows for each capability level. - [ ] **M5-05 — Make history instance-scoped and truthful.** Do not present process-wide sampled memory as durable backend history. - [ ] **M5-06 — Consolidate admin APIs.** Remove `any`-based duplicate paths over time and align not-found, unsupported, and active-operation behavior. -- [x] **M5-07 — Consolidate fakes.** `queue.NewFake` now owns one concurrency-safe direct/workflow recorder; deprecated `bus.Fake` and `queuefake.Fake` are typed compatibility views over that state. Direct dispatch shares production conversion and validation, while chain/batch builders run through the production workflow engine, record only accepted `Dispatch` calls, retain policy, and expose isolated canonical records and lookup state. +- [x] **M5-07 — Consolidate fakes.** `queue.NewFake` owns one concurrency-safe direct/workflow recorder. Direct dispatch shares production conversion and validation, while chain/batch builders run through the production workflow engine, record only accepted `Dispatch` calls, retain policy, and expose isolated canonical records and lookup state. - [ ] **M5-08 — Add observer failure policy.** Define panic/error handling instead of silently swallowing observer failures without diagnostics. Exit criteria: @@ -512,13 +511,14 @@ Record accepted decisions here using the next stable ID. | DL-011 | 2026-07-18 | Treat accepted local work, including delayed workflow descendants, as a shutdown drain obligation bounded by the supplied context. | Shutdown may now wait for accepted delayed work and return the context error when the deadline expires; cleanup remains retryable and a later call converges. This is a runtime-lifecycle correction with no API, configuration, persisted-data, or minimum-Go-version change. | | DL-012 | 2026-07-18 | Make successful queue shutdown terminal while keeping incomplete cleanup retryable. | `Dispatch` and `StartWorkers` now reject use after successful shutdown instead of reporting false success over closed resources; construct a new queue instance to restart. Repeated shutdown is idempotent. This is a runtime-behavior correction with no source/API, configuration, persisted-data, operational-migration, or minimum-Go-version change. | | DL-013 | 2026-07-18 | Fence every SQL processing generation with a random token and require that exact claim to finalize the row. | This is a persisted-schema and operational rollout change, not a source/API, configuration, wire-envelope, or minimum-Go-version break. The nullable `processing_token` column preserves existing rows and old producer-only binaries. Externally managed schemas must add it before new workers start. Quiesce every old SQL worker before migration and then start the new worker fleet; mixed old/new workers are unsafe because old workers settle by row ID and cannot honor the generation fence. Rollback likewise requires quiescing new workers before running an old worker binary. | -| DL-014 | 2026-07-18 | Keep the canonical public workflow models physically owned by root `queue`, with explicit private adapters around one `internal/workflow.Engine`; never expose an `internal` package as the real owner of a public alias. | Source-compatible `bus` model, middleware, and store names now resolve to root types. Legacy `bus.Job`, `Event`, `Observer`, `Bus`, `Option`, and self-returning builder interfaces remain physical compatibility contracts. Existing code that keys behavior on `%T`, `reflect.Type.PkgPath`, gob/interface registration names, generated type registries, DI keys, or custom type-sensitive persistence must migrate applicable names from `github.com/goforj/queue/bus` to `github.com/goforj/queue`. JSON/wire envelopes, SQL schemas/data, runtime outcomes outside DL-015, operational rollout, and the minimum Go version do not change. | -| DL-015 | 2026-07-18 | Make every `bus.New(*queue.Queue)` a view of the root engine, reject view-specific construction options, and preserve the option-bearing raw-runtime route. | This is a configuration and runtime-behavior incompatibility, not a source/API break. Root and bus views now share registrations, store, observer, middleware, and lifecycle; code requiring isolation must use distinct runtimes. Move observer, store, clock, and middleware options to root queue construction, then call option-free `bus.New(existingQueue)`. Preserving the old behavior would preserve the second engine this milestone removes. | -| DL-016 | 2026-07-18 | Dispatch ordinary root jobs by their application type and exact payload, carrying correlation in one versioned driver metadata record instead of the workflow envelope. | Existing root and bus signatures remain source-compatible; advanced metadata helpers and `WithLegacyDirectEnvelope` are additive. Wire behavior changes for root direct jobs, SQL adds nullable `queue_jobs.metadata_json`, and absent payload no longer becomes JSON `null`; arbitrary raw bytes reach handlers without a dispatch-time JSON re-marshal. New workers read old and new deliveries, but old workers cannot safely consume new direct types. Deploy new workers while producers retain legacy emission, switch producers only after all consumers are upgraded, and restore legacy emission plus drain direct backlog before worker rollback. Raw-runtime bus v1 bytes, workflow envelopes, uniqueness keys, configuration files, and the minimum Go version remain stable. | -| DL-017 | 2026-07-18 | Make `queue.NewFake` the sole fake-state owner and reduce `bus.Fake` and `queuefake.Fake` to compatibility views over its direct and workflow records. | Constructor and method signatures, the usable `bus.Fake` zero value, value copyability, and physical `bus.Fake`/`bus.BatchSpec` identities remain source-compatible. Testing runtime behavior is intentionally corrected: queue and workflow compatibility views share direct history and effective default queues; abandoned builders, invalid jobs, and canceled dispatches do not record; builder options survive; chain/batch IDs are opaque lookup identifiers instead of `fake-chain`/`fake-batch`; `FindChain`/`FindBatch` expose pending fake state; `Reset` clears direct, workflow, and store state; and fluent closure callbacks are not retained in fake runtime state or executed. Preserving separate histories or constructor-time records would preserve the duplicate owners and false-positive assertions this milestone removes. Tests that require isolated histories must use distinct fake instances; tests must treat returned IDs as opaque and may inspect them through `FindChain` or `FindBatch`. No configuration, persisted-data, wire-format, operational-rollout, or minimum-Go-version change is implied. | +| DL-014 | 2026-07-18 | Keep canonical public workflow models physically owned by root `queue`, with private adapters around one `internal/workflow.Engine`; never expose an `internal` package as the real owner. | The temporary facade was removed by DL-030. Existing consumers migrate applicable names from `github.com/goforj/queue/bus` to `github.com/goforj/queue`. JSON/wire envelopes, SQL schemas/data, runtime outcomes, operational rollout, and the minimum Go version do not change. | +| DL-015 | 2026-07-18 | During migration, make the former facade a view of the root engine rather than a second engine. | Superseded by DL-030, which removes that facade. Applications configure the root queue directly. | +| DL-016 | 2026-07-18 | Dispatch ordinary root jobs by their application type and exact payload, carrying correlation in one versioned driver metadata record instead of the workflow envelope. | Wire behavior changes for root direct jobs, SQL adds nullable `queue_jobs.metadata_json`, and absent payload no longer becomes JSON `null`; arbitrary raw bytes reach handlers without a dispatch-time JSON re-marshal. New workers read old and new deliveries, but old workers cannot safely consume new direct types. Deploy new workers while producers retain legacy emission, switch producers only after all consumers are upgraded, and restore legacy emission plus drain direct backlog before worker rollback. Workflow envelopes, uniqueness keys, configuration files, and the minimum Go version remain stable. | +| DL-017 | 2026-07-18 | Make `queue.NewFake` the sole fake-state owner for direct and workflow records. | The temporary facade fakes were removed by DL-030. `queue.NewFake` preserves accepted-dispatch recording, builder policy, opaque workflow IDs, lookup/reset behavior, payload isolation, and concurrent access. No configuration, persisted-data, wire-format, operational-rollout, or minimum-Go-version change is implied. | | DL-018 | 2026-07-18 | Reject ambiguous workflow creation records and make memory-store chain snapshots caller-independent. | This intentionally tightens runtime input validation for direct `WorkflowStore` calls without changing source/API, configuration, persisted schema, wire format, or the minimum Go version. Chain and batch IDs must be non-empty, each record must contain at least one member, and member IDs must be non-empty and unique; the public builders already satisfy these constraints. Direct callers must correct invalid records before upgrading. Memory-store callers must no longer rely on mutating input or returned chain payloads to mutate stored state. Existing persisted rows are not rewritten, but deployments that previously wrote ambiguous chain records directly should audit and repair them before further processing. | | DL-019 | 2026-07-18 | Require `VARBINARY` for every MySQL workflow identity column and derive accepted widths from the complete connected schema. | This is a MySQL persisted-schema, runtime-behavior, and operational migration correction; it is not a source/API, configuration-file, wire-envelope, or minimum-Go-version break. Fresh auto-schema uses byte-exact 255-byte workflow/member and receipt identities plus 512-byte callback keys. When a legacy schema has no receipt table, automatic startup validates the existing `VARBINARY` identity columns and derives a shared receipt wide enough for both effective workflow-ID capacities and both member-ID capacities. `TestWorkflowStoreIntegration_MySQLAutoMigratesMissingReceiptAtLegacyWidths` proves ordinary startup preserves a live 512-byte legacy schema and accepts long chain, batch, member, callback, and receipt identities. Existing receipt tables are never altered; their capacities intersect the accepted limits of the complete connected schema. Deployments with an incompatible existing receipt must quiesce workflow writers, audit comparison-equivalent and over-limit identities, migrate the table, and construct a fresh store for capacity rediscovery. Extremely wide legacy identities can produce a derived primary key beyond the server's indexed-key budget; startup then reports both widths and schema-first guidance instead of silently narrowing or altering tables. Operators must precreate a compatible indexed schema or explicitly migrate supported limits and existing data before rollout. Managed `VARCHAR`, `TEXT`, and fixed-width `BINARY` columns still fail instead of silently conflating or padding identities. | | DL-020 | 2026-07-18 | Separate queue-generation provenance, workflow-transition receipts, and the future settlement/continuation outbox. | Each SQL claim carries an opaque generation ID. Same-attempt redelivery normally retains inherited recovery provenance; after the current generation durably claims a transition receipt, the additive delivery-settlement application-state signal makes SQL retain that current generation if later infrastructure still requires redelivery. The signal is not queue settlement or observer delivery, and application retry clears the link. Direct built-in stores record the private immutable receipt in the same transaction as the chain-node or batch-member transition. `receipt_version` versions durable ownership and `event_schema_version` versions the shared observer facts independently from workflow-envelope protocol; both start at `1`, and unsupported values fail closed with an uncommitted outcome. Logical receipt proof requires a complete persisted owner, including a nonnegative owner attempt, matching workflow incarnation/member, dispatch, and job fingerprint plus nonempty current dispatch/`JobID`. The current attempt is physical provenance and may differ from the owner or be negative; chain physical `JobID` may also differ, while batch `JobID` remains the logical member key and must match. That logical proof suppresses handler replay. Reconstructing facts additionally requires the exact prior recovered generation, current attempt, and physical `JobID` owner tuple. A SQL aggregate row must own completion and a cancelled aggregate must own failure; when it names the requested logical member, its incarnation, complete physical owner, and outcome must match that member receipt, and its flags must agree with live terminal state. Contradictions fail uncommitted before effects. Validated durable predecessor success restores its immediate live continuation without handler replay when receipts are absent/hidden or carry non-exact physical provenance, but those paths emit no predecessor facts or callbacks; progressed and terminal state is a no-op, and duplicate successor enqueue remains possible. Failed chain receipts return the first persisted cause as permanent across physical nonowners without replaying callbacks or occurrence-based failure facts; an empty cause becomes a permanent diagnostic. Failed batch receipts return a generic permanent cause across the same physical variants because their original cause is not persisted. Both keep duplicate physical rows on the archive path without fabricated failure facts. Built-in `FailChain` now preserves the first terminal cause rather than allowing later calls to overwrite the authoritative value used by failed-receipt recovery. Direct store callers that relied on late failure-cause replacement must retain that metadata separately. This is a runtime-behavior tightening, not a source/API, configuration, persisted-schema, wire, or minimum-Go-version change. `claimedNow` is response-local, and queue provenance or aggregate state alone is insufficient. Real SQLite, MySQL, and PostgreSQL finalization-failure tests prove supported-version successful terminal-chain recovery; focused memory/SQLite contracts plus a repeated real SQLite archive-failure fixture prove atomic terminal-failure receipts, first-cause archive, and lineage repair. SQLite additionally proves definite chain-successor rejection recovery and failed-batch archive, while the compatibility-focused successor test covers receipt-absent, decorated, and non-exact provenance. Focused corruption contracts pin aggregate incarnation, completion, cancellation, owner, outcome, and member-presence checks. Real twelve-worker MySQL/PostgreSQL races and the SQLite two-member recovery prove only one serialized member owns aggregate terminal effects. This slice otherwise adds `busruntime` API and root runtime behavior without removing a root API or changing configuration files, application wire, or the minimum Go version. It adds `NewSQLStoreWithManagedSchema` because legacy `NewSQLStore` continues enabling migration despite the false `AutoMigrate` zero value, and it adds persisted `bus_workflow_transition_receipts`; managed deployments require schema-first rollout and quiescent worker rollback. Old binaries ignore a retained table, while dropping it loses provenance and old pruning can leave orphan rows. Custom/decorated/raw stores keep public compatibility but have weaker private guarantees outside state-confirmed chain-successor liveness. Successful recovery facts use deterministic IDs; failures remain occurrence-based. The receipt is not a settlement outbox or durable continuation/callback intent. Server-dialect failed-chain finalization evidence, conservative successor re-dispatch's duplicate ambiguity, and physical commit/readback ambiguity remain open. | +| DL-030 | 2026-07-20 | Remove the retired public compatibility surfaces instead of carrying aliases indefinitely. | This approved source/API break removes `bus`, `queuefake`, root workflow observer aliases, `Config.Observer`, and Redis `ServerLogger`. Migrate to root `queue`, `queue.NewFake`, `queue.WithObserver`, and `DriverBaseConfig.Logger`. No queue protocol, persisted workflow data, configuration-file format, or minimum Go version changes. | | DL-021 | 2026-07-19 | Treat coverage as a repository-wide multi-module and backend fan-in contract. | Unit coverage runs every buildable module independently with `GOWORK=off`; each existing backend matrix leg emits one integration profile from the actual integration module. A final guard rejects missing, extra, malformed, duplicate, or non-executing backend evidence before one explicit Codecov upload. This changes CI evidence only; it does not change source/API, configuration, persisted data, runtime behavior, wire formats, operations, or the minimum Go version. | | DL-022 | 2026-07-19 | Close coverage gaps with deterministic behavioral tests without adding production-only test seams. | Scripted failures, focused driver contracts, and a targeted RabbitMQ integration scenario now prove reachable migration, lifecycle, settlement, uniqueness, retry, and shutdown boundaries. Explicit defensive tests pin fail-closed behavior for malformed collaborator results without presenting those results as production client behavior. Fixed-structure JSON marshal failures, entropy-source failures, and failures emitted only inside concrete broker clients remain uncovered where exercising them would require global hooks or production-only indirection. This preserves runtime design and test isolation while making the remaining coverage limits explicit. | | DL-023 | 2026-07-19 | Require opaque physical identity before `settlement_failed` can close a `StatsCollector` active execution. | The source-compatible `busruntime.DeliverySettlementIdentity` type and context accessor are additive public API. Every built-in settlement-aware driver forwards the same handler context through start, process, and settlement facts. Older or custom drivers that omit it may conservatively overcount `Active`; guessing from event fields could undercount a newer execution after a late settlement. Release and upgrade the settlement-aware driver modules with root when exact gauges matter. This changes metrics runtime behavior and adds an operational rollout consideration, but does not change configuration, persisted data, wire formats, or the minimum Go version. | @@ -634,7 +634,7 @@ Record accepted decisions here using the next stable ID. - Completed M2-07 after direct tests covered both store-adapter directions, physical middleware branches, package identities, queue/raw facade builders and lifecycle, exact error propagation, nil/empty payload ownership, and v1 Dispatch-time payload encoding. Revalidated all 12 modules with vet and independent nested-module resolution, root and concurrency-sensitive drivers under the race detector, stable two-pass example/README generation, README snippets, the local/SQLite integration matrix, and real container-backed Redis and NATS suites. - Completed M2-05/M2-10: ordinary root jobs now retain their application type and exact payload, while one versioned correlation record travels through in-memory jobs, Redis headers, NATS/SQS/RabbitMQ messages, and nullable SQL storage. The engine registers both direct application handlers and legacy envelope handlers, so middleware, retry classification, logical events, settlement deferral, queued v1 work, reserved protocol names, and raw-bus wire fixtures remain unified. - Added the workers-first migration gate `WithLegacyDirectEnvelope`, additive concurrent-safe SQL metadata migration, caller-managed-schema diagnostics, malformed/future metadata fallback, backend retry/republication identity tests, and shared integration assertions that the dispatch receipt matches the delivered `Message` across local, SQLite, Redis, and NATS execution. -- Completed M5-07 by moving all fake state into `queue.FakeQueue`, adapting the deprecated bus and queuefake surfaces onto it, and running fake workflows through the production engine without retaining non-executing closure callbacks in fake runtime state. Focused tests cover execution-time recording, validation and cancellation failures, builder reuse and policy, payload isolation, lookup/reset behavior, the legacy zero value, shared compatibility views, and concurrent access under the race detector. +- Completed M5-07 by moving all fake state into `queue.FakeQueue` and running fake workflows through the production engine without retaining non-executing closure callbacks in fake runtime state. Focused tests cover execution-time recording, validation and cancellation failures, builder reuse and policy, payload isolation, lookup/reset behavior, and concurrent access under the race detector. - Completed M3-04/M3-05 with one additive first-writer `WorkflowOutcomeStore` used by memory, SQL, fake, root, and deprecated compatibility paths. Chain success/failure compare-and-swap the same ordered node, batch member outcome categories remain immutable, and losing redeliveries publish no contradictory job/workflow facts, progress, or callbacks. Concurrent outcome races, claim rollback faults, legacy dual-terminal rows, fail-fast settlement, callback claims, caller ownership, and repeated real SQLite, MySQL, and PostgreSQL contracts now cover the transition boundary. MySQL auto-schema uses byte-exact dialect types, rejects fresh-schema truncation, and rejects non-`VARBINARY` managed identity columns. A real legacy-width integration now drops only the receipt table beside 512-byte state identities, proves ordinary startup derives a 512/512 replacement without altering existing tables, and exercises identities above the fresh defaults. The separate managed-width fixture proves complete pre-existing schema discovery. - Added opaque SQL delivery-generation provenance and separate built-in workflow transition receipts without conflating either with continuations or observer delivery. Forced SQLite, MySQL, and PostgreSQL finalization failures prove supported receipts suppress duplicate application execution while exact recovered-generation ownership gates reconstructed success facts. Focused memory/SQLite store and runtime contracts add terminal chain-failure receipts atomically, return the first persisted permanent cause across exact, different, or legacy generation provenance, suppress repeated handlers/callbacks/failure facts, roll back parent failure when receipt insertion fails, and fail invalid receipts closed. Built-in `FailChain` now preserves that first cause; direct callers that used late calls as replacement metadata must retain those diagnostics separately. A real SQLite chain-failure fixture forces the initial archive and multiple recovery archives to fail, retains owner/attempt-zero/cause through fenced best-effort repair, and then reaches `dead` at attempt one with a single application/workflow occurrence. SQLite additionally covers completed predecessors, later-attempt ownership, aggregate non-inference, a two-member completing-owner recovery, and a failed batch member that archives with a generic durable cause. Active chain recovery re-dispatches an immediate successor after definite rejection without replaying its predecessor. A compatibility matrix now proves the same liveness-only behavior for missing receipts, decorated stores, and different/legacy generation provenance, with no predecessor facts/callbacks and no dispatch after successor progress or terminal state. Duplicate enqueue remains possible while successor progress is not durable intent. Real twelve-worker MySQL and PostgreSQL fail-fast races prove the locked parent transition produces one aggregate owner and one terminal fact pair. `receipt_version` and shared observer `event_schema_version` both start at `1`, evolve independently from the workflow-envelope protocol, and fail recovery closed when unsupported. Application retry clears the earlier generation link, while the delivery-settlement application-state signal preserves a current receipt owner when later infrastructure requests another same-attempt redelivery. Server-dialect failed-chain finalization evidence, remaining custom/decorated/raw-store parity, durable callbacks/continuations, settlement outbox, managed-schema/pruning gates, and physical commit/readback ambiguity remain open. - Final receipt hardening separates logical transition proof from exact physical fact ownership. A complete persisted owner still requires a nonnegative attempt, but a duplicate's current attempt may differ or be negative; chain physical `JobID` may differ, while batch `JobID` remains its logical member. These nonowners suppress handlers and facts, preserve only a live chain successor, and keep failed chain/batch deliveries on their permanent archive path. SQL aggregate readback now fails closed for stale incarnation, missing completion/member, success-owned cancellation, or owner/outcome disagreement with the member receipt; runtime flags must also match live terminal state. diff --git a/docs/production-config.md b/docs/production-config.md index 4e129fd..93ac5a8 100644 --- a/docs/production-config.md +++ b/docs/production-config.md @@ -223,8 +223,6 @@ At minimum, wire: - one observer with `queue.WithObserver(...)` for queue, worker, and workflow events -`queue.Config.Observer` remains a compatibility path and feeds the same event stream, but new applications should prefer the constructor option consistently across drivers. - Track and alert on: - `process_failed` diff --git a/docs/readme/testcounts/integration_count.json b/docs/readme/testcounts/integration_count.json index 412b5e1..f2f0852 100644 --- a/docs/readme/testcounts/integration_count.json +++ b/docs/readme/testcounts/integration_count.json @@ -1,5 +1,5 @@ { - "count": 631, - "source_hash": "sha256:64018d37bee1280db310b9ddd5606b890827ea0709f6141dca706c574450d1a8", + "count": 610, + "source_hash": "sha256:b51f84a952e96ad180d709881c8992d16a26f5b57f88fb7b35b6f00862c49996", "backend_scope": "all" } diff --git a/driver/natsqueue/natsqueue.go b/driver/natsqueue/natsqueue.go index b5872ad..ae33a97 100644 --- a/driver/natsqueue/natsqueue.go +++ b/driver/natsqueue/natsqueue.go @@ -58,9 +58,8 @@ func NewWithConfig(cfg Config, opts ...queue.Option) (*queue.Queue, error) { rootCfg := queue.Config{ Driver: queue.DriverNATS, DefaultQueue: cfg.DefaultQueue, - Observer: observer, } - return driverbridge.NewQueueFromDriver(rootCfg, newNATSQueue(cfg.URL), func(workers int) (any, error) { + return driverbridge.NewQueueFromDriver(rootCfg, observer, newNATSQueue(cfg.URL), func(workers int) (any, error) { return newNATSWorkerWithConfig(natsWorkerConfig{ URL: cfg.URL, DefaultQueue: queue.PhysicalQueueName(cfg.DefaultQueue, cfg.DefaultQueue), diff --git a/driver/rabbitmqqueue/rabbitmqqueue.go b/driver/rabbitmqqueue/rabbitmqqueue.go index 78860e2..b0994e8 100644 --- a/driver/rabbitmqqueue/rabbitmqqueue.go +++ b/driver/rabbitmqqueue/rabbitmqqueue.go @@ -58,10 +58,9 @@ func NewWithConfig(cfg Config, opts ...queue.Option) (*queue.Queue, error) { rootCfg := queue.Config{ Driver: queue.DriverRabbitMQ, DefaultQueue: cfg.DefaultQueue, - Observer: observer, } defaultQueue := queue.PhysicalQueueName(cfg.DefaultQueue, cfg.DefaultQueue) - return driverbridge.NewQueueFromDriver(rootCfg, newRabbitMQQueue(cfg.URL, defaultQueue), func(workers int) (any, error) { + return driverbridge.NewQueueFromDriver(rootCfg, observer, newRabbitMQQueue(cfg.URL, defaultQueue), func(workers int) (any, error) { return newRabbitMQWorker(rabbitMQWorkerConfig{ DefaultQueue: defaultQueue, RabbitMQURL: cfg.URL, diff --git a/driver/redisqueue/delivery_metadata_test.go b/driver/redisqueue/delivery_metadata_test.go index 1abc906..cc1bb5d 100644 --- a/driver/redisqueue/delivery_metadata_test.go +++ b/driver/redisqueue/delivery_metadata_test.go @@ -141,6 +141,7 @@ func TestRedisDriverMetadataReachesMessageHandler(t *testing.T) { server := &serverStub{} q, err := driverbridge.NewQueueFromDriver( queue.Config{Driver: queue.DriverRedis, DefaultQueue: "default"}, + nil, producer, func(int) (any, error) { return newRedisWorker(server, backend.NewServeMux(), nil), nil diff --git a/driver/redisqueue/queue_redis_impl_test.go b/driver/redisqueue/queue_redis_impl_test.go index 792be92..3926f17 100644 --- a/driver/redisqueue/queue_redis_impl_test.go +++ b/driver/redisqueue/queue_redis_impl_test.go @@ -745,6 +745,7 @@ func TestRedisQueue_ShutdownRetryClosesRootRuntime(t *testing.T) { producer := newRedisQueue(client, inspector, state, true) q, err := driverbridge.NewQueueFromDriver( queue.Config{Driver: queue.DriverRedis, DefaultQueue: "default"}, + nil, producer, nil, ) diff --git a/driver/redisqueue/redisqueue.go b/driver/redisqueue/redisqueue.go index ea09f03..e97cb96 100644 --- a/driver/redisqueue/redisqueue.go +++ b/driver/redisqueue/redisqueue.go @@ -14,10 +14,6 @@ import ( backend "github.com/hibiken/asynq" ) -// ServerLogger is the Redis worker server logger contract. -// Deprecated: use queue.Logger via Config.DriverBaseConfig.Logger. -type ServerLogger = queue.Logger - // ServerLogLevel configures Redis worker server log verbosity. type ServerLogLevel int @@ -38,7 +34,6 @@ type Config struct { Password string DB int Queues map[string]int - ServerLogger ServerLogger ServerLogLevel ServerLogLevel ShutdownTimeout time.Duration } @@ -74,7 +69,7 @@ func New(addr string, opts ...queue.Option) (*queue.Queue, error) { // Addr: "127.0.0.1:6379", // required // Password: "", // optional; default empty // DB: 0, // optional; default 0 -// ServerLogger: nil, // optional; default backend logger +// Logger: nil, // optional; default backend logger // ServerLogLevel: redisqueue.ServerLogLevelDefault, // optional // }, // queue.WithWorkers(4), // optional; default: runtime.NumCPU() (min 1) @@ -91,11 +86,10 @@ func NewWithConfig(cfg Config, opts ...queue.Option) (*queue.Queue, error) { rootCfg := queue.Config{ Driver: queue.DriverRedis, DefaultQueue: cfg.DefaultQueue, - Observer: observer, Logger: cfg.Logger, } driverBackend := newRedisQueue(newRedisClient(cfg), newRedisInspector(cfg), newRedisTimelineStore(cfg), true) - q, err := driverbridge.NewQueueFromDriver(rootCfg, driverBackend, func(workers int) (any, error) { + q, err := driverbridge.NewQueueFromDriver(rootCfg, observer, driverBackend, func(workers int) (any, error) { return newRedisWorker( backend.NewServer(backend.RedisClientOpt{ Addr: cfg.Addr, @@ -121,9 +115,7 @@ func serverConfig(cfg Config, workers int) backend.Config { if queues := normalizeQueues(cfg.Queues, cfg.DefaultQueue); len(queues) > 0 { serverCfg.Queues = queues } - if cfg.ServerLogger != nil { - serverCfg.Logger = cfg.ServerLogger - } else if cfg.Logger != nil { + if cfg.Logger != nil { serverCfg.Logger = cfg.Logger } if level, ok := serverLogLevel(cfg.ServerLogLevel); ok { diff --git a/driver/redisqueue/redisqueue_test.go b/driver/redisqueue/redisqueue_test.go index 8495f3d..567746b 100644 --- a/driver/redisqueue/redisqueue_test.go +++ b/driver/redisqueue/redisqueue_test.go @@ -42,7 +42,9 @@ func TestServerConfig_Defaults(t *testing.T) { func TestServerConfig_LoggerAndLogLevelPassthrough(t *testing.T) { logger := serverLoggerStub{} cfg := serverConfig(Config{ - ServerLogger: logger, + DriverBaseConfig: queueconfig.DriverBaseConfig{ + Logger: logger, + }, ServerLogLevel: ServerLogLevelError, Queues: map[string]int{ "critical": 5, diff --git a/driver/sqlqueuecore/wrapper.go b/driver/sqlqueuecore/wrapper.go index 39dd4b3..630981d 100644 --- a/driver/sqlqueuecore/wrapper.go +++ b/driver/sqlqueuecore/wrapper.go @@ -41,8 +41,7 @@ func NewQueue(driverName string, cfg ModuleConfig, opts ...queue.Option) (*queue rootCfg := queue.Config{ Driver: queue.DriverDatabase, DefaultQueue: cfg.DefaultQueue, - Observer: observer, Logger: cfg.Logger, } - return driverbridge.NewQueueFromDriver(rootCfg, backend, nil, opts...) + return driverbridge.NewQueueFromDriver(rootCfg, observer, backend, nil, opts...) } diff --git a/driver/sqsqueue/sqsqueue.go b/driver/sqsqueue/sqsqueue.go index a2e6984..58859c5 100644 --- a/driver/sqsqueue/sqsqueue.go +++ b/driver/sqsqueue/sqsqueue.go @@ -60,10 +60,9 @@ func NewWithConfig(cfg Config, opts ...queue.Option) (*queue.Queue, error) { rootCfg := queue.Config{ Driver: queue.DriverSQS, DefaultQueue: cfg.DefaultQueue, - Observer: observer, } defaultQueue := queue.PhysicalQueueName(cfg.DefaultQueue, cfg.DefaultQueue) - return driverbridge.NewQueueFromDriver(rootCfg, newSQSQueue(cfg), func(workers int) (any, error) { + return driverbridge.NewQueueFromDriver(rootCfg, observer, newSQSQueue(cfg), func(workers int) (any, error) { return newSQSWorker(sqsWorkerConfig{ DefaultQueue: defaultQueue, SQSRegion: cfg.Region, diff --git a/driver_runtime.go b/driver_runtime.go index d772681..afb143f 100644 --- a/driver_runtime.go +++ b/driver_runtime.go @@ -28,12 +28,12 @@ type driverWorkerBackend interface { type driverWorkerFactory func(workers int) (driverWorkerBackend, error) -func newQueueFromDriver(cfg Config, backend driverQueueBackend, workerFactory driverWorkerFactory) (queueRuntime, error) { +func newQueueFromDriver(cfg Config, observer Observer, backend driverQueueBackend, workerFactory driverWorkerFactory) (queueRuntime, error) { if backend == nil { return nil, fmt.Errorf("driver backend is nil") } cfg = cfg.normalize() - cfg.Observer = ensureObserverSink(cfg.Observer) + observer = ensureObserverSink(observer) var q queueBackend var runtime runtimeQueueBackend @@ -45,9 +45,10 @@ func newQueueFromDriver(cfg Config, backend driverQueueBackend, workerFactory dr } common := &queueCommon{ - inner: newObservedQueue(q, cfg.Driver, cfg.Observer), - cfg: cfg, - driver: cfg.Driver, + inner: newObservedQueue(q, cfg.Driver, observer), + cfg: cfg, + driver: cfg.Driver, + observerSink: observer, } if runtime != nil { return &nativeQueueRuntime{ diff --git a/error_contract_test.go b/error_contract_test.go index 7c39d89..de27cb3 100644 --- a/error_contract_test.go +++ b/error_contract_test.go @@ -18,7 +18,7 @@ func TestQueueErrorContract_DispatchCancellation(t *testing.T) { cfg := (Config{Driver: DriverWorkerpool}).normalize() rt := &nativeQueueRuntime{ common: &queueCommon{ - inner: newObservedQueue(backend, cfg.Driver, cfg.Observer), + inner: newObservedQueue(backend, cfg.Driver, nil), cfg: cfg, driver: cfg.Driver, }, diff --git a/examples/newfake/main.go b/examples/newfake/main.go index 277a275..b7af613 100644 --- a/examples/newfake/main.go +++ b/examples/newfake/main.go @@ -11,7 +11,7 @@ import ( ) func main() { - // NewFake creates the canonical fake used directly and by deprecated testing adapters. + // NewFake creates the canonical fake for direct and workflow tests. // Example: fake queue assertions fake := queue.NewFake() diff --git a/examples/queuefake-fake-count/main.go b/examples/queuefake-fake-count/main.go deleted file mode 100644 index 21fca87..0000000 --- a/examples/queuefake-fake-count/main.go +++ /dev/null @@ -1,22 +0,0 @@ -//go:build ignore -// +build ignore - -// examplegen:generated - -package main - -import ( - "github.com/goforj/queue" - "github.com/goforj/queue/queuefake" -) - -func main() { - // Count returns the total number of recorded dispatches. - - // Example: count total dispatches - f := queuefake.New() - q := f.Queue() - _ = q.Dispatch(queue.NewJob("a")) - _ = q.Dispatch(queue.NewJob("b")) - _ = f.Count() -} diff --git a/examples/queuefake-fake-countjob/main.go b/examples/queuefake-fake-countjob/main.go deleted file mode 100644 index 380aead..0000000 --- a/examples/queuefake-fake-countjob/main.go +++ /dev/null @@ -1,22 +0,0 @@ -//go:build ignore -// +build ignore - -// examplegen:generated - -package main - -import ( - "github.com/goforj/queue" - "github.com/goforj/queue/queuefake" -) - -func main() { - // CountJob returns how many times a job type was dispatched. - - // Example: count by job type - f := queuefake.New() - q := f.Queue() - _ = q.Dispatch(queue.NewJob("emails:send")) - _ = q.Dispatch(queue.NewJob("emails:send")) - _ = f.CountJob("emails:send") -} diff --git a/examples/queuefake-fake-counton/main.go b/examples/queuefake-fake-counton/main.go deleted file mode 100644 index 1bc3a19..0000000 --- a/examples/queuefake-fake-counton/main.go +++ /dev/null @@ -1,21 +0,0 @@ -//go:build ignore -// +build ignore - -// examplegen:generated - -package main - -import ( - "github.com/goforj/queue" - "github.com/goforj/queue/queuefake" -) - -func main() { - // CountOn returns how many times a job type was dispatched on a queue. - - // Example: count by queue and job type - f := queuefake.New() - q := f.Queue() - _ = q.Dispatch(queue.NewJob("emails:send").OnQueue("critical")) - _ = f.CountOn("critical", "emails:send") -} diff --git a/examples/queuefake-fake-queue/main.go b/examples/queuefake-fake-queue/main.go deleted file mode 100644 index 19a1994..0000000 --- a/examples/queuefake-fake-queue/main.go +++ /dev/null @@ -1,20 +0,0 @@ -//go:build ignore -// +build ignore - -// examplegen:generated - -package main - -import ( - "github.com/goforj/queue" - "github.com/goforj/queue/queuefake" -) - -func main() { - // Queue returns the queue fake to inject into code under test. - - // Example: queue fake - f := queuefake.New() - q := f.Queue() - _ = q.Dispatch(queue.NewJob("emails:send").OnQueue("default")) -} diff --git a/examples/queuefake-fake-records/main.go b/examples/queuefake-fake-records/main.go deleted file mode 100644 index d667948..0000000 --- a/examples/queuefake-fake-records/main.go +++ /dev/null @@ -1,21 +0,0 @@ -//go:build ignore -// +build ignore - -// examplegen:generated - -package main - -import ( - "github.com/goforj/queue" - "github.com/goforj/queue/queuefake" -) - -func main() { - // Records returns a copy of recorded dispatches. - - // Example: inspect recorded dispatches - f := queuefake.New() - _ = f.Queue().Dispatch(queue.NewJob("emails:send")) - records := f.Records() - _ = records -} diff --git a/fake_queue.go b/fake_queue.go index 50d6670..70afd17 100644 --- a/fake_queue.go +++ b/fake_queue.go @@ -34,7 +34,7 @@ type fakeQueueState struct { workflow *fakeWorkflowRecorder } -// NewFake creates the canonical fake used directly and by deprecated testing adapters. +// NewFake creates the canonical fake for direct and workflow tests. // @group Testing // // Example: fake queue assertions diff --git a/integration/all/handler_context_integration_test.go b/integration/all/handler_context_integration_test.go index ca555aa..b40ebe2 100644 --- a/integration/all/handler_context_integration_test.go +++ b/integration/all/handler_context_integration_test.go @@ -98,8 +98,6 @@ func withDefaultQueueAll[T any](cfg T, name string) T { func withObserverAll[T any](cfg T, observer Observer) T { switch c := any(&cfg).(type) { - case *Config: - c.Observer = observer case *redisqueue.Config: c.Observer = observer case *natsqueue.Config: @@ -130,7 +128,7 @@ func TestIntegrationQueue_HandlerContextDecorator_AllBackends(t *testing.T) { { name: testenv.BackendSync, newQ: func(t *testing.T, observer Observer) (*Queue, string) { - q, err := newQueue(withObserverAll(syncCfg(), observer), WithHandlerContextDecorator(func(ctx context.Context) context.Context { + q, err := newQueue(syncCfg(), WithObserver(observer), WithHandlerContextDecorator(func(ctx context.Context) context.Context { return context.WithValue(ctx, key, want) })) if err != nil { @@ -142,7 +140,7 @@ func TestIntegrationQueue_HandlerContextDecorator_AllBackends(t *testing.T) { { name: testenv.BackendWorkerpool, newQ: func(t *testing.T, observer Observer) (*Queue, string) { - q, err := newQueue(withObserverAll(workerpoolCfg(), observer), WithHandlerContextDecorator(func(ctx context.Context) context.Context { + q, err := newQueue(workerpoolCfg(), WithObserver(observer), WithHandlerContextDecorator(func(ctx context.Context) context.Context { return context.WithValue(ctx, key, want) })) if err != nil { diff --git a/integration/bus/callback_sql_integration_test.go b/integration/bus/callback_sql_integration_test.go deleted file mode 100644 index a6b8102..0000000 --- a/integration/bus/callback_sql_integration_test.go +++ /dev/null @@ -1,339 +0,0 @@ -//go:build integration -// +build integration - -package bus_test - -import ( - "context" - "encoding/json" - "errors" - "path/filepath" - "sync/atomic" - "testing" - "time" - - "github.com/goforj/queue/bus" - "github.com/goforj/queue/busruntime" - _ "modernc.org/sqlite" -) - -func newSQLiteStoreForRuntime(t *testing.T) bus.Store { - t.Helper() - dsn := filepath.Join(t.TempDir(), "bus-runtime-store.db") - store, err := bus.NewSQLStore(bus.SQLStoreConfig{ - DriverName: "sqlite", - DSN: dsn, - }) - if err != nil { - t.Fatalf("new sql store: %v", err) - } - return store -} - -type failFirstCallbackKindRuntime struct { - inner *bus.IntegrationTestRuntime - kind string - failedOnce atomic.Bool -} - -func newFailFirstCallbackKindRuntime(kind string) *failFirstCallbackKindRuntime { - return &failFirstCallbackKindRuntime{ - inner: bus.NewIntegrationTestRuntime(), - kind: kind, - } -} - -func (r *failFirstCallbackKindRuntime) BusRegister(jobType string, handler busruntime.Handler) { - r.inner.BusRegister(jobType, handler) -} - -func (r *failFirstCallbackKindRuntime) BusDispatch(ctx context.Context, jobType string, payload []byte, opts busruntime.JobOptions) error { - if jobType == bus.InternalCallbackJobTypeForIntegration() && !r.failedOnce.Load() { - var env struct { - CallbackKind string `json:"callback_kind"` - } - if err := json.Unmarshal(payload, &env); err == nil && env.CallbackKind == r.kind { - r.failedOnce.Store(true) - return errors.New("injected callback dispatch failure") - } - } - return r.inner.BusDispatch(ctx, jobType, payload, opts) -} - -func (r *failFirstCallbackKindRuntime) StartWorkers(ctx context.Context) error { return r.inner.StartWorkers(ctx) } -func (r *failFirstCallbackKindRuntime) Shutdown(ctx context.Context) error { return r.inner.Shutdown(ctx) } - -func (r *failFirstCallbackKindRuntime) DispatchJSON(ctx context.Context, jobType string, payload any) error { - return r.inner.DispatchJSON(ctx, jobType, payload) -} - -func TestSQLStore_RuntimeBatchThenFinallyDuplicateCallbacksSuppressed(t *testing.T) { - q := bus.NewIntegrationTestRuntime() - store := newSQLiteStoreForRuntime(t) - - b, err := bus.NewWithStore(q, store) - if err != nil { - t.Fatalf("new bus: %v", err) - } - if err := b.StartWorkers(context.Background()); err != nil { - t.Fatalf("start workers: %v", err) - } - - var thenCount int - var finallyCount int - b.Register("monitor:poll", func(context.Context, bus.Context) error { return nil }) - - batchID, err := b.Batch( - bus.NewJob("monitor:poll", nil), - ).Then(func(context.Context, bus.BatchState) error { - thenCount++ - return nil - }).Finally(func(context.Context, bus.BatchState) error { - finallyCount++ - return nil - }).Dispatch(context.Background()) - if err != nil { - t.Fatalf("dispatch batch: %v", err) - } - if thenCount != 1 { - t.Fatalf("expected then once, got %d", thenCount) - } - if finallyCount != 1 { - t.Fatalf("expected finally once, got %d", finallyCount) - } - - for _, callbackKind := range []string{"batch_then", "batch_finally"} { - cbPayload := map[string]any{ - "schema_version": 1, - "dispatch_id": "dup-dispatch", - "kind": "callback", - "job_id": "dup-job-" + callbackKind, - "batch_id": batchID, - "callback_kind": callbackKind, - } - if err := q.DispatchJSON(context.Background(), bus.InternalCallbackJobTypeForIntegration(), cbPayload); err != nil { - t.Fatalf("dispatch duplicate callback (%s): %v", callbackKind, err) - } - } - if thenCount != 1 { - t.Fatalf("expected then to remain once after duplicate callbacks, got %d", thenCount) - } - if finallyCount != 1 { - t.Fatalf("expected finally to remain once after duplicate callbacks, got %d", finallyCount) - } -} - -func TestSQLStore_RuntimeBatchCatchDuplicateCallbackSuppressed(t *testing.T) { - q := bus.NewIntegrationTestRuntime() - store := newSQLiteStoreForRuntime(t) - - b, err := bus.NewWithStore(q, store) - if err != nil { - t.Fatalf("new bus: %v", err) - } - if err := b.StartWorkers(context.Background()); err != nil { - t.Fatalf("start workers: %v", err) - } - - var catchCount int - b.Register("monitor:downsample", func(context.Context, bus.Context) error { return errors.New("boom") }) - batchID, err := b.Batch( - bus.NewJob("monitor:downsample", nil), - ).Catch(func(context.Context, bus.BatchState, error) error { - catchCount++ - return nil - }).Dispatch(context.Background()) - if err == nil { - t.Fatal("expected batch dispatch error") - } - if catchCount != 1 { - t.Fatalf("expected catch once after failed batch, got %d", catchCount) - } - - cbPayload := map[string]any{ - "schema_version": 1, - "dispatch_id": "dup-dispatch", - "kind": "callback", - "job_id": "dup-job-batch-catch", - "batch_id": batchID, - "callback_kind": "batch_catch", - "error": "boom", - } - if err := q.DispatchJSON(context.Background(), bus.InternalCallbackJobTypeForIntegration(), cbPayload); err != nil { - t.Fatalf("dispatch duplicate callback: %v", err) - } - if catchCount != 1 { - t.Fatalf("expected catch to remain once after duplicate callback, got %d", catchCount) - } -} - -func TestSQLStore_RuntimeChainFinallyDuplicateCallbackSuppressed(t *testing.T) { - q := bus.NewIntegrationTestRuntime() - store := newSQLiteStoreForRuntime(t) - - b, err := bus.NewWithStore(q, store) - if err != nil { - t.Fatalf("new bus: %v", err) - } - if err := b.StartWorkers(context.Background()); err != nil { - t.Fatalf("start workers: %v", err) - } - - var finallyCount int - b.Register("monitor:poll", func(context.Context, bus.Context) error { return nil }) - - chainID, err := b.Chain( - bus.NewJob("monitor:poll", nil), - ).Finally(func(context.Context, bus.ChainState) error { - finallyCount++ - return nil - }).Dispatch(context.Background()) - if err != nil { - t.Fatalf("dispatch chain: %v", err) - } - if finallyCount != 1 { - t.Fatalf("expected finally once, got %d", finallyCount) - } - - cbPayload := map[string]any{ - "schema_version": 1, - "dispatch_id": "dup-dispatch", - "kind": "callback", - "job_id": "dup-job-chain-finally", - "chain_id": chainID, - "callback_kind": "chain_finally", - } - if err := q.DispatchJSON(context.Background(), bus.InternalCallbackJobTypeForIntegration(), cbPayload); err != nil { - t.Fatalf("dispatch duplicate callback: %v", err) - } - if finallyCount != 1 { - t.Fatalf("expected finally to remain once after duplicate callback, got %d", finallyCount) - } -} - -func TestSQLStore_RuntimeChainCatchAndFinallyDuplicateCallbacksSuppressed(t *testing.T) { - q := bus.NewIntegrationTestRuntime() - store := newSQLiteStoreForRuntime(t) - - b, err := bus.NewWithStore(q, store) - if err != nil { - t.Fatalf("new bus: %v", err) - } - if err := b.StartWorkers(context.Background()); err != nil { - t.Fatalf("start workers: %v", err) - } - - var catchCount int - var finallyCount int - b.Register("monitor:downsample", func(context.Context, bus.Context) error { return errors.New("boom") }) - - chainID, err := b.Chain( - bus.NewJob("monitor:downsample", nil), - ).Catch(func(context.Context, bus.ChainState, error) error { - catchCount++ - return nil - }).Finally(func(context.Context, bus.ChainState) error { - finallyCount++ - return nil - }).Dispatch(context.Background()) - if err == nil { - t.Fatal("expected chain dispatch error") - } - if catchCount != 1 { - t.Fatalf("expected catch once after failed chain, got %d", catchCount) - } - if finallyCount != 1 { - t.Fatalf("expected finally once after failed chain, got %d", finallyCount) - } - - for _, callbackKind := range []string{"chain_catch", "chain_finally"} { - cbPayload := map[string]any{ - "schema_version": 1, - "dispatch_id": "dup-dispatch", - "kind": "callback", - "job_id": "dup-job-" + callbackKind, - "chain_id": chainID, - "callback_kind": callbackKind, - "error": "boom", - } - if callbackKind == "chain_finally" { - delete(cbPayload, "error") - } - if err := q.DispatchJSON(context.Background(), bus.InternalCallbackJobTypeForIntegration(), cbPayload); err != nil { - t.Fatalf("dispatch duplicate callback (%s): %v", callbackKind, err) - } - } - - if catchCount != 1 { - t.Fatalf("expected catch to remain once after duplicate callbacks, got %d", catchCount) - } - if finallyCount != 1 { - t.Fatalf("expected finally to remain once after duplicate callbacks, got %d", finallyCount) - } -} - -func TestSQLStore_RuntimeChainFinallyCallbackReplayAfterDispatchFaultSuppressed(t *testing.T) { - q := newFailFirstCallbackKindRuntime("chain_finally") - store := newSQLiteStoreForRuntime(t) - - b, err := bus.NewWithStore(q, store) - if err != nil { - t.Fatalf("new bus: %v", err) - } - if err := b.StartWorkers(context.Background()); err != nil { - t.Fatalf("start workers: %v", err) - } - - b.Register("monitor:poll", func(context.Context, bus.Context) error { return nil }) - - var finallyCount atomic.Int32 - chainID, err := b.Chain( - bus.NewJob("monitor:poll", nil), - ).Finally(func(context.Context, bus.ChainState) error { - finallyCount.Add(1) - return nil - }).Dispatch(context.Background()) - if err != nil { - t.Fatalf("dispatch chain: %v", err) - } - - deadline := time.Now().Add(5 * time.Second) - for time.Now().Before(deadline) { - st, err := b.FindChain(context.Background(), chainID) - if err == nil && st.Completed { - break - } - time.Sleep(20 * time.Millisecond) - } - st, err := b.FindChain(context.Background(), chainID) - if err != nil { - t.Fatalf("find chain: %v", err) - } - if !st.Completed { - t.Fatalf("expected completed chain state, got %+v", st) - } - if !q.failedOnce.Load() { - t.Fatal("expected injected chain_finally callback dispatch failure") - } - if got := finallyCount.Load(); got != 0 { - t.Fatalf("expected finally callback not invoked before replay, got %d", got) - } - - cbPayload := map[string]any{ - "schema_version": 1, - "dispatch_id": st.DispatchID, - "kind": "callback", - "job_id": "replay-job-chain-finally", - "chain_id": chainID, - "callback_kind": "chain_finally", - } - if err := q.DispatchJSON(context.Background(), bus.InternalCallbackJobTypeForIntegration(), cbPayload); err != nil { - t.Fatalf("dispatch replay callback first: %v", err) - } - if err := q.DispatchJSON(context.Background(), bus.InternalCallbackJobTypeForIntegration(), cbPayload); err != nil { - t.Fatalf("dispatch replay callback duplicate: %v", err) - } - if got := finallyCount.Load(); got != 1 { - t.Fatalf("expected finally callback once after replay + duplicate, got %d", got) - } -} diff --git a/integration/bus/config_builders_test.go b/integration/bus/config_builders_test.go deleted file mode 100644 index 12e75ad..0000000 --- a/integration/bus/config_builders_test.go +++ /dev/null @@ -1,51 +0,0 @@ -//go:build integration - -package bus_test - -import ( - "github.com/goforj/queue" - "github.com/goforj/queue/driver/mysqlqueue" - "github.com/goforj/queue/driver/natsqueue" - "github.com/goforj/queue/driver/postgresqueue" - "github.com/goforj/queue/driver/rabbitmqqueue" - "github.com/goforj/queue/driver/redisqueue" - "github.com/goforj/queue/driver/sqlitequeue" - "github.com/goforj/queue/driver/sqsqueue" - "github.com/goforj/queue/queueconfig" - "github.com/goforj/queue/integration/testenv" -) - -func nullCfg() queue.Config { return queue.Config{Driver: queue.DriverNull} } -func syncCfg() queue.Config { return queue.Config{Driver: queue.DriverSync} } -func workerpoolCfg() queue.Config { return queue.Config{Driver: queue.DriverWorkerpool} } - -func redisCfg(addr string) redisqueue.Config { - return redisqueue.Config{DriverBaseConfig: queueconfig.DriverBaseConfig{}, Addr: addr} -} -func natsCfg(url string) natsqueue.Config { - return natsqueue.Config{DriverBaseConfig: queueconfig.DriverBaseConfig{}, URL: url} -} -func rabbitmqCfg(url string) rabbitmqqueue.Config { - return rabbitmqqueue.Config{DriverBaseConfig: queueconfig.DriverBaseConfig{}, URL: url} -} -func mysqlCfg(dsn string) mysqlqueue.Config { - return mysqlqueue.Config{DriverBaseConfig: queueconfig.DriverBaseConfig{}, DSN: dsn} -} -func postgresCfg(dsn string) postgresqueue.Config { - return postgresqueue.Config{DriverBaseConfig: queueconfig.DriverBaseConfig{}, DSN: dsn} -} -func sqliteCfg(dsn string) sqlitequeue.Config { - return sqlitequeue.Config{DriverBaseConfig: queueconfig.DriverBaseConfig{}, DSN: dsn} -} -func sqsCfg(region, endpoint, accessKey, secretKey string) sqsqueue.Config { - return sqsqueue.Config{ - DriverBaseConfig: queueconfig.DriverBaseConfig{}, - Region: region, - Endpoint: endpoint, - AccessKey: accessKey, - SecretKey: secretKey, - } -} - -func mysqlDSN(addr string) string { return testenv.MySQLDSN(addr) } -func postgresDSN(addr string) string { return testenv.PostgresDSN(addr) } diff --git a/integration/bus/dispatch_failure_sql_integration_test.go b/integration/bus/dispatch_failure_sql_integration_test.go deleted file mode 100644 index f84e5cb..0000000 --- a/integration/bus/dispatch_failure_sql_integration_test.go +++ /dev/null @@ -1,154 +0,0 @@ -//go:build integration -// +build integration - -package bus_test - -import ( - "context" - "errors" - "sync/atomic" - "testing" - - "github.com/goforj/queue/bus" - "github.com/goforj/queue/busruntime" -) - -type failNthDispatchRuntime struct { - inner *bus.IntegrationTestRuntime - n int32 - target int32 - failErr error -} - -func newFailNthDispatchRuntime(target int, failErr error) *failNthDispatchRuntime { - if target <= 0 { - target = 1 - } - if failErr == nil { - failErr = errors.New("injected dispatch failure") - } - return &failNthDispatchRuntime{ - inner: bus.NewIntegrationTestRuntime(), - target: int32(target), - failErr: failErr, - } -} - -func (r *failNthDispatchRuntime) BusRegister(jobType string, handler busruntime.Handler) { - r.inner.BusRegister(jobType, handler) -} - -func (r *failNthDispatchRuntime) BusDispatch(ctx context.Context, jobType string, payload []byte, opts busruntime.JobOptions) error { - if atomic.AddInt32(&r.n, 1) == r.target { - return r.failErr - } - return r.inner.BusDispatch(ctx, jobType, payload, opts) -} - -func (r *failNthDispatchRuntime) StartWorkers(ctx context.Context) error { return r.inner.StartWorkers(ctx) } -func (r *failNthDispatchRuntime) Shutdown(ctx context.Context) error { return r.inner.Shutdown(ctx) } - -func TestSQLStore_RuntimeChainInitialDispatchFailureStateConsistent(t *testing.T) { - q := newFailNthDispatchRuntime(1, errors.New("chain enqueue failed")) - store := newSQLiteStoreForRuntime(t) - - b, err := bus.NewWithStore(q, store) - if err != nil { - t.Fatalf("new bus: %v", err) - } - if err := b.StartWorkers(context.Background()); err != nil { - t.Fatalf("start workers: %v", err) - } - - var catchCount atomic.Int32 - var finallyCount atomic.Int32 - - chainID, err := b.Chain(bus.NewJob("monitor:poll", nil)). - Catch(func(context.Context, bus.ChainState, error) error { - catchCount.Add(1) - return nil - }). - Finally(func(context.Context, bus.ChainState) error { - finallyCount.Add(1) - return nil - }). - Dispatch(context.Background()) - if err == nil { - t.Fatal("expected chain dispatch error") - } - if chainID == "" { - t.Fatal("expected chain ID on dispatch failure") - } - if got := catchCount.Load(); got != 1 { - t.Fatalf("expected catch once, got %d", got) - } - if got := finallyCount.Load(); got != 1 { - t.Fatalf("expected finally once, got %d", got) - } - - st, err := b.FindChain(context.Background(), chainID) - if err != nil { - t.Fatalf("find chain: %v", err) - } - if !st.Failed { - t.Fatalf("expected failed chain state, got %+v", st) - } - if st.Failure == "" { - t.Fatalf("expected chain failure message, got %+v", st) - } -} - -func TestSQLStore_RuntimeBatchPartialDispatchFailureStateConsistent(t *testing.T) { - // Fail the second internal dispatch so the first batch job can be enqueued and - // processed, then the batch builder returns an enqueue error on subsequent work. - q := newFailNthDispatchRuntime(2, errors.New("batch enqueue failed after first job")) - store := newSQLiteStoreForRuntime(t) - - b, err := bus.NewWithStore(q, store) - if err != nil { - t.Fatalf("new bus: %v", err) - } - if err := b.StartWorkers(context.Background()); err != nil { - t.Fatalf("start workers: %v", err) - } - - b.Register("monitor:poll", func(context.Context, bus.Context) error { return nil }) - - var catchCount atomic.Int32 - var finallyCount atomic.Int32 - batchID, err := b.Batch( - bus.NewJob("monitor:poll", nil), - bus.NewJob("monitor:poll", nil), - ).Catch(func(context.Context, bus.BatchState, error) error { - catchCount.Add(1) - return nil - }).Finally(func(context.Context, bus.BatchState) error { - finallyCount.Add(1) - return nil - }).Dispatch(context.Background()) - if err == nil { - t.Fatal("expected batch dispatch error") - } - if batchID == "" { - t.Fatal("expected batch ID on partial dispatch failure") - } - - st, err := b.FindBatch(context.Background(), batchID) - if err != nil { - t.Fatalf("find batch: %v", err) - } - // Current contract for partial enqueue failure after progress has begun: - // return the batch ID + error without force-cancelling or firing callbacks. - if st.Completed || st.Cancelled { - t.Fatalf("expected incomplete batch after partial dispatch failure, got %+v", st) - } - if st.Processed != 1 || st.Pending != 1 || st.Failed != 0 { - t.Fatalf("expected processed=1 pending=1 failed=0, got %+v", st) - } - if got := catchCount.Load(); got != 0 { - t.Fatalf("expected catch not invoked on partial dispatch failure-after-progress, got %d", got) - } - if got := finallyCount.Load(); got != 0 { - t.Fatalf("expected finally not invoked on partial dispatch failure-after-progress, got %d", got) - } -} diff --git a/integration/bus/integration_test.go b/integration/bus/integration_test.go deleted file mode 100644 index 4aa63ff..0000000 --- a/integration/bus/integration_test.go +++ /dev/null @@ -1,859 +0,0 @@ -//go:build integration - -package bus_test - -import ( - "context" - "database/sql" - "errors" - "fmt" - "math/rand" - "net" - "os" - "sync" - "sync/atomic" - "testing" - "time" - - _ "github.com/go-sql-driver/mysql" - "github.com/goforj/queue/bus" - "github.com/goforj/queue/integration/testenv" - _ "github.com/jackc/pgx/v5/stdlib" - testcontainers "github.com/testcontainers/testcontainers-go" - "github.com/testcontainers/testcontainers-go/wait" -) - -var integrationRedis struct { - container testcontainers.Container - addr string -} - -var integrationMySQL struct { - container testcontainers.Container - addr string -} - -var integrationPostgres struct { - container testcontainers.Container - addr string -} - -var integrationNATS struct { - container testcontainers.Container - url string -} - -var integrationSQS struct { - container testcontainers.Container - endpoint string - region string - accessKey string - secretKey string -} - -var integrationRabbitMQ struct { - container testcontainers.Container - url string -} - -func TestMain(m *testing.M) { - ctx := context.Background() - backends := selectedIntegrationBackends() - - if backends[testenv.BackendRedis] { - c, addr, err := startRedisContainer(ctx) - if err != nil { - fmt.Fprintf(os.Stderr, "failed to start redis integration container: %v\n", err) - os.Exit(1) - } - integrationRedis.container = c - integrationRedis.addr = addr - } - if backends[testenv.BackendMySQL] { - c, addr, err := startMySQLContainer(ctx) - if err != nil { - fmt.Fprintf(os.Stderr, "failed to start mysql integration container: %v\n", err) - shutdownContainers(ctx) - os.Exit(1) - } - integrationMySQL.container = c - integrationMySQL.addr = addr - } - if backends[testenv.BackendPostgres] { - c, addr, err := startPostgresContainer(ctx) - if err != nil { - fmt.Fprintf(os.Stderr, "failed to start postgres integration container: %v\n", err) - shutdownContainers(ctx) - os.Exit(1) - } - integrationPostgres.container = c - integrationPostgres.addr = addr - } - if backends[testenv.BackendNATS] { - c, url, err := startNATSContainer(ctx) - if err != nil { - fmt.Fprintf(os.Stderr, "failed to start nats integration container: %v\n", err) - shutdownContainers(ctx) - os.Exit(1) - } - integrationNATS.container = c - integrationNATS.url = url - } - if backends[testenv.BackendSQS] { - c, endpoint, err := startSQSContainer(ctx) - if err != nil { - fmt.Fprintf(os.Stderr, "failed to start sqs integration container: %v\n", err) - shutdownContainers(ctx) - os.Exit(1) - } - integrationSQS.container = c - integrationSQS.endpoint = endpoint - integrationSQS.region = "us-east-1" - integrationSQS.accessKey = "test" - integrationSQS.secretKey = "test" - } - if backends[testenv.BackendRabbitMQ] { - c, url, err := startRabbitMQContainer(ctx) - if err != nil { - fmt.Fprintf(os.Stderr, "failed to start rabbitmq integration container: %v\n", err) - shutdownContainers(ctx) - os.Exit(1) - } - integrationRabbitMQ.container = c - integrationRabbitMQ.url = url - } - - code := m.Run() - shutdownContainers(ctx) - os.Exit(code) -} - -func shutdownContainers(ctx context.Context) { - shutdownCtx, cancel := context.WithTimeout(ctx, 20*time.Second) - defer cancel() - for _, c := range []testcontainers.Container{ - integrationRabbitMQ.container, - integrationSQS.container, - integrationNATS.container, - integrationPostgres.container, - integrationMySQL.container, - integrationRedis.container, - } { - if c != nil { - _ = c.Terminate(shutdownCtx) - } - } -} - -func selectedIntegrationBackends() map[string]bool { - return testenv.SelectedBackends(os.Getenv("INTEGRATION_BACKEND")) -} - -func integrationBackendEnabled(name string) bool { - return testenv.BackendEnabled(os.Getenv("INTEGRATION_BACKEND"), name) -} - -func TestIntegrationBus_AllBackends(t *testing.T) { - fx := []struct { - name string - executes bool - newQ func(t *testing.T) QueueRuntime - }{ - { - name: testenv.BackendNull, - executes: false, - newQ: func(t *testing.T) QueueRuntime { - q, err := newQueueRuntime(nullCfg()) - if err != nil { - t.Fatalf("new null queue failed: %v", err) - } - return q - }, - }, - { - name: testenv.BackendSync, - executes: true, - newQ: func(t *testing.T) QueueRuntime { - q, err := newQueueRuntime(syncCfg()) - if err != nil { - t.Fatalf("new sync queue failed: %v", err) - } - return q - }, - }, - { - name: testenv.BackendWorkerpool, - executes: true, - newQ: func(t *testing.T) QueueRuntime { - q, err := newQueueRuntime(workerpoolCfg()) - if err != nil { - t.Fatalf("new workerpool queue failed: %v", err) - } - return q - }, - }, - { - name: testenv.BackendRedis, - executes: true, - newQ: func(t *testing.T) QueueRuntime { - q, err := newQueueRuntime(redisCfg(integrationRedis.addr)) - if err != nil { - t.Fatalf("new redis queue failed: %v", err) - } - return q - }, - }, - { - name: testenv.BackendMySQL, - executes: true, - newQ: func(t *testing.T) QueueRuntime { - q, err := newQueueRuntime(mysqlCfg(mysqlDSN(integrationMySQL.addr))) - if err != nil { - t.Fatalf("new mysql queue failed: %v", err) - } - return q - }, - }, - { - name: testenv.BackendPostgres, - executes: true, - newQ: func(t *testing.T) QueueRuntime { - q, err := newQueueRuntime(postgresCfg(postgresDSN(integrationPostgres.addr))) - if err != nil { - t.Fatalf("new postgres queue failed: %v", err) - } - return q - }, - }, - { - name: testenv.BackendSQLite, - executes: true, - newQ: func(t *testing.T) QueueRuntime { - q, err := newQueueRuntime(sqliteCfg(fmt.Sprintf("%s/bus-integration-%d.db", t.TempDir(), time.Now().UnixNano()))) - if err != nil { - t.Fatalf("new sqlite queue failed: %v", err) - } - return q - }, - }, - { - name: testenv.BackendNATS, - executes: true, - newQ: func(t *testing.T) QueueRuntime { - q, err := newQueueRuntime(natsCfg(integrationNATS.url)) - if err != nil { - t.Fatalf("new nats queue failed: %v", err) - } - return q - }, - }, - { - name: testenv.BackendSQS, - executes: true, - newQ: func(t *testing.T) QueueRuntime { - q, err := newQueueRuntime(sqsCfg( - integrationSQS.region, - integrationSQS.endpoint, - integrationSQS.accessKey, - integrationSQS.secretKey, - )) - if err != nil { - t.Fatalf("new sqs queue failed: %v", err) - } - return q - }, - }, - { - name: testenv.BackendRabbitMQ, - executes: true, - newQ: func(t *testing.T) QueueRuntime { - q, err := newQueueRuntime(rabbitmqCfg(integrationRabbitMQ.url)) - if err != nil { - t.Fatalf("new rabbitmq queue failed: %v", err) - } - return q - }, - }, - } - - for _, backend := range fx { - backend := backend - t.Run(backend.name, func(t *testing.T) { - if !integrationBackendEnabled(backend.name) { - t.Skipf("%s integration backend not selected", backend.name) - } - t.Parallel() - - q := backend.newQ(t) - b, err := bus.New(q) - if err != nil { - t.Fatalf("new bus failed: %v", err) - } - if err := b.StartWorkers(context.Background()); err != nil { - t.Fatalf("start bus workers failed: %v", err) - } - defer func() { - shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - _ = b.Shutdown(shutdownCtx) - }() - - queueName := uniqueQueueName("bus-integration") - if backend.name == testenv.BackendRedis || - backend.name == testenv.BackendMySQL || - backend.name == testenv.BackendPostgres || - backend.name == testenv.BackendSQLite || - backend.name == testenv.BackendNATS || - backend.name == testenv.BackendSQS || - backend.name == testenv.BackendRabbitMQ { - queueName = "default" - } - if !backend.executes { - testBusNullScenario(t, b, queueName) - return - } - testBusDispatchScenario(t, b, queueName) - testBusChainScenario(t, b, queueName) - testBusBatchScenario(t, b, queueName) - testBusWorkflowFailureCallbacksScenario(t, backend.name, b, queueName) - }) - } -} - -func testBusNullScenario(t *testing.T, b bus.Bus, queueName string) { - t.Helper() - - if _, err := b.Dispatch(context.Background(), bus.NewJob("bus:null:dispatch", map[string]string{ - "probe": "true", - }).OnQueue(queueName)); err != nil { - t.Fatalf("null scenario: dispatch failed: %v", err) - } - - chainID, err := b.Chain( - bus.NewJob("bus:null:chain:step1", nil), - bus.NewJob("bus:null:chain:step2", nil), - ).OnQueue(queueName).Dispatch(context.Background()) - if err != nil { - t.Fatalf("null scenario: chain dispatch failed: %v", err) - } - chain, err := b.FindChain(context.Background(), chainID) - if err != nil { - t.Fatalf("null scenario: find chain failed: %v", err) - } - if chain.Completed { - t.Fatal("null scenario: expected chain to remain incomplete") - } - - batchID, err := b.Batch( - bus.NewJob("bus:null:batch:step1", nil), - bus.NewJob("bus:null:batch:step2", nil), - ).OnQueue(queueName).Dispatch(context.Background()) - if err != nil { - t.Fatalf("null scenario: batch dispatch failed: %v", err) - } - batch, err := b.FindBatch(context.Background(), batchID) - if err != nil { - t.Fatalf("null scenario: find batch failed: %v", err) - } - if batch.Completed { - t.Fatal("null scenario: expected batch to remain incomplete") - } -} - -func testBusDispatchScenario(t *testing.T, b bus.Bus, queueName string) { - t.Helper() - type PollPayload struct { - URL string `json:"url"` - } - seen := make(chan string, 1) - b.Register("bus:dispatch:poll", func(_ context.Context, jc bus.Context) error { - var payload PollPayload - if err := jc.Bind(&payload); err != nil { - return err - } - seen <- payload.URL - return nil - }) - - _, err := b.Dispatch(context.Background(), bus.NewJob("bus:dispatch:poll", PollPayload{ - URL: "https://goforj.dev/health", - }).OnQueue(queueName)) - if err != nil { - t.Fatalf("dispatch scenario: dispatch failed: %v", err) - } - - select { - case got := <-seen: - if got != "https://goforj.dev/health" { - t.Fatalf("dispatch scenario: unexpected url %q", got) - } - case <-time.After(20 * time.Second): - t.Fatal("dispatch scenario: timed out waiting for handler") - } -} - -func testBusChainScenario(t *testing.T, b bus.Bus, queueName string) { - t.Helper() - var mu sync.Mutex - order := make([]string, 0, 3) - - appendOrder := func(name string) { - mu.Lock() - order = append(order, name) - mu.Unlock() - } - - b.Register("bus:chain:step1", func(context.Context, bus.Context) error { - appendOrder("step1") - return nil - }) - b.Register("bus:chain:step2", func(context.Context, bus.Context) error { - appendOrder("step2") - return nil - }) - b.Register("bus:chain:step3", func(context.Context, bus.Context) error { - appendOrder("step3") - return nil - }) - - chainID, err := b.Chain( - bus.NewJob("bus:chain:step1", nil), - bus.NewJob("bus:chain:step2", nil), - bus.NewJob("bus:chain:step3", nil), - ).OnQueue(queueName).Dispatch(context.Background()) - if err != nil { - t.Fatalf("chain scenario: dispatch failed: %v", err) - } - - waitFor(t, 20*time.Second, "chain completed", func() bool { - st, err := b.FindChain(context.Background(), chainID) - return err == nil && st.Completed - }) - - mu.Lock() - got := append([]string(nil), order...) - mu.Unlock() - if len(got) != 3 || got[0] != "step1" || got[1] != "step2" || got[2] != "step3" { - t.Fatalf("chain scenario: unexpected execution order %v", got) - } -} - -func testBusBatchScenario(t *testing.T, b bus.Bus, queueName string) { - t.Helper() - type BatchPayload struct { - ID int `json:"id"` - } - var processed atomic.Int32 - b.Register("bus:batch:work", func(_ context.Context, jc bus.Context) error { - var payload BatchPayload - if err := jc.Bind(&payload); err != nil { - return err - } - processed.Add(1) - return nil - }) - - batchID, err := b.Batch( - bus.NewJob("bus:batch:work", BatchPayload{ID: 1}), - bus.NewJob("bus:batch:work", BatchPayload{ID: 2}), - bus.NewJob("bus:batch:work", BatchPayload{ID: 3}), - ).OnQueue(queueName).Dispatch(context.Background()) - if err != nil { - t.Fatalf("batch scenario: dispatch failed: %v", err) - } - - waitFor(t, 20*time.Second, "batch completed", func() bool { - st, err := b.FindBatch(context.Background(), batchID) - return err == nil && st.Completed && st.Processed == 3 && st.Pending == 0 - }) - - if got := processed.Load(); got != 3 { - t.Fatalf("batch scenario: expected 3 processed jobs, got %d", got) - } -} - -func testBusWorkflowFailureCallbacksScenario(t *testing.T, backendName string, b bus.Bus, queueName string) { - t.Helper() - switch backendName { - case testenv.BackendWorkerpool, testenv.BackendNATS, testenv.BackendSQS: - t.Skipf("workflow failure callback semantics are not asserted in this suite for backend %s yet", backendName) - } - callbackWait := 45 * time.Second - - t.Run("workflow_chain_failure_callbacks", func(t *testing.T) { - step1Type := fmt.Sprintf("bus:chain:fail:step1:%d", time.Now().UnixNano()) - step2Type := fmt.Sprintf("bus:chain:fail:step2:%d", time.Now().UnixNano()) - - b.Register(step1Type, func(context.Context, bus.Context) error { return nil }) - b.Register(step2Type, func(context.Context, bus.Context) error { return errors.New("chain-step-fail") }) - - var catchCount atomic.Int32 - var finallyCount atomic.Int32 - catchDone := make(chan struct{}, 1) - finallyDone := make(chan struct{}, 1) - - chainID, _ := b.Chain( - bus.NewJob(step1Type, nil), - bus.NewJob(step2Type, nil), - ).OnQueue(queueName).Catch(func(_ context.Context, st bus.ChainState, err error) error { - catchCount.Add(1) - select { - case catchDone <- struct{}{}: - default: - } - return nil - }).Finally(func(_ context.Context, st bus.ChainState) error { - finallyCount.Add(1) - select { - case finallyDone <- struct{}{}: - default: - } - return nil - }).Dispatch(context.Background()) - - if chainID == "" { - t.Fatal("workflow chain failure scenario: expected chain ID") - } - - select { - case <-catchDone: - case <-time.After(callbackWait): - t.Fatal("workflow chain failure scenario: timed out waiting for catch callback") - } - select { - case <-finallyDone: - case <-time.After(callbackWait): - t.Fatal("workflow chain failure scenario: timed out waiting for finally callback") - } - - waitFor(t, callbackWait, "failed chain state", func() bool { - st, err := b.FindChain(context.Background(), chainID) - return err == nil && st.Failed - }) - waitFor(t, callbackWait, "chain catch callback count", func() bool { return catchCount.Load() == 1 }) - waitFor(t, callbackWait, "chain finally callback count", func() bool { return finallyCount.Load() == 1 }) - - if got := catchCount.Load(); got != 1 { - t.Fatalf("workflow chain failure scenario: expected catch once, got %d", got) - } - if got := finallyCount.Load(); got != 1 { - t.Fatalf("workflow chain failure scenario: expected finally once, got %d", got) - } - - st, err := b.FindChain(context.Background(), chainID) - if err != nil { - t.Fatalf("workflow chain failure scenario: find chain failed: %v", err) - } - if !st.Failed { - t.Fatalf("workflow chain failure scenario: expected failed chain state, got %+v", st) - } - if st.Failure == "" { - t.Fatalf("workflow chain failure scenario: expected failure message, got %+v", st) - } - }) - - t.Run("workflow_batch_failure_callbacks", func(t *testing.T) { - jobType := fmt.Sprintf("bus:batch:fail:work:%d", time.Now().UnixNano()) - b.Register(jobType, func(context.Context, bus.Context) error { return errors.New("batch-work-fail") }) - - var catchCount atomic.Int32 - var finallyCount atomic.Int32 - catchDone := make(chan struct{}, 1) - finallyDone := make(chan struct{}, 1) - - batchID, _ := b.Batch( - bus.NewJob(jobType, nil), - bus.NewJob(jobType, nil), - ).OnQueue(queueName).Catch(func(_ context.Context, st bus.BatchState, err error) error { - if !st.Completed || !st.Cancelled { - return errors.New("expected cancelled/completed batch state in catch") - } - catchCount.Add(1) - select { - case catchDone <- struct{}{}: - default: - } - return nil - }).Finally(func(_ context.Context, st bus.BatchState) error { - if !st.Completed { - return errors.New("expected completed batch state in finally") - } - finallyCount.Add(1) - select { - case finallyDone <- struct{}{}: - default: - } - return nil - }).Dispatch(context.Background()) - - if batchID == "" { - t.Fatal("workflow batch failure scenario: expected batch ID") - } - - select { - case <-catchDone: - case <-time.After(callbackWait): - t.Fatal("workflow batch failure scenario: timed out waiting for catch callback") - } - select { - case <-finallyDone: - case <-time.After(callbackWait): - t.Fatal("workflow batch failure scenario: timed out waiting for finally callback") - } - - waitFor(t, callbackWait, "failed/cancelled batch state", func() bool { - st, err := b.FindBatch(context.Background(), batchID) - return err == nil && st.Completed && st.Cancelled - }) - waitFor(t, callbackWait, "batch catch callback count", func() bool { return catchCount.Load() == 1 }) - waitFor(t, callbackWait, "batch finally callback count", func() bool { return finallyCount.Load() == 1 }) - - if got := catchCount.Load(); got != 1 { - t.Fatalf("workflow batch failure scenario: expected catch once, got %d", got) - } - if got := finallyCount.Load(); got != 1 { - t.Fatalf("workflow batch failure scenario: expected finally once, got %d", got) - } - - st, err := b.FindBatch(context.Background(), batchID) - if err != nil { - t.Fatalf("workflow batch failure scenario: find batch failed: %v", err) - } - if !st.Completed || !st.Cancelled { - t.Fatalf("workflow batch failure scenario: expected completed+cancelled batch, got %+v", st) - } - }) -} - -func waitFor(t *testing.T, timeout time.Duration, label string, ready func() bool) { - t.Helper() - deadline := time.Now().Add(timeout) - for { - if ready() { - return - } - if time.Now().After(deadline) { - t.Fatalf("timed out waiting for %s", label) - } - time.Sleep(25 * time.Millisecond) - } -} - -func uniqueQueueName(prefix string) string { - return fmt.Sprintf("%s-%d-%d", prefix, time.Now().UnixNano(), rand.Intn(100000)) -} - -func startRedisContainer(ctx context.Context) (testcontainers.Container, string, error) { - req := testcontainers.ContainerRequest{ - Image: "redis:7", - ExposedPorts: []string{"6379/tcp"}, - WaitingFor: wait.ForListeningPort("6379/tcp").WithStartupTimeout(60 * time.Second), - } - c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ContainerRequest: req, Started: true}) - if err != nil { - return nil, "", err - } - host, err := c.Host(ctx) - if err != nil { - return nil, "", err - } - port, err := c.MappedPort(ctx, "6379/tcp") - if err != nil { - return nil, "", err - } - return c, fmt.Sprintf("%s:%s", host, port.Port()), nil -} - -func startMySQLContainer(ctx context.Context) (testcontainers.Container, string, error) { - req := testcontainers.ContainerRequest{ - Image: "mysql:8.0", - ExposedPorts: []string{"3306/tcp"}, - Env: map[string]string{ - "MYSQL_ROOT_PASSWORD": "root", - "MYSQL_DATABASE": "queue_test", - "MYSQL_USER": "queue", - "MYSQL_PASSWORD": "queue", - }, - WaitingFor: wait.ForAll( - wait.ForListeningPort("3306/tcp"), - wait.ForLog("ready for connections"), - ).WithStartupTimeout(120 * time.Second), - } - c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ContainerRequest: req, Started: true}) - if err != nil { - return nil, "", err - } - host, err := c.Host(ctx) - if err != nil { - _ = c.Terminate(ctx) - return nil, "", err - } - port, err := c.MappedPort(ctx, "3306/tcp") - if err != nil { - _ = c.Terminate(ctx) - return nil, "", err - } - addr := net.JoinHostPort(host, port.Port()) - if err := waitForMySQLReady(addr, 60*time.Second); err != nil { - _ = c.Terminate(ctx) - return nil, "", err - } - return c, addr, nil -} - -func startPostgresContainer(ctx context.Context) (testcontainers.Container, string, error) { - req := testcontainers.ContainerRequest{ - Image: "postgres:16", - ExposedPorts: []string{"5432/tcp"}, - Env: map[string]string{ - "POSTGRES_USER": "queue", - "POSTGRES_PASSWORD": "queue", - "POSTGRES_DB": "queue_test", - }, - WaitingFor: wait.ForAll( - wait.ForListeningPort("5432/tcp"), - wait.ForLog("database system is ready to accept connections"), - ).WithStartupTimeout(90 * time.Second), - } - c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ContainerRequest: req, Started: true}) - if err != nil { - return nil, "", err - } - host, err := c.Host(ctx) - if err != nil { - _ = c.Terminate(ctx) - return nil, "", err - } - port, err := c.MappedPort(ctx, "5432/tcp") - if err != nil { - _ = c.Terminate(ctx) - return nil, "", err - } - addr := net.JoinHostPort(host, port.Port()) - if err := waitForPostgresReady(addr, 60*time.Second); err != nil { - _ = c.Terminate(ctx) - return nil, "", err - } - return c, addr, nil -} - -func startNATSContainer(ctx context.Context) (testcontainers.Container, string, error) { - req := testcontainers.ContainerRequest{ - Image: "nats:2", - ExposedPorts: []string{"4222/tcp"}, - WaitingFor: wait.ForListeningPort("4222/tcp").WithStartupTimeout(60 * time.Second), - } - c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ContainerRequest: req, Started: true}) - if err != nil { - return nil, "", err - } - host, err := c.Host(ctx) - if err != nil { - return nil, "", err - } - port, err := c.MappedPort(ctx, "4222/tcp") - if err != nil { - return nil, "", err - } - return c, fmt.Sprintf("nats://%s:%s", host, port.Port()), nil -} - -func startSQSContainer(ctx context.Context) (testcontainers.Container, string, error) { - var lastErr error - for attempt := 1; attempt <= 3; attempt++ { - req := testcontainers.ContainerRequest{ - Image: "localstack/localstack:3.8", - ExposedPorts: []string{"4566/tcp"}, - Env: map[string]string{ - "SERVICES": "sqs", - "AWS_DEFAULT_REGION": "us-east-1", - }, - WaitingFor: wait.ForListeningPort("4566/tcp").WithStartupTimeout(120 * time.Second), - } - c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ContainerRequest: req, Started: true}) - if err != nil { - lastErr = err - } else { - host, hostErr := c.Host(ctx) - if hostErr != nil { - _ = c.Terminate(ctx) - lastErr = hostErr - } else { - port, portErr := c.MappedPort(ctx, "4566/tcp") - if portErr != nil { - _ = c.Terminate(ctx) - lastErr = portErr - } else { - return c, fmt.Sprintf("http://%s:%s", host, port.Port()), nil - } - } - } - if attempt < 3 { - time.Sleep(time.Duration(attempt) * time.Second) - } - } - return nil, "", fmt.Errorf("start sqs container after retries: %w", lastErr) -} - -func startRabbitMQContainer(ctx context.Context) (testcontainers.Container, string, error) { - req := testcontainers.ContainerRequest{ - Image: "rabbitmq:3.12-management", - ExposedPorts: []string{"5672/tcp"}, - WaitingFor: wait.ForListeningPort("5672/tcp").WithStartupTimeout(90 * time.Second), - } - c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ContainerRequest: req, Started: true}) - if err != nil { - return nil, "", err - } - host, err := c.Host(ctx) - if err != nil { - return nil, "", err - } - port, err := c.MappedPort(ctx, "5672/tcp") - if err != nil { - return nil, "", err - } - return c, fmt.Sprintf("amqp://guest:guest@%s:%s/", host, port.Port()), nil -} - -func waitForMySQLReady(addr string, timeout time.Duration) error { - dsn := fmt.Sprintf("queue:queue@tcp(%s)/queue_test?parseTime=true", addr) - deadline := time.Now().Add(timeout) - var lastErr error - for time.Now().Before(deadline) { - db, err := sql.Open("mysql", dsn) - if err == nil { - pingErr := db.Ping() - _ = db.Close() - if pingErr == nil { - return nil - } - lastErr = pingErr - } else { - lastErr = err - } - time.Sleep(500 * time.Millisecond) - } - return fmt.Errorf("mysql not ready: %w", lastErr) -} - -func waitForPostgresReady(addr string, timeout time.Duration) error { - dsn := fmt.Sprintf("postgres://queue:queue@%s/queue_test?sslmode=disable", addr) - deadline := time.Now().Add(timeout) - var lastErr error - for time.Now().Before(deadline) { - db, err := sql.Open("pgx", dsn) - if err == nil { - pingErr := db.Ping() - _ = db.Close() - if pingErr == nil { - return nil - } - lastErr = pingErr - } else { - lastErr = err - } - time.Sleep(500 * time.Millisecond) - } - return fmt.Errorf("postgres not ready: %w", lastErr) -} diff --git a/integration/bus/queue_factory_test.go b/integration/bus/queue_factory_test.go deleted file mode 100644 index b00794c..0000000 --- a/integration/bus/queue_factory_test.go +++ /dev/null @@ -1,17 +0,0 @@ -//go:build integration - -package bus_test - -import ( - "github.com/goforj/queue/integration/testenv" -) - -type QueueRuntime = testenv.Runtime - -func newQueueRuntime(cfg any) (QueueRuntime, error) { - return testenv.NewQueueRuntime(cfg) -} - -func withWorkers(q QueueRuntime, count int) QueueRuntime { - return testenv.WithWorkers(q, count) -} diff --git a/integration/root/config_builders_test.go b/integration/root/config_builders_test.go index 49a22b6..1fe64ef 100644 --- a/integration/root/config_builders_test.go +++ b/integration/root/config_builders_test.go @@ -14,8 +14,8 @@ import ( "github.com/goforj/queue/driver/redisqueue" "github.com/goforj/queue/driver/sqlitequeue" "github.com/goforj/queue/driver/sqsqueue" - "github.com/goforj/queue/queueconfig" "github.com/goforj/queue/integration/testenv" + "github.com/goforj/queue/queueconfig" ) func nullCfg() queue.Config { return queue.Config{Driver: queue.DriverNull} } @@ -75,8 +75,6 @@ func withDefaultQueue[T any](cfg T, name string) T { func withObserver[T any](cfg T, observer queue.Observer) T { switch c := any(&cfg).(type) { - case *queue.Config: - c.Observer = observer case *redisqueue.Config: c.Observer = observer case *natsqueue.Config: diff --git a/integration/root/workflow_contract_integration_test.go b/integration/root/workflow_contract_integration_test.go new file mode 100644 index 0000000..2859e87 --- /dev/null +++ b/integration/root/workflow_contract_integration_test.go @@ -0,0 +1,159 @@ +//go:build integration + +package root_test + +import ( + "context" + "fmt" + "sync/atomic" + "testing" + "time" + + "github.com/goforj/queue" + "github.com/goforj/queue/integration/testenv" +) + +// TestCanonicalWorkflowContract_AllExecutableBackends proves the root queue +// API owns chain and batch orchestration without the retired bus facade. +func TestCanonicalWorkflowContract_AllExecutableBackends(t *testing.T) { + fixtures := []struct { + name string + queue string + newQueue func(t *testing.T) *queue.Queue + }{ + {name: testenv.BackendSync, queue: "default", newQueue: func(t *testing.T) *queue.Queue { + q, err := testenv.NewQueue(syncCfg(), queue.WithWorkers(1)) + if err != nil { + t.Fatalf("new sync queue: %v", err) + } + return q + }}, + {name: testenv.BackendWorkerpool, queue: "default", newQueue: func(t *testing.T) *queue.Queue { + q, err := testenv.NewQueue(workerpoolCfg(), queue.WithWorkers(1)) + if err != nil { + t.Fatalf("new workerpool queue: %v", err) + } + return q + }}, + {name: testenv.BackendRedis, queue: "workflow_contract_redis", newQueue: func(t *testing.T) *queue.Queue { + ensureRedis(t) + q, err := testenv.NewQueue(withDefaultQueue(redisCfg(integrationRedis.addr), "workflow_contract_redis"), queue.WithWorkers(1)) + if err != nil { + t.Fatalf("new redis queue: %v", err) + } + return q + }}, + {name: testenv.BackendNATS, queue: "workflow_contract_nats", newQueue: func(t *testing.T) *queue.Queue { + ensureNATS(t) + q, err := testenv.NewQueue(withDefaultQueue(natsCfg(integrationNATS.url), "workflow_contract_nats"), queue.WithWorkers(1)) + if err != nil { + t.Fatalf("new nats queue: %v", err) + } + return q + }}, + {name: testenv.BackendSQS, queue: "workflow_contract_sqs", newQueue: func(t *testing.T) *queue.Queue { + ensureSQS(t) + q, err := testenv.NewQueue(withDefaultQueue(sqsCfg(integrationSQS.region, integrationSQS.endpoint, integrationSQS.accessKey, integrationSQS.secretKey), "workflow_contract_sqs"), queue.WithWorkers(1)) + if err != nil { + t.Fatalf("new sqs queue: %v", err) + } + return q + }}, + {name: testenv.BackendRabbitMQ, queue: "workflow_contract_rabbitmq", newQueue: func(t *testing.T) *queue.Queue { + ensureRabbitMQ(t) + q, err := testenv.NewQueue(withDefaultQueue(rabbitmqCfg(integrationRabbitMQ.url), "workflow_contract_rabbitmq"), queue.WithWorkers(1)) + if err != nil { + t.Fatalf("new RabbitMQ queue: %v", err) + } + return q + }}, + {name: testenv.BackendSQLite, queue: "workflow_contract_sqlite", newQueue: func(t *testing.T) *queue.Queue { + q, err := testenv.NewQueue(withDefaultQueue(sqliteCfg(fmt.Sprintf("%s/workflow-contract.db", t.TempDir())), "workflow_contract_sqlite"), queue.WithWorkers(1)) + if err != nil { + t.Fatalf("new sqlite queue: %v", err) + } + return q + }}, + {name: testenv.BackendMySQL, queue: "workflow_contract_mysql", newQueue: func(t *testing.T) *queue.Queue { + ensureMySQLDB(t) + q, err := testenv.NewQueue(withDefaultQueue(mysqlCfg(mysqlDSN(integrationMySQL.addr)), "workflow_contract_mysql"), queue.WithWorkers(1)) + if err != nil { + t.Fatalf("new mysql queue: %v", err) + } + return q + }}, + {name: testenv.BackendPostgres, queue: "workflow_contract_postgres", newQueue: func(t *testing.T) *queue.Queue { + ensurePostgresDB(t) + q, err := testenv.NewQueue(withDefaultQueue(postgresCfg(postgresDSN(integrationPostgres.addr)), "workflow_contract_postgres"), queue.WithWorkers(1)) + if err != nil { + t.Fatalf("new postgres queue: %v", err) + } + return q + }}, + } + + for _, fixture := range fixtures { + t.Run(fixture.name, func(t *testing.T) { + if !integrationBackendEnabled(fixture.name) { + t.Skipf("%s integration backend not selected", fixture.name) + } + q := fixture.newQueue(t) + t.Cleanup(func() { _ = q.Shutdown(context.Background()) }) + + var chainCalls atomic.Int32 + var batchCalls atomic.Int32 + q.Register("workflow:chain:first", func(context.Context, queue.Message) error { + chainCalls.Add(1) + return nil + }) + q.Register("workflow:chain:second", func(context.Context, queue.Message) error { + chainCalls.Add(1) + return nil + }) + q.Register("workflow:batch", func(context.Context, queue.Message) error { + batchCalls.Add(1) + return nil + }) + if err := q.StartWorkers(context.Background()); err != nil { + t.Fatalf("start workers: %v", err) + } + + chainID, err := q.Chain( + queue.NewJob("workflow:chain:first"), + queue.NewJob("workflow:chain:second"), + ).OnQueue(fixture.queue).Dispatch(context.Background()) + if err != nil { + t.Fatalf("dispatch chain: %v", err) + } + waitForCanonicalWorkflow(t, "chain completion", func() bool { + state, findErr := q.FindChain(context.Background(), chainID) + return findErr == nil && state.Completed && !state.Failed && chainCalls.Load() == 2 + }) + + batchID, err := q.Batch( + queue.NewJob("workflow:batch"), + queue.NewJob("workflow:batch"), + ).OnQueue(fixture.queue).Dispatch(context.Background()) + if err != nil { + t.Fatalf("dispatch batch: %v", err) + } + waitForCanonicalWorkflow(t, "batch completion", func() bool { + state, findErr := q.FindBatch(context.Background(), batchID) + return findErr == nil && state.Completed && state.Processed == 2 && state.Failed == 0 && batchCalls.Load() == 2 + }) + }) + } +} + +// waitForCanonicalWorkflow keeps local asynchronous runtimes and synchronous runtimes under one contract assertion. +func waitForCanonicalWorkflow(t *testing.T, scenario string, check func() bool) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if check() { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("%s did not complete", scenario) +} diff --git a/internal/architecture/comments_test.go b/internal/architecture/comments_test.go index 7c8e663..0482a36 100644 --- a/internal/architecture/comments_test.go +++ b/internal/architecture/comments_test.go @@ -12,10 +12,10 @@ import ( "testing" ) -// TestWorkflowProductionDeclarationsAreDocumented keeps the extracted engine and compatibility facade aligned with repository comment rules. +// TestWorkflowProductionDeclarationsAreDocumented keeps the extracted engine aligned with repository comment rules. func TestWorkflowProductionDeclarationsAreDocumented(t *testing.T) { repository := repositoryRoot(t) - for _, directory := range []string{filepath.Join(repository, "internal", "workflow"), filepath.Join(repository, "bus")} { + for _, directory := range []string{filepath.Join(repository, "internal", "workflow")} { for _, missing := range undocumentedProductionDeclarations(t, directory) { t.Errorf("missing name-first declaration comment: %s", missing) } diff --git a/internal/architecture/imports_test.go b/internal/architecture/imports_test.go index e72510c..df91572 100644 --- a/internal/architecture/imports_test.go +++ b/internal/architecture/imports_test.go @@ -11,15 +11,11 @@ import ( "testing" ) -// TestWorkflowDependencyDirection prevents the public compatibility package from becoming an inward engine dependency again. +// TestWorkflowDependencyDirection keeps the internal workflow engine independent from root public packages. func TestWorkflowDependencyDirection(t *testing.T) { repository := repositoryRoot(t) - assertProductionImportsExclude(t, repository, map[string]struct{}{ - "github.com/goforj/queue/bus": {}, - }) assertProductionImportsExclude(t, filepath.Join(repository, "internal", "workflow"), map[string]struct{}{ "github.com/goforj/queue": {}, - "github.com/goforj/queue/bus": {}, "github.com/goforj/queue/queuecore": {}, }) } diff --git a/internal/driverbridge/bridge.go b/internal/driverbridge/bridge.go index 86b1dba..53c0daa 100644 --- a/internal/driverbridge/bridge.go +++ b/internal/driverbridge/bridge.go @@ -58,11 +58,11 @@ func NewObserverSink(observers ...queue.Observer) queue.Observer { // the runtime seam is being moved behind internal APIs. func NewQueueFromDriver( cfg queue.Config, + observer queue.Observer, backend any, workerFactory func(workers int) (any, error), opts ...queue.Option, ) (*queue.Queue, error) { - cfg.Observer = NewObserverSink(cfg.Observer) driverBackend, err := adaptQueueBackend(backend) if err != nil { return nil, err @@ -74,7 +74,7 @@ func NewQueueFromDriver( for _, opt := range opts { rawOpts = append(rawOpts, opt) } - qv, err := runtimehook.BuildQueueFromDriver(cfg, driverBackend, adaptWorkerFactory(workerFactory), rawOpts) + qv, err := runtimehook.BuildQueueFromDriver(cfg, NewObserverSink(observer), driverBackend, adaptWorkerFactory(workerFactory), rawOpts) if err != nil { return nil, err } diff --git a/internal/driverbridge/bridge_test.go b/internal/driverbridge/bridge_test.go index 9e33a77..ba84115 100644 --- a/internal/driverbridge/bridge_test.go +++ b/internal/driverbridge/bridge_test.go @@ -131,6 +131,7 @@ func TestNewQueueFromDriverPreservesReadiness(t *testing.T) { } legacyQueue, err := NewQueueFromDriver( queue.Config{Driver: queue.DriverDatabase}, + nil, legacy, nil, ) @@ -154,6 +155,7 @@ func TestNewQueueFromDriverPreservesReadiness(t *testing.T) { } canonicalQueue, err := NewQueueFromDriver( queue.Config{Driver: queue.DriverDatabase}, + nil, canonical, nil, ) @@ -188,6 +190,7 @@ func TestNewQueueFromDriver_ExternalWorkerFactoryAndOptionalCapabilities(t *test q, err := NewQueueFromDriver( queue.Config{Driver: queue.DriverNATS, DefaultQueue: "default"}, + nil, backend, func(int) (any, error) { return w, nil }, ) @@ -267,6 +270,7 @@ func TestNewQueueFromDriver_NativeBackendOptionalCapabilities(t *testing.T) { q, err := NewQueueFromDriver( queue.Config{Driver: queue.DriverDatabase, DefaultQueue: "default"}, + nil, backend, nil, ) @@ -303,7 +307,8 @@ func TestNewQueueFromDriverSharesObserverSink(t *testing.T) { })) q, err := NewQueueFromDriver( - queue.Config{Driver: queue.DriverNATS, DefaultQueue: "default", Observer: sink}, + queue.Config{Driver: queue.DriverNATS, DefaultQueue: "default"}, + sink, &externalBackendStub{driver: queue.DriverNATS}, nil, queue.WithObserver(queue.ObserverFunc(func(_ context.Context, event queue.Event) { diff --git a/internal/readmecheck/readme_manual_snippets_test.go b/internal/readmecheck/readme_manual_snippets_test.go index 84c8e11..154eb44 100644 --- a/internal/readmecheck/readme_manual_snippets_test.go +++ b/internal/readmecheck/readme_manual_snippets_test.go @@ -190,10 +190,7 @@ func compileObservabilitySnippet() { }), ) - q, _ := queue.New(queue.Config{ - Driver: queue.DriverWorkerpool, - Observer: observer, - }) + q, _ := queue.New(queue.Config{Driver: queue.DriverWorkerpool}, queue.WithObserver(observer)) _ = q } @@ -212,10 +209,7 @@ func compileComposeObserversSnippet() { }), ) - q, _ := queue.New(queue.Config{ - Driver: queue.DriverWorkerpool, - Observer: observer, - }) + q, _ := queue.New(queue.Config{Driver: queue.DriverWorkerpool}, queue.WithObserver(observer)) _ = q } diff --git a/internal/runtimehook/hook.go b/internal/runtimehook/hook.go index c819536..6838d82 100644 --- a/internal/runtimehook/hook.go +++ b/internal/runtimehook/hook.go @@ -7,7 +7,7 @@ type WorkerFactory func(workers int) (any, error) // BuildQueueFromDriver builds a high-level *queue.Queue from a private driver // backend representation provided by internal/driverbridge. -type BuildQueueFromDriverFunc func(cfg any, backend any, workerFactory WorkerFactory, opts []any) (any, error) +type BuildQueueFromDriverFunc func(cfg any, observer any, backend any, workerFactory WorkerFactory, opts []any) (any, error) // ExtractRuntimeFromQueue exposes the internal runtime from *queue.Queue for // test-only bridges (not public API use). diff --git a/observability_benchmark_test.go b/observability_benchmark_test.go index 24de9ba..c0ebebd 100644 --- a/observability_benchmark_test.go +++ b/observability_benchmark_test.go @@ -62,10 +62,7 @@ func BenchmarkEnqueueSync_NoObserver(b *testing.B) { func BenchmarkEnqueueSync_WithObserver(b *testing.B) { collector := NewStatsCollector() - q, err := newRuntime(Config{ - Driver: DriverSync, - Observer: collector, - }) + q, err := newRuntimeWithObserver(Config{Driver: DriverSync}, collector) if err != nil { b.Fatalf("new queue failed: %v", err) } diff --git a/observability_test.go b/observability_test.go index 0419e16..4b4280e 100644 --- a/observability_test.go +++ b/observability_test.go @@ -20,10 +20,7 @@ func startTestQueue(t *testing.T, q queueRuntime) { func TestStatsCollector_CapturesQueueProcessing(t *testing.T) { collector := NewStatsCollector() - q, err := newRuntime(Config{ - Driver: DriverSync, - Observer: collector, - }) + q, err := newRuntimeWithObserver(Config{Driver: DriverSync}, collector) if err != nil { t.Fatalf("new queue failed: %v", err) } @@ -49,10 +46,7 @@ func TestStatsCollector_CapturesQueueProcessing(t *testing.T) { func TestStatsCollector_CapturesProcessingFailure(t *testing.T) { collector := NewStatsCollector() - q, err := newRuntime(Config{ - Driver: DriverSync, - Observer: collector, - }) + q, err := newRuntimeWithObserver(Config{Driver: DriverSync}, collector) if err != nil { t.Fatalf("new queue failed: %v", err) } @@ -532,12 +526,9 @@ func TestStatsSnapshot_Getters(t *testing.T) { } func TestObserverPanic_DoesNotBreakDispatch(t *testing.T) { - q, err := newRuntime(Config{ - Driver: DriverSync, - Observer: ObserverFunc(func(context.Context, Event) { - panic("observer panic") - }), - }) + q, err := newRuntimeWithObserver(Config{Driver: DriverSync}, ObserverFunc(func(context.Context, Event) { + panic("observer panic") + })) if err != nil { t.Fatalf("new queue failed: %v", err) } @@ -550,12 +541,9 @@ func TestObserverPanic_DoesNotBreakDispatch(t *testing.T) { func TestObserverPanic_DoesNotBreakHandlerExecution(t *testing.T) { var called atomic.Int64 - q, err := newRuntime(Config{ - Driver: DriverSync, - Observer: ObserverFunc(func(context.Context, Event) { - panic("observer panic") - }), - }) + q, err := newRuntimeWithObserver(Config{Driver: DriverSync}, ObserverFunc(func(context.Context, Event) { + panic("observer panic") + })) if err != nil { t.Fatalf("new queue failed: %v", err) } diff --git a/public_workflow_identity_test.go b/public_workflow_identity_test.go index 51cbd99..7a15c66 100644 --- a/public_workflow_identity_test.go +++ b/public_workflow_identity_test.go @@ -5,12 +5,10 @@ import ( "testing" "github.com/goforj/queue" - "github.com/goforj/queue/bus" ) const ( queuePackagePath = "github.com/goforj/queue" - busPackagePath = "github.com/goforj/queue/bus" ) // TestPublicWorkflowTypesAreOwnedByQueue pins queue as the physical owner of the canonical workflow model. @@ -50,98 +48,6 @@ func TestPublicWorkflowTypesAreOwnedByQueue(t *testing.T) { } } -// TestBusCompatibleAliasesResolveToQueue pins the deprecated facade to the canonical queue identities. -func TestBusCompatibleAliasesResolveToQueue(t *testing.T) { - t.Parallel() - - aliases := []struct { - name string - busType reflect.Type - queueType reflect.Type - }{ - {name: "Context", busType: reflectedType[bus.Context](), queueType: reflectedType[queue.Message]()}, - {name: "JobOptions", busType: reflectedType[bus.JobOptions](), queueType: reflectedType[queue.StoredJobOptions]()}, - {name: "DispatchResult", busType: reflectedType[bus.DispatchResult](), queueType: reflectedType[queue.DispatchResult]()}, - {name: "StoredJob", busType: reflectedType[bus.StoredJob](), queueType: reflectedType[queue.StoredJob]()}, - {name: "ChainNode", busType: reflectedType[bus.ChainNode](), queueType: reflectedType[queue.ChainNode]()}, - {name: "ChainRecord", busType: reflectedType[bus.ChainRecord](), queueType: reflectedType[queue.ChainRecord]()}, - {name: "ChainState", busType: reflectedType[bus.ChainState](), queueType: reflectedType[queue.ChainState]()}, - {name: "BatchJob", busType: reflectedType[bus.BatchJob](), queueType: reflectedType[queue.BatchJob]()}, - {name: "BatchJobOutcome", busType: reflectedType[bus.BatchJobOutcome](), queueType: reflectedType[queue.BatchJobOutcome]()}, - {name: "BatchRecord", busType: reflectedType[bus.BatchRecord](), queueType: reflectedType[queue.BatchRecord]()}, - {name: "BatchState", busType: reflectedType[bus.BatchState](), queueType: reflectedType[queue.BatchState]()}, - {name: "Store", busType: reflectedType[bus.Store](), queueType: reflectedType[queue.WorkflowStore]()}, - {name: "WorkflowOutcomeStore", busType: reflectedType[bus.WorkflowOutcomeStore](), queueType: reflectedType[queue.WorkflowOutcomeStore]()}, - {name: "SQLStoreConfig", busType: reflectedType[bus.SQLStoreConfig](), queueType: reflectedType[queue.SQLStoreConfig]()}, - {name: "Next", busType: reflectedType[bus.Next](), queueType: reflectedType[queue.Next]()}, - {name: "Middleware", busType: reflectedType[bus.Middleware](), queueType: reflectedType[queue.Middleware]()}, - {name: "MiddlewareFunc", busType: reflectedType[bus.MiddlewareFunc](), queueType: reflectedType[queue.MiddlewareFunc]()}, - {name: "RetryPolicy", busType: reflectedType[bus.RetryPolicy](), queueType: reflectedType[queue.RetryPolicy]()}, - {name: "SkipWhen", busType: reflectedType[bus.SkipWhen](), queueType: reflectedType[queue.SkipWhen]()}, - {name: "FailOnError", busType: reflectedType[bus.FailOnError](), queueType: reflectedType[queue.FailOnError]()}, - {name: "RateLimiter", busType: reflectedType[bus.RateLimiter](), queueType: reflectedType[queue.RateLimiter]()}, - {name: "RateLimit", busType: reflectedType[bus.RateLimit](), queueType: reflectedType[queue.RateLimit]()}, - {name: "Lock", busType: reflectedType[bus.Lock](), queueType: reflectedType[queue.Lock]()}, - {name: "Locker", busType: reflectedType[bus.Locker](), queueType: reflectedType[queue.Locker]()}, - {name: "WithoutOverlapping", busType: reflectedType[bus.WithoutOverlapping](), queueType: reflectedType[queue.WithoutOverlapping]()}, - } - - for _, contract := range aliases { - if contract.busType != contract.queueType { - t.Errorf("bus.%s type = %v, want queue identity %v", contract.name, contract.busType, contract.queueType) - continue - } - if got := contract.busType.PkgPath(); got != queuePackagePath { - t.Errorf("bus.%s package path = %q, want canonical queue path %q", contract.name, got, queuePackagePath) - } - } -} - -// TestLegacyBusTypesRemainOwnedByBus pins the intentionally distinct compatibility contracts to the bus package. -func TestLegacyBusTypesRemainOwnedByBus(t *testing.T) { - t.Parallel() - - contracts := []struct { - name string - typeOf reflect.Type - }{ - {name: "Bus", typeOf: reflectedType[bus.Bus]()}, - {name: "BatchSpec", typeOf: reflectedType[bus.BatchSpec]()}, - {name: "Fake", typeOf: reflectedType[bus.Fake]()}, - {name: "Handler", typeOf: reflectedType[bus.Handler]()}, - {name: "Observer", typeOf: reflectedType[bus.Observer]()}, - {name: "ObserverFunc", typeOf: reflectedType[bus.ObserverFunc]()}, - {name: "Option", typeOf: reflectedType[bus.Option]()}, - } - - for _, contract := range contracts { - if got := contract.typeOf.PkgPath(); got != busPackagePath { - t.Errorf("bus.%s package path = %q, want %q", contract.name, got, busPackagePath) - } - } - - types := []struct { - name string - busType reflect.Type - queueType reflect.Type - }{ - {name: "Job", busType: reflectedType[bus.Job](), queueType: reflectedType[queue.Job]()}, - {name: "Event", busType: reflectedType[bus.Event](), queueType: reflectedType[queue.Event]()}, - {name: "EventKind", busType: reflectedType[bus.EventKind](), queueType: reflectedType[queue.EventKind]()}, - {name: "ChainBuilder", busType: reflectedType[bus.ChainBuilder](), queueType: reflectedType[queue.ChainBuilder]()}, - {name: "BatchBuilder", busType: reflectedType[bus.BatchBuilder](), queueType: reflectedType[queue.BatchBuilder]()}, - } - - for _, contract := range types { - if got := contract.busType.PkgPath(); got != busPackagePath { - t.Errorf("bus.%s package path = %q, want %q", contract.name, got, busPackagePath) - } - if contract.busType == contract.queueType { - t.Errorf("bus.%s unexpectedly shares queue identity %v", contract.name, contract.queueType) - } - } -} - // reflectedType returns the reflection identity for T, including interface types. func reflectedType[T any]() reflect.Type { return reflect.TypeOf((*T)(nil)).Elem() diff --git a/queue.go b/queue.go index b1594c9..620fa7c 100644 --- a/queue.go +++ b/queue.go @@ -72,10 +72,7 @@ func (c WorkerpoolConfig) normalize() WorkerpoolConfig { // @group Config type Config struct { Driver Driver - // Observer is a compatibility attachment path for queue lifecycle events. - // Deprecated: use WithObserver so all event layers share one constructor option. - Observer Observer - Logger Logger + Logger Logger DefaultQueue string } @@ -129,8 +126,13 @@ func New(cfg Config, opts ...Option) (*Queue, error) { } func newRuntime(cfg Config) (queueRuntime, error) { + return newRuntimeWithObserver(cfg, nil) +} + +// newRuntimeWithObserver builds a root runtime with an internal observer sink for focused runtime tests. +func newRuntimeWithObserver(cfg Config, observer Observer) (queueRuntime, error) { cfg = cfg.normalize() - cfg.Observer = ensureObserverSink(cfg.Observer) + observer = ensureObserverSink(observer) var q queueBackend var err error @@ -162,9 +164,10 @@ func newRuntime(cfg Config) (queueRuntime, error) { runtime = native } common := &queueCommon{ - inner: newObservedQueue(q, cfg.Driver, cfg.Observer), - cfg: cfg, - driver: cfg.Driver, + inner: newObservedQueue(q, cfg.Driver, observer), + cfg: cfg, + driver: cfg.Driver, + observerSink: observer, } if runtime != nil { return &nativeQueueRuntime{ @@ -196,6 +199,7 @@ type queueCommon struct { inner queueBackend cfg Config driver Driver + observerSink Observer ctx context.Context handlerContextDecorator func(context.Context) context.Context } @@ -360,20 +364,20 @@ func (q *queueCommon) addObserver(observer Observer) { if q == nil || observer == nil { return } - q.cfg.Observer = addObserverToSink(q.cfg.Observer, observer) + q.observerSink = addObserverToSink(q.observerSink, observer) if observed, ok := q.inner.(*observedQueue); ok { - observed.observer = q.cfg.Observer + observed.observer = q.observerSink return } - q.inner = newObservedQueue(q.inner, q.driver, q.cfg.Observer) + q.inner = newObservedQueue(q.inner, q.driver, q.observerSink) } // observer returns the composed application observer shared by execution and workflow adapters. func (q *queueCommon) observer() Observer { - if q == nil || !observerHasRecipients(q.cfg.Observer) { + if q == nil || !observerHasRecipients(q.observerSink) { return nil } - return q.cfg.Observer + return q.observerSink } func (q *queueCommon) WithContext(ctx context.Context) *queueCommon { @@ -1197,10 +1201,10 @@ func (q *queueCommon) wrapRegisteredHandler(jobType string, handler Handler) Han if q.cfg.Driver == DriverRedis { return handler } - if !observerHasRecipients(q.cfg.Observer) { + if !observerHasRecipients(q.observerSink) { return wrapHandlerContext(q.handlerContextDecorator, handler) } - return wrapObservedHandler(q.cfg.Observer, q.cfg.Driver, "", jobType, q.handlerContextDecorator, handler) + return wrapObservedHandler(q.observerSink, q.cfg.Driver, "", jobType, q.handlerContextDecorator, handler) } // wrapHandlerContext applies optional execution context decoration while diff --git a/queue_runtime_unit_test.go b/queue_runtime_unit_test.go index ca414a8..59e87e1 100644 --- a/queue_runtime_unit_test.go +++ b/queue_runtime_unit_test.go @@ -1712,7 +1712,8 @@ func TestQueueCommonWrapRegisteredHandlerPreservesContextOnNilDecoration(t *test } decoratorCalls := 0 common := &queueCommon{ - cfg: Config{Driver: DriverSync, Observer: observer}, + cfg: Config{Driver: DriverSync}, + observerSink: observer, handlerContextDecorator: func(context.Context) context.Context { decoratorCalls++ return nil @@ -1781,9 +1782,10 @@ func TestRuntimeHandlerContextDecoratorNativeExternalParity(t *testing.T) { driver := DriverSync common := &queueCommon{ - inner: backend, - cfg: Config{Driver: driver, DefaultQueue: "default", Observer: observer}, - driver: driver, + inner: backend, + cfg: Config{Driver: driver, DefaultQueue: "default"}, + driver: driver, + observerSink: observer, } var runtime queueRuntime = &nativeQueueRuntime{ common: common, @@ -1855,12 +1857,10 @@ func TestQueueCommonWrapRegisteredHandlerDefersRedisDecoration(t *testing.T) { decoratorCalls := 0 observerCalls := 0 common := &queueCommon{ - cfg: Config{ - Driver: DriverRedis, - Observer: ObserverFunc(func(context.Context, Event) { - observerCalls++ - }), - }, + cfg: Config{Driver: DriverRedis}, + observerSink: ObserverFunc(func(context.Context, Event) { + observerCalls++ + }), handlerContextDecorator: func(ctx context.Context) context.Context { decoratorCalls++ return context.WithValue(ctx, "decorated", true) diff --git a/queuefake/doc.go b/queuefake/doc.go deleted file mode 100644 index 96ceb01..0000000 --- a/queuefake/doc.go +++ /dev/null @@ -1,5 +0,0 @@ -// Package queuefake preserves the historical queue-first testing harness. -// -// Its queue and bus compatibility views now share one concurrency-safe -// queue.FakeQueue. New code should use queue.NewFake directly. -package queuefake diff --git a/queuefake/fake.go b/queuefake/fake.go deleted file mode 100644 index a061bd1..0000000 --- a/queuefake/fake.go +++ /dev/null @@ -1,338 +0,0 @@ -package queuefake - -import ( - "testing" - - "github.com/goforj/queue" - "github.com/goforj/queue/bus" -) - -// Fake preserves the historical queuefake harness as two typed views over one -// canonical queue.FakeQueue state. -// -// Deprecated: use queue.NewFake directly. -// @group Testing -type Fake struct { - q *queue.FakeQueue - b *bus.Fake -} - -// New creates compatibility views backed by one canonical root fake. -// -// Deprecated: use queue.NewFake directly. -// @group Testing -// -// Example: queuefake harness -// -// f := queuefake.New() -// q := f.Queue() -// _ = q.Dispatch(queue.NewJob("emails:send").OnQueue("default")) -// f.AssertDispatched(t, "emails:send") -// f.AssertCount(t, 1) -func New() *Fake { - workflow := bus.NewFake() - return &Fake{ - q: workflow.Queue(), - b: workflow, - } -} - -// Queue returns the queue fake to inject into code under test. -// @group Testing -// -// Example: queue fake -// -// f := queuefake.New() -// q := f.Queue() -// _ = q.Dispatch(queue.NewJob("emails:send").OnQueue("default")) -func (f *Fake) Queue() *queue.FakeQueue { return f.q } - -// Workflow returns the deprecated bus view over the same state as Queue. -// -// Deprecated: call Queue().Chain or Queue().Batch. -// @group Testing -// -// Example: workflow fake -// -// f := queuefake.New() -// wf := f.Workflow() -// _, _ = wf.Chain( -// bus.NewJob("a", nil), -// bus.NewJob("b", nil), -// ).Dispatch(context.Background()) -// f.AssertChained(t, []string{"a", "b"}) -func (f *Fake) Workflow() *bus.Fake { return f.b } - -// Reset clears direct, chain, batch, and workflow-store state atomically from -// every compatibility view. -// @group Testing -// -// Example: reset recorded dispatches -// -// f := queuefake.New() -// q := f.Queue() -// _ = q.Dispatch(queue.NewJob("emails:send")) -// f.Reset() -// f.AssertNothingDispatched(t) -func (f *Fake) Reset() { f.q.Reset() } - -// Records returns a copy of recorded dispatches. -// @group Testing -// -// Example: inspect recorded dispatches -// -// f := queuefake.New() -// _ = f.Queue().Dispatch(queue.NewJob("emails:send")) -// records := f.Records() -// _ = records -func (f *Fake) Records() []queue.DispatchRecord { return f.q.Records() } - -// Count returns the total number of recorded dispatches. -// @group Testing -// -// Example: count total dispatches -// -// f := queuefake.New() -// q := f.Queue() -// _ = q.Dispatch(queue.NewJob("a")) -// _ = q.Dispatch(queue.NewJob("b")) -// _ = f.Count() -func (f *Fake) Count() int { return len(f.q.Records()) } - -// CountJob returns how many times a job type was dispatched. -// @group Testing -// -// Example: count by job type -// -// f := queuefake.New() -// q := f.Queue() -// _ = q.Dispatch(queue.NewJob("emails:send")) -// _ = q.Dispatch(queue.NewJob("emails:send")) -// _ = f.CountJob("emails:send") -func (f *Fake) CountJob(jobType string) int { - var count int - for _, rec := range f.q.Records() { - if rec.Job.Type == jobType { - count++ - } - } - return count -} - -// CountOn returns how many times a job type was dispatched on a queue. -// @group Testing -// -// Example: count by queue and job type -// -// f := queuefake.New() -// q := f.Queue() -// _ = q.Dispatch(queue.NewJob("emails:send").OnQueue("critical")) -// _ = f.CountOn("critical", "emails:send") -func (f *Fake) CountOn(queueName, jobType string) int { - var count int - for _, rec := range f.q.Records() { - if rec.Queue == queueName && rec.Job.Type == jobType { - count++ - } - } - return count -} - -// Workflow assertion wrappers retain source compatibility while reading the -// same direct records as the root queue assertions. - -// AssertNothingWorkflowDispatched fails when any workflow dispatch was recorded. -// @group Testing -// -// Example: assert no workflow dispatches -// -// f := queuefake.New() -// f.AssertNothingWorkflowDispatched(t) -func (f *Fake) AssertNothingWorkflowDispatched(t testing.TB) { - t.Helper() - f.b.AssertNothingDispatched(t) -} - -// AssertWorkflowDispatched fails when jobType was not workflow-dispatched. -// @group Testing -// -// Example: assert workflow dispatch by type -// -// f := queuefake.New() -// _, _ = f.Workflow().Dispatch(nil, bus.NewJob("a", nil)) -// f.AssertWorkflowDispatched(t, "a") -func (f *Fake) AssertWorkflowDispatched(t testing.TB, jobType string) { - t.Helper() - f.b.AssertDispatched(t, jobType) -} - -// AssertWorkflowDispatchedOn fails when jobType was not workflow-dispatched on queueName. -// @group Testing -// -// Example: assert workflow dispatch on queue -// -// f := queuefake.New() -// _, _ = f.Workflow().Dispatch(nil, bus.NewJob("a", nil).OnQueue("critical")) -// f.AssertWorkflowDispatchedOn(t, "critical", "a") -func (f *Fake) AssertWorkflowDispatchedOn(t testing.TB, queueName, jobType string) { - t.Helper() - f.b.AssertDispatchedOn(t, queueName, jobType) -} - -// AssertWorkflowDispatchedTimes fails when workflow dispatch count for jobType does not match expected. -// @group Testing -// -// Example: assert workflow dispatch count -// -// f := queuefake.New() -// wf := f.Workflow() -// _, _ = wf.Dispatch(nil, bus.NewJob("a", nil)) -// _, _ = wf.Dispatch(nil, bus.NewJob("a", nil)) -// f.AssertWorkflowDispatchedTimes(t, "a", 2) -func (f *Fake) AssertWorkflowDispatchedTimes(t testing.TB, jobType string, expected int) { - t.Helper() - f.b.AssertDispatchedTimes(t, jobType, expected) -} - -// AssertWorkflowNotDispatched fails when jobType was workflow-dispatched. -// @group Testing -// -// Example: assert workflow not dispatched -// -// f := queuefake.New() -// f.AssertWorkflowNotDispatched(t, "emails:send") -func (f *Fake) AssertWorkflowNotDispatched(t testing.TB, jobType string) { - t.Helper() - f.b.AssertNotDispatched(t, jobType) -} - -// AssertChained fails if no recorded workflow chain matches expected job type order. -// @group Testing -// -// Example: assert chain sequence -// -// f := queuefake.New() -// _, _ = f.Workflow().Chain(bus.NewJob("a", nil), bus.NewJob("b", nil)).Dispatch(nil) -// f.AssertChained(t, []string{"a", "b"}) -func (f *Fake) AssertChained(t testing.TB, expected []string) { - t.Helper() - f.b.AssertChained(t, expected) -} - -// AssertBatchCount fails if total recorded workflow batch count does not match n. -// @group Testing -// -// Example: assert workflow batch count -// -// f := queuefake.New() -// _, _ = f.Workflow().Batch(bus.NewJob("a", nil)).Dispatch(nil) -// f.AssertBatchCount(t, 1) -func (f *Fake) AssertBatchCount(t testing.TB, n int) { - t.Helper() - f.b.AssertBatchCount(t, n) -} - -// AssertNothingBatched fails if any workflow batch was recorded. -// @group Testing -// -// Example: assert no workflow batches -// -// f := queuefake.New() -// f.AssertNothingBatched(t) -func (f *Fake) AssertNothingBatched(t testing.TB) { - t.Helper() - f.b.AssertNothingBatched(t) -} - -// AssertBatched fails unless at least one recorded workflow batch matches predicate. -// @group Testing -// -// Example: assert batched jobs by predicate -// -// f := queuefake.New() -// _, _ = f.Workflow().Batch(bus.NewJob("a", nil), bus.NewJob("b", nil)).Dispatch(nil) -// f.AssertBatched(t, func(spec bus.BatchSpec) bool { return len(spec.JobTypes) == 2 }) -func (f *Fake) AssertBatched(t testing.TB, predicate func(spec bus.BatchSpec) bool) { - t.Helper() - f.b.AssertBatched(t, predicate) -} - -// AssertNothingDispatched fails when any dispatch was recorded. -// @group Testing -// -// Example: assert no queue dispatches -// -// f := queuefake.New() -// f.AssertNothingDispatched(t) -func (f *Fake) AssertNothingDispatched(t testing.TB) { - t.Helper() - f.q.AssertNothingDispatched(t) -} - -// AssertCount fails when total dispatch count is not expected. -// @group Testing -// -// Example: assert total queue dispatch count -// -// f := queuefake.New() -// q := f.Queue() -// _ = q.Dispatch(queue.NewJob("a")) -// _ = q.Dispatch(queue.NewJob("b")) -// f.AssertCount(t, 2) -func (f *Fake) AssertCount(t testing.TB, expected int) { - t.Helper() - f.q.AssertCount(t, expected) -} - -// AssertDispatched fails when jobType was not dispatched. -// @group Testing -// -// Example: assert queue dispatch by type -// -// f := queuefake.New() -// _ = f.Queue().Dispatch(queue.NewJob("emails:send")) -// f.AssertDispatched(t, "emails:send") -func (f *Fake) AssertDispatched(t testing.TB, jobType string) { - t.Helper() - f.q.AssertDispatched(t, jobType) -} - -// AssertDispatchedOn fails when jobType was not dispatched on queueName. -// @group Testing -// -// Example: assert queue dispatch on queue -// -// f := queuefake.New() -// _ = f.Queue().Dispatch(queue.NewJob("emails:send").OnQueue("critical")) -// f.AssertDispatchedOn(t, "critical", "emails:send") -func (f *Fake) AssertDispatchedOn(t testing.TB, queueName, jobType string) { - t.Helper() - f.q.AssertDispatchedOn(t, queueName, jobType) -} - -// AssertDispatchedTimes fails when jobType dispatch count does not match expected. -// @group Testing -// -// Example: assert queue dispatch count by type -// -// f := queuefake.New() -// q := f.Queue() -// _ = q.Dispatch(queue.NewJob("emails:send")) -// _ = q.Dispatch(queue.NewJob("emails:send")) -// f.AssertDispatchedTimes(t, "emails:send", 2) -func (f *Fake) AssertDispatchedTimes(t testing.TB, jobType string, expected int) { - t.Helper() - f.q.AssertDispatchedTimes(t, jobType, expected) -} - -// AssertNotDispatched fails when jobType was dispatched. -// @group Testing -// -// Example: assert queue type was not dispatched -// -// f := queuefake.New() -// f.AssertNotDispatched(t, "emails:send") -func (f *Fake) AssertNotDispatched(t testing.TB, jobType string) { - t.Helper() - f.q.AssertNotDispatched(t, jobType) -} diff --git a/queuefake/fake_test.go b/queuefake/fake_test.go deleted file mode 100644 index 2e3cdff..0000000 --- a/queuefake/fake_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package queuefake_test - -import ( - "context" - "errors" - "testing" - - "github.com/goforj/queue" - "github.com/goforj/queue/bus" - "github.com/goforj/queue/queuefake" -) - -func TestFakeHarness_QueueAndAssertions(t *testing.T) { - f := queuefake.New() - q := f.Queue() - - f.AssertNothingDispatched(t) - - if err := q.Dispatch(queue.NewJob("emails:send").OnQueue("default")); err != nil { - t.Fatalf("dispatch 1: %v", err) - } - if err := q.Dispatch(queue.NewJob("emails:send").OnQueue("critical")); err != nil { - t.Fatalf("dispatch 2: %v", err) - } - if err := q.Dispatch(queue.NewJob("emails:cleanup").OnQueue("default")); err != nil { - t.Fatalf("dispatch 3: %v", err) - } - - if got := f.Count(); got != 3 { - t.Fatalf("Count()=%d want 3", got) - } - if got := f.CountJob("emails:send"); got != 2 { - t.Fatalf("CountJob(emails:send)=%d want 2", got) - } - if got := f.CountOn("critical", "emails:send"); got != 1 { - t.Fatalf("CountOn(critical, emails:send)=%d want 1", got) - } - - f.AssertCount(t, 3) - f.AssertDispatched(t, "emails:send") - f.AssertDispatchedOn(t, "critical", "emails:send") - f.AssertDispatchedTimes(t, "emails:send", 2) - f.AssertNotDispatched(t, "emails:archive") - - if got := len(f.Records()); got != 3 { - t.Fatalf("Records len=%d want 3", got) - } - - f.Reset() - f.AssertNothingDispatched(t) -} - -func TestFakeHarness_WorkflowAssertions(t *testing.T) { - f := queuefake.New() - wf := f.Workflow() - - f.AssertNothingWorkflowDispatched(t) - f.AssertNothingBatched(t) - - _, _ = wf.Dispatch(context.Background(), bus.NewJob("reports:generate", nil).OnQueue("critical")) - _, _ = wf.Dispatch(context.Background(), bus.NewJob("reports:generate", nil)) - _, _ = wf.Chain( - bus.NewJob("a", nil), - bus.NewJob("b", nil), - ).Dispatch(context.Background()) - _, _ = wf.Batch( - bus.NewJob("x", nil), - bus.NewJob("y", nil), - ).Dispatch(context.Background()) - - f.AssertWorkflowDispatched(t, "reports:generate") - f.AssertWorkflowDispatchedOn(t, "critical", "reports:generate") - f.AssertWorkflowDispatchedTimes(t, "reports:generate", 2) - f.AssertWorkflowNotDispatched(t, "reports:archive") - f.AssertChained(t, []string{"a", "b"}) - f.AssertBatchCount(t, 1) - f.AssertBatched(t, func(spec bus.BatchSpec) bool { - return len(spec.JobTypes) == 2 && spec.JobTypes[0] == "x" - }) -} - -// TestFakeHarness_CompatibilityViewsShareState verifies queuefake no longer -// owns independent queue and workflow recorder models. -func TestFakeHarness_CompatibilityViewsShareState(t *testing.T) { - fake := queuefake.New() - if fake.Queue() != fake.Workflow().Queue() { - t.Fatal("Queue and Workflow returned different canonical fakes") - } - if err := fake.Queue().Dispatch(queue.NewJob("queue:direct").OnQueue("root")); err != nil { - t.Fatalf("queue dispatch: %v", err) - } - fake.AssertWorkflowDispatched(t, "queue:direct") - if _, err := fake.Workflow().Dispatch(context.Background(), bus.NewJob("workflow:direct", nil).OnQueue("legacy")); err != nil { - t.Fatalf("workflow dispatch: %v", err) - } - fake.AssertDispatched(t, "workflow:direct") - - chainID, err := fake.Queue().Chain(queue.NewJob("chain:shared")).Dispatch(context.Background()) - if err != nil { - t.Fatalf("chain dispatch: %v", err) - } - batchID, err := fake.Workflow().Batch(bus.NewJob("batch:shared", nil)).Dispatch(context.Background()) - if err != nil { - t.Fatalf("batch dispatch: %v", err) - } - fake.AssertChained(t, []string{"chain:shared"}) - fake.AssertBatchCount(t, 1) - - fake.Reset() - fake.AssertNothingDispatched(t) - fake.AssertNothingWorkflowDispatched(t) - fake.AssertNothingBatched(t) - if len(fake.Queue().ChainRecords()) != 0 { - t.Fatal("Reset retained chain records") - } - if _, err := fake.Queue().FindChain(context.Background(), chainID); !errors.Is(err, queue.ErrWorkflowNotFound) { - t.Fatalf("FindChain after Reset error = %v", err) - } - if _, err := fake.Workflow().FindBatch(context.Background(), batchID); !errors.Is(err, bus.ErrNotFound) { - t.Fatalf("FindBatch after Reset error = %v", err) - } -} diff --git a/runtime.go b/runtime.go index f7e5cfb..9136b83 100644 --- a/runtime.go +++ b/runtime.go @@ -10,30 +10,6 @@ import ( "github.com/goforj/queue/internal/workflow" ) -// WorkflowEventKind identifies high-level workflow runtime lifecycle events. -// -// Deprecated: use EventKind. Delivery and workflow facts now share one event model. -// @group Queue -type WorkflowEventKind = EventKind - -// WorkflowEvent is emitted by the high-level workflow runtime observer hooks. -// -// Deprecated: use Event. Delivery and workflow facts now share one event model. -// @group Queue -type WorkflowEvent = Event - -// WorkflowObserver receives high-level workflow runtime events. -// -// Deprecated: use Observer. A single observer now receives every event layer. -// @group Queue -type WorkflowObserver = Observer - -// WorkflowObserverFunc adapts a function to a workflow observer. -// -// Deprecated: use ObserverFunc. A single observer now receives every event layer. -// @group Queue -type WorkflowObserverFunc = ObserverFunc - // Permanent marks an error as terminal so workers do not spend the remaining application retry budget on it. // @group Queue func Permanent(err error) error { diff --git a/runtime_hook.go b/runtime_hook.go index f5f45e2..35b1757 100644 --- a/runtime_hook.go +++ b/runtime_hook.go @@ -12,11 +12,15 @@ func init() { runtimehook.ExtractRuntimeFromQueue = extractRuntimeFromQueueHook } -func buildQueueFromDriverHook(cfgv any, backendv any, workerFactoryv runtimehook.WorkerFactory, optsv []any) (any, error) { +func buildQueueFromDriverHook(cfgv any, observerv any, backendv any, workerFactoryv runtimehook.WorkerFactory, optsv []any) (any, error) { cfg, ok := cfgv.(Config) if !ok { return nil, fmt.Errorf("invalid queue config type %T", cfgv) } + observer, ok := observerv.(Observer) + if !ok && observerv != nil { + return nil, fmt.Errorf("invalid queue observer type %T", observerv) + } backend, ok := backendv.(driverQueueBackend) if !ok { return nil, fmt.Errorf("invalid driver backend type %T", backendv) @@ -46,7 +50,7 @@ func buildQueueFromDriverHook(cfgv any, backendv any, workerFactoryv runtimehook opts = append(opts, opt) } - raw, err := newQueueFromDriver(cfg, backend, workerFactory) + raw, err := newQueueFromDriver(cfg, observer, backend, workerFactory) if err != nil { return nil, err } diff --git a/runtime_test.go b/runtime_test.go index 5fc7b26..135f62c 100644 --- a/runtime_test.go +++ b/runtime_test.go @@ -146,7 +146,7 @@ func TestNew_WithObserver(t *testing.T) { var observed atomic.Int32 rt, err := New( Config{Driver: DriverSync}, - WithObserver(WorkflowObserverFunc(func(context.Context, WorkflowEvent) { + WithObserver(ObserverFunc(func(context.Context, Event) { observed.Add(1) })), ) @@ -391,7 +391,7 @@ func TestNew_WithStoreClockMiddlewareAndPrune(t *testing.T) { Config{Driver: DriverSync}, WithStore(NewMemoryStore()), WithClock(func() time.Time { return fixedNow }), - WithObserver(WorkflowObserverFunc(func(context.Context, WorkflowEvent) { observed.Add(1) })), + WithObserver(ObserverFunc(func(context.Context, Event) { observed.Add(1) })), WithMiddleware(MiddlewareFunc(func(ctx context.Context, m Message, next Next) error { mwCalls.Add(1) return next(ctx, m) diff --git a/test-plan.md b/test-plan.md index c38a4ad..2d96cf5 100644 --- a/test-plan.md +++ b/test-plan.md @@ -324,7 +324,7 @@ This is a trust-critical area. Users will assume high-level workflow helpers enc ### Guarantees enforced today -- `queue.NewFake` is the only fake state owner; deprecated `bus.Fake` and `queuefake.Fake` are compatibility views over it. +- `queue.NewFake` is the only fake state owner for direct and workflow assertions. - Direct dispatch uses the same typed-value conversion and `Job` validation as production runtimes. - Chain and batch builders use the production workflow engine, record only from `Dispatch`, retain queue/name/failure policy, and expose isolated canonical records plus lookup state. - Closure callbacks remain fluent compatibility inputs but are not retained in fake runtime state or executed by the recording fake. @@ -334,8 +334,7 @@ This is a trust-critical area. Users will assume high-level workflow helpers enc ### Compatibility coverage -- `queue.NewFake() *queue.FakeQueue`, `queuefake.Fake.Queue() *queue.FakeQueue`, `queuefake.Fake.Workflow() *bus.Fake`, and `bus.NewFake() *bus.Fake` retain their established signatures. -- `bus.Fake` and the one-field `bus.BatchSpec` retain their physical package identity and keyed/unkeyed source forms. +- `queue.NewFake() *queue.FakeQueue` is the supported fake constructor and API. - Legacy bus builders retain shallow job snapshots and Dispatch-time payload JSON encoding before entering the canonical fake. ## Test Types We Should Add or Expand (Prioritized) diff --git a/unified_observer_test.go b/unified_observer_test.go index eb27062..194737a 100644 --- a/unified_observer_test.go +++ b/unified_observer_test.go @@ -51,49 +51,17 @@ func TestWithObserverReceivesEveryEventLayer(t *testing.T) { } } -// TestConfigObserverReceivesWorkflowEvents preserves the compatibility configuration path during migration. -func TestConfigObserverReceivesWorkflowEvents(t *testing.T) { - var events []Event - q, err := New(Config{ - Driver: DriverSync, - Observer: ObserverFunc(func(_ context.Context, event Event) { - events = append(events, event) - }), - }) - if err != nil { - t.Fatalf("new sync queue: %v", err) - } - q.Register("reports:build", func(context.Context, Message) error { return nil }) - if err := q.StartWorkers(context.Background()); err != nil { - t.Fatalf("start workers: %v", err) - } - t.Cleanup(func() { - if err := q.Shutdown(context.Background()); err != nil { - t.Errorf("shutdown: %v", err) - } - }) - - if _, err := q.Dispatch(NewJob("reports:build").OnQueue("default")); err != nil { - t.Fatalf("dispatch: %v", err) - } - if _, ok := findEvent(events, EventJobSucceeded); !ok { - t.Fatalf("config observer did not receive workflow events: %+v", events) - } -} - -// TestConfigAndOptionObserversShareOneEventIdentity prevents nested wrappers from describing one fact twice. -func TestConfigAndOptionObserversShareOneEventIdentity(t *testing.T) { - var configEvents []Event - var optionEvents []Event +// TestMultipleOptionObserversShareOneEventIdentity prevents nested wrappers from describing one fact twice. +func TestMultipleOptionObserversShareOneEventIdentity(t *testing.T) { + var firstEvents []Event + var secondEvents []Event q, err := New( - Config{ - Driver: DriverSync, - Observer: ObserverFunc(func(_ context.Context, event Event) { - configEvents = append(configEvents, event) - }), - }, + Config{Driver: DriverSync}, + WithObserver(ObserverFunc(func(_ context.Context, event Event) { + firstEvents = append(firstEvents, event) + })), WithObserver(ObserverFunc(func(_ context.Context, event Event) { - optionEvents = append(optionEvents, event) + secondEvents = append(secondEvents, event) })), ) if err != nil { @@ -113,13 +81,13 @@ func TestConfigAndOptionObserversShareOneEventIdentity(t *testing.T) { t.Fatalf("dispatch: %v", err) } for _, kind := range []EventKind{EventEnqueueAccepted, EventProcessStarted, EventJobSucceeded} { - configMatches := eventsOfKind(configEvents, kind) - optionMatches := eventsOfKind(optionEvents, kind) - if len(configMatches) != 1 || len(optionMatches) != 1 { - t.Fatalf("event %q counts = config:%d option:%d, want 1/1", kind, len(configMatches), len(optionMatches)) + firstMatches := eventsOfKind(firstEvents, kind) + secondMatches := eventsOfKind(secondEvents, kind) + if len(firstMatches) != 1 || len(secondMatches) != 1 { + t.Fatalf("event %q counts = first:%d second:%d, want 1/1", kind, len(firstMatches), len(secondMatches)) } - if configMatches[0].EventID != optionMatches[0].EventID || !configMatches[0].Time.Equal(optionMatches[0].Time) { - t.Fatalf("event %q identity differs: config=%+v option=%+v", kind, configMatches[0], optionMatches[0]) + if firstMatches[0].EventID != secondMatches[0].EventID || !firstMatches[0].Time.Equal(secondMatches[0].Time) { + t.Fatalf("event %q identity differs: first=%+v second=%+v", kind, firstMatches[0], secondMatches[0]) } } } From 3a1ebe933ff71d0ebb8fc3401105e070e2df4139 Mon Sep 17 00:00:00 2001 From: Chris Miles Date: Mon, 20 Jul 2026 08:43:10 +0000 Subject: [PATCH 2/4] ci: remove retired bus coverage fixture --- scripts/coverage-codecov.sh | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/scripts/coverage-codecov.sh b/scripts/coverage-codecov.sh index d9fdf16..c2b281c 100755 --- a/scripts/coverage-codecov.sh +++ b/scripts/coverage-codecov.sh @@ -175,17 +175,6 @@ collect_unit() { ) raw_profiles+=("$raw_profile") - if [[ "$relative_dir" == "." ]]; then - local tagged_profile="$TMP_ROOT/unit-root-integration-tag.out" - echo "==> unit coverage: root integration-tagged bus fixtures" - ( - cd "$module_dir" - GOWORK=off GOCACHE="$GOCACHE_DIR" GOMODCACHE="$GOMODCACHE_DIR" \ - go test -count=1 -tags=integration -covermode=atomic -coverpkg=./... \ - -coverprofile="$tagged_profile" ./bus - ) - raw_profiles+=("$tagged_profile") - fi printf '%s\t%s\n' "$relative_dir" "$module_path" >>"$manifest_tmp" done < <( find "$ROOT_DIR" -type f -name go.mod \ From fcec336dc018bdfe6af74bad2221d48b032fd377 Mon Sep 17 00:00:00 2001 From: Chris Miles Date: Mon, 20 Jul 2026 08:46:53 +0000 Subject: [PATCH 3/4] ci: remove legacy coverage assertion --- .github/scripts/check_codecov_coverage.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/scripts/check_codecov_coverage.sh b/.github/scripts/check_codecov_coverage.sh index c374027..cd56cc2 100755 --- a/.github/scripts/check_codecov_coverage.sh +++ b/.github/scripts/check_codecov_coverage.sh @@ -123,7 +123,6 @@ require_path "$unit_profile" "^${ROOT_MODULE//./[.]}/integration/.*[.]go:" "inte require_path "$unit_profile" "^${ROOT_MODULE//./[.]}/docs/readme/testcounts/.*[.]go:" "tagged documentation tooling source" require_covered_path "^${ROOT_MODULE//./[.]}/queue[.]go:" "representative root source" "${expected_profiles[@]}" -require_covered_path "^${ROOT_MODULE//./[.]}/bus/testhooks_integration[.]go:" "root integration-tagged bus fixture" "$unit_profile" require_covered_function "$unit_profile" "docs/readme/testcounts/main.go" "loadIntegrationCountManifest" "generated test-count evidence validation" for driver in "${driver_modules[@]}"; do require_covered_path "^${ROOT_MODULE//./[.]}/driver/$driver/.*[.]go:" "driver/$driver source" "${expected_profiles[@]}" From 700ed2f12d335a9a9938ccc57122b04ca26b25d4 Mon Sep 17 00:00:00 2001 From: Chris Miles Date: Mon, 20 Jul 2026 08:57:06 +0000 Subject: [PATCH 4/4] test: cover driver runtime hook validation --- README.md | 2 +- runtime_hook.go | 5 +- runtime_hook_test.go | 175 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 runtime_hook_test.go diff --git a/README.md b/README.md index 3743e82..74cda18 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Latest tag - Unit tests (executed count) + Unit tests (executed count) Integration tests (executed count)

diff --git a/runtime_hook.go b/runtime_hook.go index 35b1757..8667cbd 100644 --- a/runtime_hook.go +++ b/runtime_hook.go @@ -30,9 +30,12 @@ func buildQueueFromDriverHook(cfgv any, observerv any, backendv any, workerFacto if workerFactoryv != nil { workerFactory = func(workers int) (driverWorkerBackend, error) { v, err := workerFactoryv(workers) - if err != nil || v == nil { + if err != nil { return nil, err } + if v == nil { + return nil, fmt.Errorf("driver worker factory returned nil") + } w, ok := v.(driverWorkerBackend) if !ok { return nil, fmt.Errorf("invalid worker backend type %T", v) diff --git a/runtime_hook_test.go b/runtime_hook_test.go new file mode 100644 index 0000000..c8f6c93 --- /dev/null +++ b/runtime_hook_test.go @@ -0,0 +1,175 @@ +package queue + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/goforj/queue/internal/runtimehook" +) + +type runtimeHookWorkerStub struct{} + +// Register satisfies the driver worker boundary for bridge tests. +func (runtimeHookWorkerStub) Register(string, Handler) {} + +// StartWorkers satisfies the driver worker boundary for bridge tests. +func (runtimeHookWorkerStub) StartWorkers(context.Context) error { return nil } + +// Shutdown satisfies the driver worker boundary for bridge tests. +func (runtimeHookWorkerStub) Shutdown(context.Context) error { return nil } + +// TestBuildQueueFromDriverHookRejectsInvalidBridgeValues verifies the internal +// bridge fails closed when driver packages provide values outside its contract. +func TestBuildQueueFromDriverHookRejectsInvalidBridgeValues(t *testing.T) { + backend := &driverQueueBackendStub{driver: DriverNull} + validConfig := Config{Driver: DriverNull} + + tests := []struct { + name string + config any + observer any + backend any + workerFactory runtimehook.WorkerFactory + opts []any + want string + }{ + { + name: "config", + config: "not a config", + backend: backend, + want: "invalid queue config type", + }, + { + name: "observer", + config: validConfig, + observer: "not an observer", + backend: backend, + want: "invalid queue observer type", + }, + { + name: "backend", + config: validConfig, + backend: "not a backend", + want: "invalid driver backend type", + }, + { + name: "option", + config: validConfig, + backend: backend, + opts: []any{"not an option"}, + want: "invalid queue option type", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := buildQueueFromDriverHook(test.config, test.observer, test.backend, test.workerFactory, test.opts) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("buildQueueFromDriverHook() error = %v, want %q", err, test.want) + } + }) + } +} + +// TestBuildQueueFromDriverHookBuildsAndExtractsRuntime verifies the bridge's +// successful path preserves the constructed runtime identity. +func TestBuildQueueFromDriverHookBuildsAndExtractsRuntime(t *testing.T) { + raw, err := buildQueueFromDriverHook( + Config{Driver: DriverNull}, + nil, + &driverQueueBackendStub{driver: DriverNull}, + func(int) (any, error) { return runtimeHookWorkerStub{}, nil }, + []any{WithWorkers(1)}, + ) + if err != nil { + t.Fatalf("buildQueueFromDriverHook() error = %v", err) + } + q, ok := raw.(*Queue) + if !ok { + t.Fatalf("buildQueueFromDriverHook() type = %T, want *Queue", raw) + } + runtime, err := extractRuntimeFromQueueHook(q) + if err != nil { + t.Fatalf("extractRuntimeFromQueueHook() error = %v", err) + } + if runtime != q.q { + t.Fatal("extractRuntimeFromQueueHook() returned a different runtime") + } + if err := q.StartWorkers(t.Context()); err != nil { + t.Fatalf("StartWorkers() error = %v", err) + } + if err := q.Shutdown(t.Context()); err != nil { + t.Fatalf("Shutdown() error = %v", err) + } +} + +// TestBuildQueueFromDriverHookRejectsInvalidWorkerFactoryResults verifies the +// deferred factory boundary rejects absent and incompatible worker backends. +func TestBuildQueueFromDriverHookRejectsInvalidWorkerFactoryResults(t *testing.T) { + tests := []struct { + name string + factory runtimehook.WorkerFactory + want string + }{ + { + name: "nil worker backend", + factory: func(int) (any, error) { + return nil, nil + }, + want: "driver worker factory returned nil", + }, + { + name: "wrong worker backend type", + factory: func(int) (any, error) { + return "not a worker backend", nil + }, + want: "invalid worker backend type", + }, + { + name: "worker factory error", + factory: func(int) (any, error) { + return nil, errors.New("worker construction failed") + }, + want: "worker construction failed", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + raw, err := buildQueueFromDriverHook(Config{Driver: DriverNull}, nil, &driverQueueBackendStub{driver: DriverNull}, test.factory, nil) + if err != nil { + t.Fatalf("buildQueueFromDriverHook() error = %v", err) + } + q := raw.(*Queue) + err = q.StartWorkers(t.Context()) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("StartWorkers() error = %v, want %q", err, test.want) + } + }) + } +} + +// TestExtractRuntimeFromQueueHookRejectsInvalidValues verifies only initialized +// public queues can cross the internal test bridge. +func TestExtractRuntimeFromQueueHookRejectsInvalidValues(t *testing.T) { + tests := []struct { + name string + value any + want string + }{ + {name: "wrong type", value: "not a queue", want: "invalid queue type"}, + {name: "nil queue", value: (*Queue)(nil), want: "invalid queue type"}, + {name: "nil runtime", value: &Queue{}, want: "queue runtime is nil"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := extractRuntimeFromQueueHook(test.value) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("extractRuntimeFromQueueHook() error = %v, want %q", err, test.want) + } + }) + } +}