Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 33 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<img src="https://img.shields.io/github/v/tag/goforj/queue?label=version&sort=semver" alt="Latest tag">
<a href="https://codecov.io/gh/goforj/queue"><img src="https://codecov.io/gh/goforj/queue/graph/badge.svg?token=40Z5UQATME"/></a>
<!-- test-count:embed:start -->
<img src="https://img.shields.io/badge/unit_tests-970-brightgreen" alt="Unit tests (executed count)">
<img src="https://img.shields.io/badge/unit_tests-978-brightgreen" alt="Unit tests (executed count)">
<img src="https://img.shields.io/badge/integration_tests-631-blue" alt="Integration tests (executed count)">
<!-- test-count:embed:end -->
</p>
Expand Down Expand Up @@ -471,38 +471,43 @@ _ = q

| Layer | EventKind | Meaning |
| ---: | --- | --- |
| **queue** | dispatch_started | Public dispatch began. |
| **queue** | dispatch_succeeded | Backend acceptance completed; synchronous execution may still return an application error. |
| **queue** | dispatch_failed | Public dispatch failed before backend acceptance. |
| **queue** | enqueue_accepted | Job accepted by driver for enqueue. |
| **queue** | enqueue_rejected | Job enqueue failed. |
| **queue** | enqueue_duplicate | Duplicate job rejected due to uniqueness key. |
| **queue** | enqueue_canceled | Context cancellation prevented enqueue. |
| **worker** | process_started | Worker began processing job. |
| **worker** | process_succeeded | Handler returned success. |
| **worker** | process_failed | Handler returned an error or panicked. |
| **queue** | dispatch_started | After job validation, the root facade began a public dispatch. |
| **queue** | dispatch_succeeded | The backend accepted the public dispatch; synchronous execution may still return an application error. |
| **queue** | dispatch_failed | Public dispatch ended before backend acceptance. |
| **queue** | enqueue_accepted | The driver confirmed enqueue acceptance. |
| **queue** | enqueue_rejected | The driver rejected enqueue with an error. |
| **queue** | enqueue_duplicate | Uniqueness policy rejected the logical job as a duplicate. |
| **queue** | enqueue_canceled | Context cancellation or deadline expiry prevented enqueue. |
| **worker** | process_started | A physical handler attempt began. |
| **worker** | process_succeeded | Handler success crossed the driver's settlement boundary; runtimes without a settlement hook emit after handler return. |
| **worker** | process_failed | A handler attempt returned an error or panicked. |
| **worker** | process_retried | A numbered application retry attempt began; infrastructure redelivery may repeat the fact. |
| **worker** | process_archived | Driver confirmed terminal settlement; unsupported paths omit this fact. |
| **queue** | queue_paused | Queue was paused (driver supports pause). |
| **queue** | queue_resumed | Queue was resumed. |
| **workflow** | job_started | A workflow job handler started execution. |
| **workflow** | job_succeeded | A workflow job handler completed successfully. |
| **worker** | process_archived | A SQL driver confirmed its fenced transition to terminal `dead` state; other built-in runtimes omit it. |
| **worker** | process_recovered | SQL bulk recovery requeued one stale in-flight claim; identity fields are unavailable at that boundary. |
| **worker** | republish_failed | An internal delay or retry replacement could not be published. |
| **worker** | settlement_failed | Delivery finalization, acknowledgement, deletion, or negative settlement failed or was ambiguous; redelivery remains possible. |
| **queue** | queue_paused | A supporting driver confirmed queue consumption was paused. |
| **queue** | queue_resumed | A supporting driver confirmed queue consumption was resumed. |
| **workflow** | job_started | A logical execution attempt began before handler lookup. |
| **workflow** | job_succeeded | Logical job success committed; settlement-aware drivers publish the fact only after settlement. |
| **workflow** | job_failed | A logical job reached permanent or exhausted failure. |
| **workflow** | chain_started | A chain workflow was created and started. |
| **workflow** | chain_advanced | Chain progressed from one node to the next node. |
| **workflow** | chain_completed | Chain reached terminal success. |
| **workflow** | chain_failed | Chain reached terminal failure. |
| **workflow** | batch_started | A batch workflow was created and started. |
| **workflow** | batch_progressed | Batch state changed as jobs completed/failed. |
| **workflow** | chain_started | A chain record was created and initial dispatch began. |
| **workflow** | chain_advanced | A committed node outcome advanced the chain to its next node. |
| **workflow** | chain_completed | The final chain node committed terminal success. |
| **workflow** | chain_failed | A chain committed terminal failure. |
| **workflow** | batch_started | A batch record was created and initial member dispatch began. |
| **workflow** | batch_progressed | A batch member committed a terminal outcome. |
| **workflow** | batch_completed | Batch reached terminal success (or allowed-failure completion). |
| **workflow** | batch_failed | Batch reached terminal failure. |
| **workflow** | batch_cancelled | Batch was cancelled before normal completion. |
| **workflow** | callback_started | Chain/batch callback execution started. |
| **workflow** | callback_succeeded | Chain/batch callback completed successfully. |
| **workflow** | callback_failed | Chain/batch callback returned an error. |
| **workflow** | batch_failed | A non-allowed member failure or initial member dispatch rejection committed terminal batch failure. |
| **workflow** | batch_cancelled | Remaining batch work was cancelled after terminal failure. |
| **workflow** | callback_started | A claimed terminal Catch, Then, or Finally callback began execution. |
| **workflow** | callback_succeeded | Terminal callback success crossed the applicable settlement boundary. |
| **workflow** | callback_failed | A terminal callback was invalid or unavailable, returned an error, or panicked. |

Handler panics now emit `process_failed` before the original panic value is rethrown. This adds truthful failure telemetry without changing backend panic recovery or retry behavior.

Callback lifecycle facts cover terminal Catch, Then, and Finally callbacks. Inline `Batch.Progress` closures do not emit `callback_*` facts.

## Examples

Runnable examples live in the separate `examples` module ([`./examples`](./examples)).
Expand Down Expand Up @@ -1131,7 +1136,7 @@ fmt.Println(snapshot.Paused("default"))

#### <a id="queue-statssnapshot-paused"></a>Paused

Paused returns paused count for a queue.
Paused returns the observed pause state for a queue as zero or one.

```go
collector := queue.NewStatsCollector()
Expand Down
102 changes: 102 additions & 0 deletions bus/observer_adapter_contract_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
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)
}
}
}
2 changes: 1 addition & 1 deletion docs/bus-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> **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 `plan.md` for both.
> implementation roadmap; use [`docs/plan.md`](./plan.md) for both.

## Current Ownership

Expand Down
2 changes: 1 addition & 1 deletion docs/bus-implementation-checklist.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Bus Implementation Checklist

> **Status:** Historical checklist for the original independent `bus`
> implementation. `plan.md` is the current source of truth. The engine now lives
> 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.

Expand Down
53 changes: 40 additions & 13 deletions docs/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ This document defines the root application facade's unified observability contra

Queue dispatch lifecycle:

- `EventDispatchStarted`: public dispatch began.
- `EventDispatchStarted`: a public dispatch began after job validation. Jobs rejected during validation emit no dispatch lifecycle facts.
- `EventDispatchSucceeded`: public dispatch crossed the backend acceptance boundary. A synchronous handler can still return an application error after this fact.
- `EventDispatchFailed`: public dispatch failed before acceptance.
- `EventDispatchFailed`: a validated public dispatch failed before acceptance.
- `EventEnqueueAccepted`: job accepted for dispatch.
- `EventEnqueueRejected`: dispatch failed with error.
- `EventEnqueueDuplicate`: dispatch rejected as duplicate (`UniqueFor`).
Expand All @@ -26,21 +26,35 @@ Processing lifecycle:
- `EventProcessSucceeded`: handler attempt succeeded. SQL, SQS, and RabbitMQ emit this only after durable row finalization, deletion, or acknowledgement respectively; backends without a post-handler settlement hook retain their documented weaker boundary.
- `EventProcessFailed`: handler attempt returned an error or panicked. A panic is reported before the original panic value is rethrown so backend recovery and retry semantics remain unchanged.
- `EventProcessRetried`: processing began for a numbered application retry attempt. Infrastructure redelivery of that same attempt may repeat the fact.
- `EventProcessArchived`: the driver confirmed terminal settlement for a failed attempt.
- `EventProcessArchived`: a driver confirmed terminal settlement of a failed attempt. Built-in support is listed below; unsupported drivers omit it rather than predicting a later backend transition.
- `EventProcessRecovered`: SQL bulk recovery requeued one stale in-flight claim. The driver emits one countable fact per affected row, but the bulk update does not load identity or correlation fields.
- `EventRepublishFailed`: an internal delay or retry replacement could not be published.
- `EventSettlementFailed`: durable SQL finalization, broker acknowledgement, or broker deletion failed after handler or replacement work completed, so redelivery remains possible.
- `EventSettlementFailed`: delivery finalization, acknowledgement, deletion, or negative settlement failed or was ambiguous, so redelivery remains possible. This includes malformed and unregistered deliveries that fail settlement before handler execution.

Queue control lifecycle:

- `EventQueuePaused`: queue consumption paused.
- `EventQueueResumed`: queue consumption resumed.
- `EventQueuePaused`: a supporting driver confirmed that queue consumption was paused.
- `EventQueueResumed`: a supporting driver confirmed that queue consumption was resumed.

Workflow lifecycle:

- `EventJobStarted`, `EventJobSucceeded`, `EventJobFailed`
- `EventChainStarted`, `EventChainAdvanced`, `EventChainCompleted`, `EventChainFailed`
- `EventBatchStarted`, `EventBatchProgressed`, `EventBatchCompleted`, `EventBatchFailed`, `EventBatchCancelled`
- `EventCallbackStarted`, `EventCallbackSucceeded`, `EventCallbackFailed`
- `EventJobStarted`: a logical execution attempt began after envelope validation and before handler lookup. A missing handler therefore still has a started fact.
- `EventJobSucceeded`: logical job success committed. Settlement-aware drivers defer the fact until physical settlement succeeds.
- `EventJobFailed`: a logical job reached permanent or exhausted failure.
- `EventChainStarted`: a chain record was created and its initial dispatch began.
- `EventChainAdvanced`: a committed node outcome advanced the chain to its next node.
- `EventChainCompleted`: the final chain node committed terminal success.
- `EventChainFailed`: a chain committed terminal failure.
- `EventBatchStarted`: a batch record was created and its initial member dispatch began.
- `EventBatchProgressed`: a batch member committed a terminal outcome.
- `EventBatchCompleted`: the batch reached terminal success, including completion with allowed member failures.
- `EventBatchFailed`: an initial member dispatch rejection or a non-allowed member failure committed terminal batch failure.
- `EventBatchCancelled`: remaining batch work was cancelled after terminal failure.
- `EventCallbackStarted`: a claimed terminal Catch, Then, or Finally callback began execution after state validation.
- `EventCallbackSucceeded`: a terminal callback completed successfully across the applicable settlement boundary.
- `EventCallbackFailed`: a terminal callback was invalid or unavailable, returned an error, or panicked.

Retryable callback store failures remain uncommitted and emit no terminal callback fact. Inline `Batch.Progress` closures do not emit `callback_*` facts.

Positive job, chain, batch, and callback facts use the same SQL/SQS/RabbitMQ settlement boundary as `EventProcessSucceeded`. The SQL queue gives every processing claim an opaque generation ID. Same-attempt infrastructure redelivery normally retains inherited unsettled-generation provenance. When the current generation commits a receipt-backed workflow transition before later infrastructure work requests redelivery, the workflow engine marks application state committed and SQL retains that current generation instead. The signal selects the truthful receipt owner; it does not commit deferred facts or prove observer delivery. An application retry increments the attempt and clears the link, while recovery flags, aggregate state, and application error text do not supply equivalent authority.

Expand Down Expand Up @@ -76,18 +90,20 @@ Processing events additionally include:
- `MaxRetry`
- `Duration` (for `Succeeded` and `Failed`)

Failure/cancel/reject events additionally include:
Failure, rejection, and enqueue-cancellation events additionally include:

- `Err`

`EventBatchCancelled` omits `Err`; the adjacent `EventBatchFailed` fact carries the terminal cause. `EventProcessRecovered` is intentionally identity-free because SQL proves recovery with one fenced bulk update rather than a pre-update row read that could race ownership.

Every layer includes the applicable `DispatchID`, `JobID`, `ChainID`, and `BatchID` correlation fields when the delivery carries supported metadata. Queue and worker facts read the versioned direct-driver sidecar or decode a retained workflow envelope, so they can be joined to workflow facts without inspecting payloads in application observers.

## Semantics and guarantees

- Events are per-attempt, not aggregated.
- Physical processing and logical job-execution events are per-attempt. Dispatch and queue-control facts are operation-scoped, while chain and batch facts describe aggregate transitions.
- Dispatch, enqueue, and queue-control events use `EventLayerQueue`; physical attempt events use `EventLayerWorker`; logical job, chain, batch, and callback transitions use `EventLayerWorkflow`.
- `EventProcessRetried` is emitted when processing begins with `Attempt > 0`. It is intentionally not emitted merely because a handler returned an error, and consumers must tolerate a repeated fact when infrastructure redelivers the same numbered attempt.
- `EventProcessArchived` is reserved for a driver-confirmed terminal settlement; drivers that cannot yet confirm that boundary omit it rather than emitting a prediction.
- `EventProcessArchived` is reserved for a driver-confirmed terminal settlement. Drivers that cannot confirm that boundary omit it rather than emitting a prediction.
- `JobKey` is a deterministic hash of the logical job type and payload. Volatile dispatch/workflow IDs are excluded, and the value is not guaranteed globally unique.
- Correlated recoverable job successes and emitted positive chain or batch transition facts use a deterministic `EventID` for the same logical fact across settlement recovery. Failure EventIDs remain occurrence-based. Deterministic identity supports deduplication; it does not prove that an observer received the fact or make every event exactly-once.
- `Queue` is the effective physical backend name carried by the dispatch. With a namespaced default such as `billing_default`, an explicit logical queue such as `critical` is reported as `billing_critical`. Jobs that omit a queue continue to report `default`; changing how `Config.DefaultQueue` routes empty targets is a separate targeting decision. Correlated queue, worker, workflow, aggregate, and callback facts always report the same name.
Expand All @@ -106,6 +122,17 @@ Driver-specific capabilities:
- Pause/resume control: currently supported by Sync, Workerpool, Redis.
- Other drivers still emit collector-based events when `Observer` is configured.

Built-in `EventProcessArchived` support:

| Runtime | Emits | Confirmed boundary |
| --- | --- | --- |
| SQL (SQLite, MySQL, PostgreSQL) | Yes | The fenced transition to `dead` committed. |
| SQS | No | `DeleteMessage` success cannot prove the receipt was still current or prevent rare standard-queue redelivery. |
| RabbitMQ | No | Consumer acknowledgement is one-way; channel loss can requeue a delivery before the broker processes it. |
| Redis | No | Asynq performs archival after the handler returns, outside the observer's confirmed boundary. |
| NATS | No | Core NATS has no durable terminal archive settlement to confirm. |
| Sync, Workerpool, Null | No | These runtimes expose no durable archive boundary. |

## Observer behavior contract

- Observers are best-effort telemetry hooks only; they must not control queue execution or implement workflow continuations.
Expand Down
Loading
Loading