legion-transport 1.4.30 → 1.4.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +5 -2
- data/.gitignore +1 -0
- data/.rubocop.yml +3 -0
- data/CHANGELOG.md +48 -0
- data/Gemfile +11 -0
- data/README.md +168 -7
- data/legion-transport.gemspec +2 -1
- data/lib/legion/transport/connection/recovery.rb +241 -0
- data/lib/legion/transport/connection/ssl.rb +3 -21
- data/lib/legion/transport/connection/vault.rb +28 -43
- data/lib/legion/transport/connection.rb +345 -257
- data/lib/legion/transport/errors.rb +11 -0
- data/lib/legion/transport/exchange.rb +61 -30
- data/lib/legion/transport/helper.rb +44 -6
- data/lib/legion/transport/helpers/pool.rb +117 -48
- data/lib/legion/transport/in_process.rb +135 -50
- data/lib/legion/transport/local.rb +277 -18
- data/lib/legion/transport/message.rb +115 -150
- data/lib/legion/transport/messages/check_subtask.rb +3 -1
- data/lib/legion/transport/messages/direct.rb +2 -2
- data/lib/legion/transport/messages/dynamic.rb +12 -4
- data/lib/legion/transport/messages/region_re_route.rb +1 -1
- data/lib/legion/transport/messages/subtask.rb +10 -13
- data/lib/legion/transport/messages/task.rb +8 -10
- data/lib/legion/transport/messages/task_update.rb +4 -9
- data/lib/legion/transport/publisher.rb +128 -0
- data/lib/legion/transport/queue.rb +65 -58
- data/lib/legion/transport/queues/region_outbound.rb +1 -1
- data/lib/legion/transport/queues/task_update.rb +5 -1
- data/lib/legion/transport/routes.rb +109 -17
- data/lib/legion/transport/settings.rb +36 -8
- data/lib/legion/transport/spool/legacy.rb +143 -0
- data/lib/legion/transport/spool/recovery_store.rb +180 -0
- data/lib/legion/transport/spool.rb +560 -105
- data/lib/legion/transport/task/envelope.rb +45 -0
- data/lib/legion/transport/task/protocol.rb +58 -0
- data/lib/legion/transport/tenant_migration.rb +408 -0
- data/lib/legion/transport/tenant_provisioner.rb +36 -20
- data/lib/legion/transport/tenant_topology.rb +24 -12
- data/lib/legion/transport/version.rb +1 -1
- data/lib/legion/transport.rb +1 -5
- metadata +24 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 03b783d62b4f350acbc74c3cd55501ad8a86af2123006085e0d78ef0d22101e0
|
|
4
|
+
data.tar.gz: b5d109c65eb57c52050c61cfe3e5de6330c0a9ad5eaebd814e90355f0ec08264
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 95416ee45e311a0ba0c3883556ce6e91e384cc7d7980de85e60cb73a5cbe937381e69e3ceeb080f2cf939f095844826e7858588e6f0fa0616bde6933a7bb532a
|
|
7
|
+
data.tar.gz: e78f9eedf46b3beb2aa69c586ffe41b9c258f6acb1583117ee689dfb3c762d97a7869ba4bf38f98e4373469cf1760caea7c203f0b53ce5dc5e4674e9ac02472a
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -8,15 +8,18 @@ on:
|
|
|
8
8
|
|
|
9
9
|
jobs:
|
|
10
10
|
ci:
|
|
11
|
-
uses: LegionIO/.github/.github/workflows/ci.yml@
|
|
11
|
+
uses: LegionIO/.github/.github/workflows/ci.yml@e0fc7364b73f446d4cb9bdc162a66ed34bf0daa7
|
|
12
12
|
with:
|
|
13
13
|
needs-rabbitmq: true
|
|
14
|
+
companion-gems: ${{ github.event_name == 'pull_request' && '{"legion-settings":{"repository":"LegionIO/legion-settings","ref":"8e73a9ef3c13958e3e1e7fc98532e41283159706"}}' || '{}' }}
|
|
14
15
|
|
|
15
16
|
lint:
|
|
16
17
|
uses: LegionIO/.github/.github/workflows/lint-patterns.yml@main
|
|
17
18
|
|
|
18
19
|
security:
|
|
19
|
-
uses: LegionIO/.github/.github/workflows/security-scan.yml@
|
|
20
|
+
uses: LegionIO/.github/.github/workflows/security-scan.yml@e0fc7364b73f446d4cb9bdc162a66ed34bf0daa7
|
|
21
|
+
with:
|
|
22
|
+
companion-gems: ${{ github.event_name == 'pull_request' && '{"legion-settings":{"repository":"LegionIO/legion-settings","ref":"8e73a9ef3c13958e3e1e7fc98532e41283159706"}}' || '{}' }}
|
|
20
23
|
|
|
21
24
|
version-changelog:
|
|
22
25
|
uses: LegionIO/.github/.github/workflows/version-changelog.yml@main
|
data/.gitignore
CHANGED
data/.rubocop.yml
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,54 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [1.4.32] - 2026-10-30
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- Add strict `Message#publish!` and raw `Publisher.publish!` APIs backed by Transport-owned local `transport.task_publication` policy; return explicit accepted results or raise `PublishRejected` exposing the original result. Keep legacy non-task publication behavior unchanged and disable duplicate spooling for task outbox handoffs by default.
|
|
9
|
+
- Preserve canonical task lineage, policy identity, typed engine/schema context, attempts, deadlines and schedule metadata across task subclasses and raw-byte retries.
|
|
10
|
+
- Own the immutable task wire contract in `Legion::Transport::Task::Protocol`, with status/route lookup and terminal recognition helpers independent of runtime settings, Data and Core. Nest task protocol and envelope implementations under `lib/legion/transport/task/`.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- Preserve fixed task stage routes and canonical statuses despite local configuration drift, reject missing task routes, and route meaningful short templates/structured conditions without string-length bypasses.
|
|
14
|
+
- Implement lite manual acknowledgement, rejection/requeue, redelivery metadata and ownership-safe cancellation/channel recovery. Resume existing surviving consumers after cancellation/channel closure, including self-cancellation, without delivering onto a closing session. Preserve callback failures as recoverable pending work without claiming delivery or looping synchronously on rejects.
|
|
15
|
+
- Declare Base64 as a direct runtime dependency so standalone consumers can load spool support on Ruby 3.4 and newer without incidental dependencies.
|
|
16
|
+
- Read message limits and metadata, connection/TLS policy, Vault configuration and routing directly from live `Legion::Settings[:transport]`; remove cached settings snapshots and optional-dependency fallbacks.
|
|
17
|
+
- Reject missing or invalid payload safety limits with a configuration error before broker publication.
|
|
18
|
+
- Restore persisted date/time and numeric-string AMQP timestamps to Unix seconds before spool replay; preserve ciphertext and IVs, and retain unparseable timestamp records in recovery without blocking healthy messages.
|
|
19
|
+
- Restore the public `Connection.settings` API used by subscription actors to create dedicated channels. Read the canonical transport settings on every call so live configuration replacement remains effective.
|
|
20
|
+
- Preserve encryption IVs through task-envelope metadata handling for both existing `publish` and opt-in `publish!` callers.
|
|
21
|
+
- Honor Dynamic constructor and per-publication routing overrides, and retain the supported `task.scheduled` and `transformer.exception` statuses.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
- Require generic `legion-settings >= 1.4.5`; register Transport defaults at startup, never during policy reads, and document local publication policy versus stable wire identities and lite-mode durability limits.
|
|
25
|
+
|
|
26
|
+
## [1.4.31] - 2026-10-29
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
- Bound pool checkout with a monotonic deadline that includes connection creation; reserve capacity without holding the pool mutex during network operations, wake waiters on shutdown, and reject late connections.
|
|
30
|
+
- Bound connection shutdown across pooled, publisher, log, and build sessions with one five-second deadline; disable recovery, close sockets, and stop old consumer workers before replacing sessions.
|
|
31
|
+
- Keep forced reconnect exclusive to its owning caller and restore existing queue, channel, and consumer references, bindings, and QoS before notifying reconnect callbacks.
|
|
32
|
+
- Reuse and track thread-local publisher channels in pool mode; preserve publisher caches across repeated forced reconnects without accumulating log channels.
|
|
33
|
+
- Start each Bunny session inside its host failover attempt and clean up failed handshakes before advancing to the next host. Fixes #41.
|
|
34
|
+
- Bound recovery snapshot lock acquisition and invalidate the complete reconnect generation on shutdown; cancelled owners cannot reopen transport or disturb a newer session. Stop legacy Bunny readers before intentional socket closure to avoid asynchronous owner-thread errors.
|
|
35
|
+
- Use the supported zero-argument Bunny confirm API with bounded waits, retain Vault PEM owners for the session/recovery lifetime, and keep DLX cleanup failures from masking declaration failures. Fixes #42.
|
|
36
|
+
- Support the common lite-mode channel factory contract, ordered pending-only delivery outside global locks, exchange-scoped direct/topic/fanout routing, and atomic consumer cancellation with session-owned cleanup. Fixes #43.
|
|
37
|
+
- Apply tenant naming at real declaration/binding/DLX boundaries, scope exchange caches by tenant and declaration context, align provisioning with actual resources, and enforce encoded-byte quotas in message/helper publishing and replay. Fixes #44.
|
|
38
|
+
- Correlate mandatory returns on exchanges, retire ambiguous confirm-timeout channels, persist only successful spool writes, and replay claimed wire records asynchronously after connection recovery with at-least-once confirmation semantics.
|
|
39
|
+
- Preserve existing queues, bindings, and DLQ contents on declaration mismatches; surface required DLX failures, report actual HTTP publish outcomes, and default to persistent messages with prefetch 2 while retaining explicit false/zero overrides. Fixes #45.
|
|
40
|
+
- Fail closed when requested encryption is not ready, bound encryption attempts, retain cipher/IV metadata in spool replay, and preserve explicit plaintext and bootstrap exceptions. Fixes #46.
|
|
41
|
+
- Automatically upgrade historical raw JSONL spool records to durable wire envelopes before replay; preserve routing, tenant, encryption intent and false/zero metadata, replace stale IVs, and reuse stored ciphertext across interrupted replay. Retry key readiness and late-loaded exchange classes without a manual trigger. Retain original data on failed or interrupted conversion and hold file ownership across atomic replacement.
|
|
42
|
+
- Keep spool claim cleanup safe when shutdown interrupts draining or conversion before the progress offset or original handle is initialized.
|
|
43
|
+
- Complete tenant queue and DLQ data migration with explicit/registered manifests, nonmutating plans, exclusive source locks, tenant-conflict checks, byte-preserving routed confirmation before source acknowledgement, bounded resumable progress, and an authenticated-host HTTP endpoint.
|
|
44
|
+
- Quarantine malformed and missing/unroutable-destination spool records without blocking healthy work. Add durable inspect/repair/retry APIs, stable recovery IDs, per-record cross-process ownership, atomic ready-state persistence, and delivered tombstones that prevent stale journal markers from resurrecting completed repairs.
|
|
45
|
+
- Recover historical Dynamic destinations from saved function metadata, scope literal legacy exchanges by the saved tenant, and retry unavailable encryption/extension metadata independently of healthy records. Preserve ciphertext and IV during destination repair.
|
|
46
|
+
- Use Bunny's version-aware acknowledgement/rejection APIs during tenant migration, wait for asynchronous requeue visibility, and preserve structured failures when tenant normalization raises without repeating coercion during logging.
|
|
47
|
+
|
|
48
|
+
### Changed
|
|
49
|
+
- Direct `Local.subscribe` uses `exchange_name:` for named exchanges; its default is the default exchange, not an unscoped routing-key subscription.
|
|
50
|
+
- New spool records preserve encoded payload bytes and the full envelope. Historical unversioned records upgrade automatically during replay; capacity exhaustion rejects new writes instead of evicting pending data.
|
|
51
|
+
- Standardize changed keyword-based transport methods with anonymous `**` when extra context is unused, preserve intentional keyword forwarding, and use kwargs for internal helpers without migrating existing application-facing positional APIs.
|
|
52
|
+
|
|
5
53
|
## [1.4.30] - 2026-07-31
|
|
6
54
|
|
|
7
55
|
### Fixed
|
data/Gemfile
CHANGED
|
@@ -3,6 +3,16 @@
|
|
|
3
3
|
source 'https://rubygems.org'
|
|
4
4
|
|
|
5
5
|
gemspec
|
|
6
|
+
|
|
7
|
+
dependency_root = if File.directory?(File.expand_path('.ci-dependencies',
|
|
8
|
+
__dir__))
|
|
9
|
+
File.expand_path('.ci-dependencies', __dir__)
|
|
10
|
+
else
|
|
11
|
+
File.expand_path('..', __dir__)
|
|
12
|
+
end
|
|
13
|
+
settings_path = File.join(dependency_root, 'legion-settings')
|
|
14
|
+
gem 'legion-settings', path: settings_path if File.directory?(settings_path)
|
|
15
|
+
|
|
6
16
|
group :test do
|
|
7
17
|
gem 'rake'
|
|
8
18
|
gem 'rspec'
|
|
@@ -10,4 +20,5 @@ group :test do
|
|
|
10
20
|
gem 'rubocop'
|
|
11
21
|
gem 'rubocop-legion'
|
|
12
22
|
gem 'simplecov'
|
|
23
|
+
gem 'sinatra', '>= 4.0'
|
|
13
24
|
end
|
data/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Legion::Transport is the Ruby gem responsible for connecting LegionIO to its FIFO queue system (RabbitMQ over AMQP 0.9.1). It provides thread-safe connection management, exchange/queue abstractions, message publishing with optional encryption, and consumer wrappers.
|
|
4
4
|
|
|
5
|
-
**Version**: 1.4.
|
|
5
|
+
**Version**: 1.4.32
|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
@@ -36,6 +36,8 @@ gem 'legion-transport'
|
|
|
36
36
|
|
|
37
37
|
## Usage
|
|
38
38
|
|
|
39
|
+
Keyword-based recovery, spooling, and tenant-migration APIs accept unrelated keyword context without rejecting the call; named option splats still consume or forward their options. Existing positional constructor and message-publish interfaces remain supported.
|
|
40
|
+
|
|
39
41
|
### Basic Connection
|
|
40
42
|
|
|
41
43
|
```ruby
|
|
@@ -91,6 +93,102 @@ result[:accepted] # => true/false
|
|
|
91
93
|
|
|
92
94
|
Options merge with the message's defaults — you can override `routing_key:` or `persistent:` per-publish without constructing a new message.
|
|
93
95
|
|
|
96
|
+
Mandatory publishing waits for publisher confirmation before reporting routed acceptance. Confirm waits use Bunny's zero-argument API and retire a timed-out channel so late acknowledgments cannot contaminate the next send. A timeout is an unknown delivery outcome, not proof that the broker rejected the message.
|
|
97
|
+
|
|
98
|
+
#### Task pipeline publication
|
|
99
|
+
|
|
100
|
+
Task producers and durable outbox relays can opt into `publish!` when they need explicit acceptance. The existing `publish(options = nil)` API remains available; existing callers do not automatically acquire strict publication policy:
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
message = Legion::Transport::Messages::Task.new(
|
|
104
|
+
function: 'process_order', routing_key: 'orders.process',
|
|
105
|
+
task_id: 42, parent_id: 7, master_id: 1,
|
|
106
|
+
principal_id: 'order-service', principal_type: 'service',
|
|
107
|
+
args: { order_id: 123 }
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
begin
|
|
111
|
+
result = message.publish!(spool: false)
|
|
112
|
+
# result[:accepted] == true and result[:status] == :accepted
|
|
113
|
+
rescue Legion::Transport::PublishRejected => error
|
|
114
|
+
# error.result has the original :status, :accepted, destination and error details.
|
|
115
|
+
# Keep/release the outbox intent for retry; do not acknowledge it as delivered.
|
|
116
|
+
end
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`Message#publish!(**options)` and `Publisher.publish!(exchange:, payload:, envelope:, **options)` use live, process-local `Legion::Settings[:transport][:task_publication]` through `Legion::Transport::Settings::Helper#transport_task_publication_settings`. Transport registers its defaults at startup with generic `legion-settings >= 1.4.5`; reads never register defaults or cache a loader. Message limits and metadata, connection/TLS configuration, and routing read the live canonical Transport subtree. Defaults enable publisher confirmation, mandatory routing and structured results, use a 5000ms confirmation timeout, and disable the secondary disk spool. Constructor options and call options can explicitly override policy; disabling confirmation or mandatory routing weakens the corresponding guarantee. The bang APIs always return a structured accepted result or raise: spooled, unroutable, nacked, failed and ambiguous timeout outcomes are **not** accepted.
|
|
120
|
+
|
|
121
|
+
The raw `Publisher.publish!` API is for subscription retries: pass the original bytes and full AMQP envelope, including content encoding, IV, message/correlation IDs, and retry headers. It never re-encodes, re-encrypts or spools the payload. The message API continues using the existing Publisher/Spool implementation. Both message publication APIs preserve task encryption IVs; legacy `publish` retains its existing return convention.
|
|
122
|
+
|
|
123
|
+
Task, SubTask, Dynamic, CheckSubtask and TaskUpdate preserve canonical task metadata in both the JSON body and AMQP headers: lineage, identity/tenant/worker policy, engine/schema/options, execution attempts/tokens, depth/deadline, schedule/occurrence and definition version. Explicit business arguments stay under `args`, separate from orchestration values such as `results`, `function_args`, `conditions` and `transformation`. Dynamic retains its legacy flat-argument fallback; supply `args:` to prevent that fallback from passing orchestration values to the runner. Headers include the fixed `task_protocol_version` (`1`). An empty or missing task routing key is rejected rather than silently sent to the empty route.
|
|
124
|
+
|
|
125
|
+
`require 'legion/transport/task/protocol'` exposes the immutable wire contract `Legion::Transport::Task::Protocol`: `STATUSES`, `TERMINAL_STATUSES`, `LEGACY_TERMINAL_STATUSES`, `ROUTES`, `MESSAGE_TYPES`, `VERSION` and `METADATA_FIELDS`. Include or extend its `Helper` for `task_status(name:)`, `task_route(name:)` and `task_terminal_status?(status:)`. Status/route lookup rejects unknown names; terminal recognition includes legacy terminal spellings. Shared task-envelope behavior lives in `legion/transport/task/envelope` under `Legion::Transport::Task::Envelope`.
|
|
126
|
+
|
|
127
|
+
Stage routes remain `task.subtask.check`, `task.subtask.conditioner`, `task.subtask.transform` and `task.update`. CheckSubtask and TaskUpdate always use these fixed routes; Task, SubTask and Dynamic retain explicit target routing keys, including per-publication overrides. Local settings, including obsolete root `task_execution` overrides, cannot rename wire identities or redefine metadata/version/terminal semantics. Settings is not distributed-consistent storage: publication policy is local, while wire identity is fixed and durable execution authority belongs to the hosting application's database.
|
|
128
|
+
|
|
129
|
+
Nonblank short templates such as `{}` and structured conditions are routed to their stages rather than bypassed by string-length heuristics. TaskUpdate accepts canonical statuses, including the previously supported `task.scheduled` and `transformer.exception`. `Messages::Task` needs no database and supports an explicit exchange/routing key; `Messages::Dynamic` resolves its existing `function_id` through the host's optional Data model.
|
|
130
|
+
|
|
131
|
+
#### Lite-mode delivery limits
|
|
132
|
+
|
|
133
|
+
With `LEGION_MODE=lite`, strict publication accepts only a matching declared queue when mandatory routing is enabled. Acceptance means **queued in process memory**, not durable broker storage or successful execution. Restart/session shutdown loses pending work; there are no RabbitMQ durability, priority, TTL or dead-letter guarantees.
|
|
134
|
+
|
|
135
|
+
Manual consumers must acknowledge successful work. `queue.acknowledge(tag)` removes an outstanding delivery; `queue.reject(tag, requeue: true)` retains the original bytes/properties and marks redelivery. Cancellation and channel closure return unacknowledged work to surviving session-owned queues and resume already-subscribed consumers without waiting for another publication. Session shutdown closes its consumers before handing shared work to surviving sessions. `requeue: false` discards it. A synchronous rejection is left pending until subsequent queue activity or a replacement subscription, avoiding a tight retry loop. Callback failures propagate and retain pending work; a failing initial backlog subscription is cancelled so replacement consumers can recover it. Direct `Local.subscribe` retains its automatic-acknowledgement API.
|
|
136
|
+
|
|
137
|
+
Use the Data task/outbox APIs in the hosting application when crash-durable intent is required. Transport does not depend on Data and does not claim lite publication provides that durability.
|
|
138
|
+
|
|
139
|
+
### Spooling and automatic replay
|
|
140
|
+
|
|
141
|
+
Connection failures can persist the already encoded wire payload, complete AMQP envelope, effective broker destination, and tenant identity. `:spooled` means the disk write succeeded; a full spool or failed write returns `:failed`.
|
|
142
|
+
|
|
143
|
+
- Startup, native recovery, and forced recovery schedule one asynchronous replay worker. Network operations run outside spool-write and connection locks.
|
|
144
|
+
- Replay uses the original ciphertext and IV, checks the stored tenant's encoded-byte quota, and removes records only after routed broker confirmation.
|
|
145
|
+
- Replay restores numeric strings and saved date/time strings to Unix timestamp seconds, preserving timezone offsets. Unparseable timestamp strings are quarantined with their original records instead of blocking later messages.
|
|
146
|
+
- Delivery is **at least once**: interruption after broker acceptance but before confirmation is recorded can cause redelivery. Consumers must tolerate duplicates.
|
|
147
|
+
- Concurrent appends are not part of a drainer's claimed file. Shutdown retains the unconfirmed tail for the next startup.
|
|
148
|
+
- Configure `Legion::Settings[:transport][:spool][:directory]` or pass `directory:` to `Spool.setup`; the default is `~/.legionio/spool`. Capacity limits reject new writes rather than deleting pending messages.
|
|
149
|
+
- Manual `Spool.drain` callbacks acknowledge a record only by returning literal `true`.
|
|
150
|
+
|
|
151
|
+
Older unversioned JSONL files upgrade automatically during replay; no migration command or opt-in is required. Their raw payloads are encoded once, with saved routing, tenant and envelope metadata preserved. Saved encryption flags and evidence of an earlier encrypted attempt take precedence over the current encryption default; explicit plaintext and cluster-secret bootstrap requests remain supported. A stale saved IV is replaced with the IV for the newly encoded bytes, not reused.
|
|
152
|
+
|
|
153
|
+
The claimed file is streamed to a locked replacement, flushed and fsynced, then atomically renamed before any upgraded record is published. Interrupted conversion retains the original data; interruption after conversion replays the stored wire bytes without re-encrypting. Missing key readiness or extension classes still loading at startup are retried automatically. Concurrent appends and existing versioned records remain intact.
|
|
154
|
+
|
|
155
|
+
Incomplete destinations, malformed records, and broker-rejected destinations are moved into durable per-record recovery storage under `spool/recovery/`; they do not block healthy records. Missing crypto or extension metadata stays `waiting` and retries automatically. Saved Dynamic function metadata is used when available; a destination that was never recorded is supplied through the repair API, never guessed.
|
|
156
|
+
|
|
157
|
+
#### Inspect, repair, and replay
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
spool = Legion::Transport::Spool
|
|
161
|
+
spool.status # pending, quarantined, waiting, delivered_recoveries
|
|
162
|
+
entry = spool.recovery_entries(status: 'quarantined', limit: 100, offset: 0).first
|
|
163
|
+
original = spool.recovery_record(id: entry.fetch(:id))
|
|
164
|
+
# original[:record] is the parsed original; original[:raw_line] is exact original bytes in Base64.
|
|
165
|
+
spool.repair_record(id: entry.fetch(:id), changes: { exchange: 'task', routing_key: 'worker.run' })
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Repair atomically persists a `ready` wire record and schedules confirmed replay. Destination-only repairs preserve existing ciphertext and IV. Supported changes are `exchange`, `routing_key`, `tenant_id`, `exchange_type`, `payload`, `headers`, and `encrypt`. Changing a wire record's encryption requires an explicit raw `payload`; changing its tenant requires an explicit `exchange`. A completely malformed record can be repaired by supplying its raw payload, exchange, and routing key. Validation/encryption errors leave the stored record unchanged.
|
|
169
|
+
|
|
170
|
+
After broker acceptance the entry becomes `delivered`, retains an idempotence tombstone, and releases its stored payload. Delivered entries cannot be repaired/re-published. `spool.retry_recovery(id: ...)` retries the retained destination after external topology/metadata repair; retries preserve earlier repairs and are idempotent after delivery. Per-record cross-process locks serialize repair and replay without blocking unrelated inspection or spool writes.
|
|
171
|
+
|
|
172
|
+
The host Legion API exposes the same operations under its existing authentication:
|
|
173
|
+
|
|
174
|
+
| Method | Path | Result |
|
|
175
|
+
|--------|------|--------|
|
|
176
|
+
| GET | `/api/transport/spool` | Pending and recovery-state counts |
|
|
177
|
+
| GET | `/api/transport/spool/recovery?status=quarantined&limit=100&offset=0` | Metadata-only page; no message payloads |
|
|
178
|
+
| GET | `/api/transport/spool/recovery/:id` | Explicit original-record/raw-byte inspection |
|
|
179
|
+
| POST | `/api/transport/spool/recovery/:id/repair` | JSON repair fields; `202` means durable and queued, not yet delivered |
|
|
180
|
+
| POST | `/api/transport/spool/recovery/:id/retry` | `202` ready/waiting, `200` already delivered, `422` still quarantined |
|
|
181
|
+
|
|
182
|
+
Unknown IDs return `404`; invalid repairs return `422`; unavailable encryption/storage returns `503`. `pending` includes waiting/ready recovery work but excludes quarantined records and delivered tombstones. Ordinary wire replay and repaired replay remain at least once across a crash between broker acceptance and durable local completion.
|
|
183
|
+
|
|
184
|
+
### Encryption policy
|
|
185
|
+
|
|
186
|
+
`transport.messages.encrypt: true` or `encrypt: true` requires a ready cluster secret. An unready key raises `Legion::Transport::EncryptionNotReady` before publishing or spooling plaintext. Encryption itself is bounded by `crypt.cluster_secret_timeout` (five seconds when unset). Explicit `encrypt: false` and the cluster-secret bootstrap message remain deliberately unencrypted.
|
|
187
|
+
|
|
188
|
+
### HTTP publish outcomes
|
|
189
|
+
|
|
190
|
+
`POST /api/transport/publish` requests mandatory routing and publisher confirmation. Responses distinguish accepted (`201`), spooled/deferred (`202`), unroutable (`422`), confirm timeout (`504`), and failed/nacked (`503`) results. `published: true` is returned only for accepted publication; unexpected exceptions return an error response.
|
|
191
|
+
|
|
94
192
|
### Creating a Queue
|
|
95
193
|
|
|
96
194
|
```ruby
|
|
@@ -125,7 +223,67 @@ exchange.publish(payload, routing_key: 'task.my_runner.my_function')
|
|
|
125
223
|
}
|
|
126
224
|
```
|
|
127
225
|
|
|
128
|
-
Supported server keys: `host:` (string), `hosts:` (array), `server:` (string), `servers:` (array). All are merged and deduped. Port 5672 is injected where omitted.
|
|
226
|
+
Supported server keys: `host:` (string), `hosts:` (array), `server:` (string), `servers:` (array). All are merged and deduped. Port 5672 is injected where omitted. Connection setup attempts each configured host, including the AMQP handshake, and closes failed attempts before moving to the next host.
|
|
227
|
+
|
|
228
|
+
### Pooling and recovery
|
|
229
|
+
|
|
230
|
+
- Set `transport.connection_pool_size` above `1` to enable lazy session pooling. Publisher channels are reused per thread and tracked for cleanup.
|
|
231
|
+
- Pool checkout uses one monotonic deadline for waiting and connection creation. Shutdown wakes blocked callers with `Legion::Transport::PoolClosed`; a late factory result is closed instead of returning to the pool.
|
|
232
|
+
- `Connection.shutdown` shares a five-second teardown deadline across owned sessions, closes their sockets, and stops consumer workers. Channel or session creation racing with shutdown cannot install stale resources.
|
|
233
|
+
- `Connection.force_reconnect` permits one active reconnect. It restores existing consumer/channel/queue objects, declarations, bindings, and QoS before running `on_force_reconnect` callbacks. Cancelled consumers remain cancelled, and repeated reconnects reuse publisher channels.
|
|
234
|
+
- Restoration failures return `false`, close the failed replacement, and retain the subscription snapshot for a later reconnect attempt. Restoration refuses to rebind channels while old consumer workers remain alive.
|
|
235
|
+
- Recovery snapshots have their own five-second deadline. If an old Bunny channel/consumer lock is stuck, reconnect returns `false` without detaching the old session; release the lock and retry.
|
|
236
|
+
- Shutdown invalidates the entire in-flight reconnect generation. A cancelled snapshot, replacement attempt, or cleanup cannot reopen transport, tear down a newer session, or clear a newer reconnect owner's guard.
|
|
237
|
+
- Intentional teardown stops socket readers before closing their sockets, avoiding Bunny 2.x asynchronous network errors in the session-owning thread. An open lite-mode connection needs no network recovery and retains its consumers during `force_reconnect`.
|
|
238
|
+
|
|
239
|
+
### Tenant topology and quotas
|
|
240
|
+
|
|
241
|
+
Enable `transport.tenant_topology.enabled` to scope ordinary queue/exchange declarations, bindings, and DLXs to the active `Legion::TenantContext.current` identity. The default format is `t.<tenant_id>.<name>`; disabled topology and the `default` tenant retain unprefixed names. Configured shared exchanges remain shared. Exchange caches include tenant, effective name, declaration options, and explicit channel context.
|
|
242
|
+
|
|
243
|
+
`TenantProvisioner.provision(tenant_id)` creates the same `task`, `results`, and `events` resources and corresponding dead-letter topology used by producers and consumers. Message and helper publishing enforce per-tenant message/encoded-byte limits; replay preserves the stored tenant instead of using its worker's ambient context.
|
|
244
|
+
|
|
245
|
+
Queue, exchange, and DLX mismatches fail without deleting existing topology or messages. Required DLX provisioning errors prevent source-queue construction.
|
|
246
|
+
|
|
247
|
+
#### Migrating existing unprefixed queues and data
|
|
248
|
+
|
|
249
|
+
`TenantMigration.migrate` plans and transfers existing queue **and dead-letter** data into tenant-scoped topology. It preserves payload bytes, cipher/IV, AMQP properties and routing keys, and acknowledges each source message only after mandatory routed confirmation at its target. A dedicated per-source transfer exchange prevents delivery to unrelated queues sharing the destination exchange. The original exchange is retained in `x-legion-original-exchange`.
|
|
250
|
+
|
|
251
|
+
Pause legacy producers and consumers for the selected queues, enable tenant topology, and specify the owning tenant. Active source consumers and contradictory tenant headers/plaintext metadata are refused; ambient thread context never establishes migration ownership.
|
|
252
|
+
|
|
253
|
+
```ruby
|
|
254
|
+
manifest = [{
|
|
255
|
+
name: 'orders.jobs',
|
|
256
|
+
options: { durable: true, arguments: { 'x-queue-type' => 'quorum',
|
|
257
|
+
'x-dead-letter-exchange' => 'orders.dlx' } },
|
|
258
|
+
bindings: [{ exchange: 'orders', exchange_type: 'topic', routing_key: 'orders.#' }]
|
|
259
|
+
}]
|
|
260
|
+
|
|
261
|
+
plan = Legion::Transport::TenantMigration.migrate(
|
|
262
|
+
tenant_id: 'alpha', queues: manifest, dry_run: true
|
|
263
|
+
)
|
|
264
|
+
result = Legion::Transport::TenantMigration.migrate(
|
|
265
|
+
tenant_id: 'alpha', queues: manifest, max_messages: 10_000, timeout: 30
|
|
266
|
+
)
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Dry runs inspect source counts/consumers and resolve names without consuming or creating topology. Execution declares all targets and bindings before transfer, migrates the associated `orders.dlx.queue`, and reports per-queue source/target names, moved/remaining counts, status, and errors. Repeat a `pending` result to resume until `completed`; verify all source counts are zero before resuming tenant-scoped producers/consumers. Sources and their bindings are never deleted. Producers must remain paused during cutover so they cannot repopulate legacy queues after verification.
|
|
270
|
+
|
|
271
|
+
Omit `queues:` when the process has already instantiated and bound the tenant's real queue wrappers; declarations and bindings are registered automatically and survive wrapper recreation. After a process restart, or for passive-only declarations, supply the manifest or instantiate/bind those wrappers again. Specify non-default exchange types/options and actual queue arguments; unavailable source metadata is not invented. Shared DLQs and connection-exclusive targets are refused rather than assigned to an arbitrary tenant.
|
|
272
|
+
|
|
273
|
+
`POST /api/transport/tenants/:tenant_id/migrate` accepts the same JSON fields (`queues`, `dry_run`, `max_messages`, `timeout`). Responses are `200` planned/completed, `202` pending, `409` ownership/consumer/topology conflicts, `422` invalid input, or `503` failure. The API inherits the host's authentication.
|
|
274
|
+
|
|
275
|
+
Broker locks exclude concurrent migrations of the same source. Interrupted/unconfirmed transfers are requeued; a repeat invocation resumes the remaining source messages. Delivery is at least once if the target accepted before source acknowledgement was interrupted. Count/time bounds, encoded-byte quotas, and bounded owned-session teardown apply. Lite mode has no persisted broker topology and explicitly rejects this operation.
|
|
276
|
+
|
|
277
|
+
### Lite delivery
|
|
278
|
+
|
|
279
|
+
Lite mode supports exchange-scoped direct, fanout, and topic bindings (`*` matches one word; `#` matches zero or more). Only undelivered messages remain pending; callbacks run outside the global storage mutex, and cancellation unregisters callbacks before completing. Session shutdown retires its own queues and consumers without resetting unrelated sessions.
|
|
280
|
+
|
|
281
|
+
Direct `Local.subscribe` callers must specify `exchange_name:` for named exchanges; omitting it subscribes only to the default exchange:
|
|
282
|
+
|
|
283
|
+
```ruby
|
|
284
|
+
Legion::Transport::Local.subscribe('worker.run', exchange_name: 'events') { |payload| puts payload }
|
|
285
|
+
Legion::Transport::Local.publish('events', 'worker.run', 'work')
|
|
286
|
+
```
|
|
129
287
|
|
|
130
288
|
## Configuration
|
|
131
289
|
|
|
@@ -143,13 +301,15 @@ Configuration is managed through `legion-settings` with environment variable ove
|
|
|
143
301
|
| TTL | `transport.messages.ttl` | `nil` |
|
|
144
302
|
| Persistent | `transport.messages.persistent` | `true` |
|
|
145
303
|
|
|
304
|
+
`Legion::Transport::Connection.settings` exposes the current `Legion::Settings[:transport]` subtree for subscription actors and other connection consumers. It does not cache settings; replacing the live configuration takes effect on the next call.
|
|
305
|
+
|
|
146
306
|
### Full Default Settings
|
|
147
307
|
|
|
148
308
|
```json
|
|
149
309
|
{
|
|
150
310
|
"type": "rabbitmq",
|
|
151
311
|
"connected": false,
|
|
152
|
-
"logger_level": "
|
|
312
|
+
"logger_level": "warn",
|
|
153
313
|
"messages": {
|
|
154
314
|
"encrypt": false,
|
|
155
315
|
"ttl": null,
|
|
@@ -171,8 +331,7 @@ Configuration is managed through `legion-settings` with environment variable ove
|
|
|
171
331
|
"block": false,
|
|
172
332
|
"auto_delete": false,
|
|
173
333
|
"arguments": {
|
|
174
|
-
"x-
|
|
175
|
-
"x-overflow": "reject-publish"
|
|
334
|
+
"x-queue-type": "quorum"
|
|
176
335
|
}
|
|
177
336
|
},
|
|
178
337
|
"connection": {
|
|
@@ -189,7 +348,7 @@ Configuration is managed through `legion-settings` with environment variable ove
|
|
|
189
348
|
"port": 5672,
|
|
190
349
|
"vhost": "/",
|
|
191
350
|
"recovery_attempts": 10,
|
|
192
|
-
"logger_level": "
|
|
351
|
+
"logger_level": "warn",
|
|
193
352
|
"connected": false
|
|
194
353
|
},
|
|
195
354
|
"channel": {
|
|
@@ -228,10 +387,12 @@ TLS can be configured through transport settings:
|
|
|
228
387
|
tls_key: '/path/to/key.pem',
|
|
229
388
|
ca_certs: '/path/to/ca.pem',
|
|
230
389
|
verify_peer: true,
|
|
231
|
-
|
|
390
|
+
vault_pki: false
|
|
232
391
|
}
|
|
233
392
|
```
|
|
234
393
|
|
|
394
|
+
Vault-issued PEM files remain owned by their Bunny session through native recovery and are explicitly removed when that session is retired or connection creation fails.
|
|
395
|
+
|
|
235
396
|
## Dependencies
|
|
236
397
|
|
|
237
398
|
| Gem | Version | Purpose |
|
data/legion-transport.gemspec
CHANGED
|
@@ -26,10 +26,11 @@ Gem::Specification.new do |spec|
|
|
|
26
26
|
'rubygems_mfa_required' => 'true'
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
spec.add_dependency 'base64'
|
|
29
30
|
spec.add_dependency 'bunny', '>= 2.23'
|
|
30
31
|
spec.add_dependency 'concurrent-ruby', '>= 1.2'
|
|
31
32
|
spec.add_dependency 'legion-json', '>= 1.2.0'
|
|
32
33
|
spec.add_dependency 'legion-logging', '>= 1.5.0'
|
|
33
|
-
spec.add_dependency 'legion-settings', '>= 1.
|
|
34
|
+
spec.add_dependency 'legion-settings', '>= 1.4.5'
|
|
34
35
|
spec.add_dependency 'logger'
|
|
35
36
|
end
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'timeout'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Transport
|
|
7
|
+
module Connection
|
|
8
|
+
# A forced reconnect replaces the session, unlike Bunny's automatic recovery.
|
|
9
|
+
# Keep the original channels: actors retain queues, consumers and channel references.
|
|
10
|
+
# Capture before teardown; restore only after the old transport and workers stop.
|
|
11
|
+
class Recovery
|
|
12
|
+
def self.capture(sessions:, excluded_channels: [], timeout: Legion::Transport::Connection::SHUTDOWN_TIMEOUT, **)
|
|
13
|
+
Timeout.timeout(timeout) do
|
|
14
|
+
new(sessions: sessions.compact.uniq, excluded_channels: excluded_channels)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(sessions:, excluded_channels: [], **)
|
|
19
|
+
@excluded_channels = excluded_channels
|
|
20
|
+
@registered_sessions = {}
|
|
21
|
+
@states = sessions.filter_map { |session| capture_session(session: session) }
|
|
22
|
+
@mutex = Mutex.new
|
|
23
|
+
@restored = false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def restore(session:, **)
|
|
27
|
+
@mutex.synchronize do
|
|
28
|
+
return true if @restored || @states.empty?
|
|
29
|
+
raise IOError, 'replacement session is not open' unless session&.open?
|
|
30
|
+
raise IOError, 'partially restored session must be torn down before retrying' if @attempted_session.equal?(session)
|
|
31
|
+
|
|
32
|
+
ensure_quiescent_sources!
|
|
33
|
+
@attempted_session = session
|
|
34
|
+
active = @states.to_h { |state| [state, active_consumers(state: state)] }
|
|
35
|
+
@states.each do |state|
|
|
36
|
+
state[:channels].each { |channel| restore_channel(channel: channel, session: session) }
|
|
37
|
+
end
|
|
38
|
+
restore_topology(active: active)
|
|
39
|
+
active.each do |state, consumers|
|
|
40
|
+
consumers.each do |consumer|
|
|
41
|
+
consumer.consumer_tag = state[:consumer_tags].fetch(consumer)
|
|
42
|
+
consumer.channel.basic_consume_with(consumer)
|
|
43
|
+
@registered_sessions[consumer] = session
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
@restored = true
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def capture_session(session:, **)
|
|
53
|
+
# Network recovery closes channels without unregistering them. Explicit
|
|
54
|
+
# Channel#close unregisters them, so retained closed channels still need recovery.
|
|
55
|
+
channels = session.instance_variable_get(:@channel_mutex).synchronize do
|
|
56
|
+
session.instance_variable_get(:@channels).values.reject do |channel|
|
|
57
|
+
channel.number.zero? || @excluded_channels.include?(channel)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
return if channels.empty?
|
|
61
|
+
|
|
62
|
+
registrations = channels.flat_map do |channel|
|
|
63
|
+
channel.instance_variable_get(:@consumer_mutex).synchronize { channel.consumers.to_a }
|
|
64
|
+
end
|
|
65
|
+
consumers = registrations.map(&:last)
|
|
66
|
+
queues = (channels.flat_map { |channel| channel.queues.values } + consumers.map(&:queue).grep(::Bunny::Queue)).uniq
|
|
67
|
+
exchanges = channels.flat_map { |channel| channel.exchanges.values }.uniq
|
|
68
|
+
state = { session: session, channels: channels, consumers: consumers,
|
|
69
|
+
consumer_names: consumers.to_h { |consumer| [consumer, consumer.queue_name] },
|
|
70
|
+
consumer_tags: registrations.to_h { |tag, consumer| [consumer, tag] },
|
|
71
|
+
queues: [], exchanges: [], queue_bindings: [], exchange_bindings: [] }
|
|
72
|
+
if session.respond_to?(:topology_registry)
|
|
73
|
+
capture_registry(state: state, registry: session.topology_registry, queues: queues, exchanges: exchanges)
|
|
74
|
+
else
|
|
75
|
+
capture_objects(state: state, queues: queues, exchanges: exchanges)
|
|
76
|
+
end
|
|
77
|
+
state
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def capture_registry(state:, registry:, queues:, exchanges:, **)
|
|
81
|
+
queues_by_name = queues.group_by(&:name)
|
|
82
|
+
exchanges_by_name = exchanges.to_h { |exchange| [exchange.name, exchange] }
|
|
83
|
+
registry.filtered_queues.each do |record|
|
|
84
|
+
objects = queues_by_name[record.name] || []
|
|
85
|
+
options = if objects.empty?
|
|
86
|
+
{ durable: record.durable, exclusive: record.exclusive, auto_delete: record.auto_delete, arguments: record.arguments }
|
|
87
|
+
else
|
|
88
|
+
objects.first.options.dup
|
|
89
|
+
end
|
|
90
|
+
state[:queues] << { name: record.name, server_named: record.server_named?, options: options, objects: objects,
|
|
91
|
+
channel: recovery_channel(state: state, channel: objects.first&.channel || record.channel) }
|
|
92
|
+
end
|
|
93
|
+
registry.filtered_exchanges.reject(&:predeclared?).each do |record|
|
|
94
|
+
object = exchanges_by_name[record.name]
|
|
95
|
+
options = if object
|
|
96
|
+
object.instance_variable_get(:@options).dup
|
|
97
|
+
else
|
|
98
|
+
{ durable: record.durable, auto_delete: record.auto_delete, arguments: record.arguments }
|
|
99
|
+
end
|
|
100
|
+
state[:exchanges] << { name: record.name, type: record.type, options: options,
|
|
101
|
+
channel: recovery_channel(state: state, channel: object&.channel || record.channel) }
|
|
102
|
+
end
|
|
103
|
+
capture_registry_bindings(state: state, registry: registry)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def capture_registry_bindings(state:, registry:, **)
|
|
107
|
+
registry.filtered_queue_bindings.each do |binding|
|
|
108
|
+
state[:queue_bindings] << { queue: binding.destination, exchange: binding.source,
|
|
109
|
+
options: { routing_key: binding.routing_key, arguments: binding.arguments },
|
|
110
|
+
channel: recovery_channel(state: state, channel: binding.channel) }
|
|
111
|
+
end
|
|
112
|
+
registry.filtered_exchange_bindings.each do |binding|
|
|
113
|
+
state[:exchange_bindings] << { source: binding.source, destination: binding.destination,
|
|
114
|
+
options: { routing_key: binding.routing_key, arguments: binding.arguments },
|
|
115
|
+
channel: recovery_channel(state: state, channel: binding.channel) }
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def capture_objects(state:, queues:, exchanges:, **)
|
|
120
|
+
queues.group_by(&:name).each do |name, objects|
|
|
121
|
+
queue = objects.first
|
|
122
|
+
state[:queues] << { name: name, server_named: queue.server_named?, options: queue.options.dup,
|
|
123
|
+
objects: objects, channel: queue.channel }
|
|
124
|
+
objects.each do |object|
|
|
125
|
+
object.instance_variable_get(:@bindings).each do |binding|
|
|
126
|
+
state[:queue_bindings] << { queue: name, exchange: binding[:exchange], channel: object.channel,
|
|
127
|
+
options: { routing_key: binding[:routing_key], arguments: binding[:arguments] } }
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
exchanges.each do |exchange|
|
|
132
|
+
unless exchange.predefined?
|
|
133
|
+
state[:exchanges] << { name: exchange.name, type: exchange.type, channel: exchange.channel,
|
|
134
|
+
options: exchange.instance_variable_get(:@options).dup }
|
|
135
|
+
end
|
|
136
|
+
exchange.instance_variable_get(:@bindings).each do |binding|
|
|
137
|
+
source = binding[:source]
|
|
138
|
+
state[:exchange_bindings] << { source: source.respond_to?(:name) ? source.name : source,
|
|
139
|
+
destination: exchange.name, channel: exchange.channel, options: binding[:opts].dup }
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
state[:queue_bindings].uniq!
|
|
143
|
+
state[:exchange_bindings].uniq!
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def recovery_channel(state:, channel:, **)
|
|
147
|
+
state[:channels].include?(channel) ? channel : state[:channels].first
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def active_consumers(state:, **)
|
|
151
|
+
# Channel#close retains the consumer map, whereas Basic.CancelOk removes it.
|
|
152
|
+
# Recheck after teardown so a cancellation after capture is not resurrected.
|
|
153
|
+
state[:consumers].select do |consumer|
|
|
154
|
+
tag = state[:consumer_tags].fetch(consumer)
|
|
155
|
+
source = @registered_sessions.fetch(consumer, state[:session])
|
|
156
|
+
registered = !source.respond_to?(:topology_registry) || source.topology_registry.consumers.key?(tag)
|
|
157
|
+
registered && consumer.channel.consumers[tag].equal?(consumer)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def ensure_quiescent_sources!
|
|
162
|
+
channels = @states.flat_map { |state| state[:channels] }
|
|
163
|
+
channels.map(&:connection).uniq.each do |source|
|
|
164
|
+
reader = source.instance_variable_get(:@reader_loop)&.instance_variable_get(:@thread)
|
|
165
|
+
raise IOError, 'old session transport or reader is still running' if source.transport.open? || reader&.alive?
|
|
166
|
+
end
|
|
167
|
+
# Bunny 3.x delivers plain integer tags. A callback from an old work pool
|
|
168
|
+
# could acknowledge an unrelated new delivery if allowed to survive rebinding.
|
|
169
|
+
return unless channels.any? { |channel| Array(channel.work_pool.threads).any?(&:alive?) }
|
|
170
|
+
|
|
171
|
+
raise IOError, 'old consumer workers are still running'
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def restore_channel(channel:, session:, **)
|
|
175
|
+
number = session.next_channel_id
|
|
176
|
+
raise IOError, 'replacement session channel limit exhausted' if number.negative?
|
|
177
|
+
|
|
178
|
+
pool = channel.work_pool
|
|
179
|
+
channel.instance_variable_set(:@connection, session)
|
|
180
|
+
channel.instance_variable_set(:@logger, session.logger)
|
|
181
|
+
channel.instance_variable_set(:@frame_max, session.frame_max)
|
|
182
|
+
channel.id = number
|
|
183
|
+
channel.instance_variable_set(:@work_pool, ::Bunny::ConsumerWorkPool.new(pool.size, pool.abort_on_exception,
|
|
184
|
+
pool.instance_variable_get(:@shutdown_timeout)))
|
|
185
|
+
channel.send(:reset_continuations)
|
|
186
|
+
# Bunny 2.x uses versioned delivery tags; advance before accepting deliveries.
|
|
187
|
+
channel.increment_recoveries_counter if channel.respond_to?(:increment_recoveries_counter)
|
|
188
|
+
channel.open
|
|
189
|
+
channel.recover_prefetch_setting
|
|
190
|
+
channel.recover_confirm_mode
|
|
191
|
+
channel.recover_tx_mode
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def restore_topology(active:, **)
|
|
195
|
+
# Bunny's aggregate recovery either swallows declaration errors (2.x) or
|
|
196
|
+
# replaces consumers and loses the original queue references (3.x). Use its
|
|
197
|
+
# recorded topology and protocol APIs, but let every failure reach the caller.
|
|
198
|
+
active.each_key do |state|
|
|
199
|
+
state[:exchanges].each do |exchange|
|
|
200
|
+
next if exchange[:options][:no_declare]
|
|
201
|
+
|
|
202
|
+
exchange[:channel].exchange_declare(exchange[:name], exchange[:type], exchange[:options])
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
names = active.to_h do |state, _consumers|
|
|
206
|
+
[state, state[:queues].to_h { |queue| [queue[:name], restore_queue(queue: queue)] }]
|
|
207
|
+
end
|
|
208
|
+
active.each do |state, consumers|
|
|
209
|
+
state[:exchange_bindings].each do |binding|
|
|
210
|
+
binding[:channel].exchange_bind(binding[:source], binding[:destination], binding[:options])
|
|
211
|
+
end
|
|
212
|
+
state[:queue_bindings].each do |binding|
|
|
213
|
+
binding[:channel].queue_bind(names[state].fetch(binding[:queue], binding[:queue]), binding[:exchange], binding[:options])
|
|
214
|
+
end
|
|
215
|
+
consumers.each do |consumer|
|
|
216
|
+
next if consumer.queue.respond_to?(:name)
|
|
217
|
+
|
|
218
|
+
original_name = state[:consumer_names].fetch(consumer)
|
|
219
|
+
consumer.instance_variable_set(:@queue, names[state].fetch(original_name, original_name))
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def restore_queue(queue:, **)
|
|
225
|
+
name = queue[:name]
|
|
226
|
+
unless queue[:options][:no_declare]
|
|
227
|
+
requested_name = queue[:server_named] ? '' : name
|
|
228
|
+
name = queue[:channel].queue_declare(requested_name, queue[:options]).queue
|
|
229
|
+
end
|
|
230
|
+
queue[:objects].each do |object|
|
|
231
|
+
object.channel.deregister_queue(object)
|
|
232
|
+
object.instance_variable_set(:@name, name)
|
|
233
|
+
object.channel.register_queue(object)
|
|
234
|
+
object.channel.record_queue(object) if object.channel.respond_to?(:record_queue)
|
|
235
|
+
end
|
|
236
|
+
name
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|