bsv-wallet-postgres 0.3.1 → 0.4.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d007064fafd9853cdc8696661ef969ddef47c0eeb9a1c0451acc7793a67fd1f1
4
- data.tar.gz: 21335a50ff36fbaf390ddfe558e5efbb67514ae4da5189f0b6b9c0a2a4b7c2e6
3
+ metadata.gz: 69980501d8a03bd6552190800aba40a65f3c34d2b6633a3a59d58030a11d54e3
4
+ data.tar.gz: 7ee7f4c26fc294d9f227cdae83e3c9d9dcc7cce64592dda289db8c223c6b34ec
5
5
  SHA512:
6
- metadata.gz: bd2578b3303a8b2a1407c51366aa9a30e78d28e38875fb5f229d5408a76f02a6ecbcaddfa3e1a01d770257e0c3e3f18ea15a257097cd5c5cc5444b4d4501d382
7
- data.tar.gz: e296a4026b3bddedd41cd572ad627c279ee9aaf16ca4d7ee061c11718a1b81e27cb76e9583dda0012bf55cdfaf7c28a34791c28306951802879c76463efd7157
6
+ metadata.gz: 5fa70d4686c6f55d782578f9f7c2b6bb290c113ebdd2a8e05a11d8062e56ba21337a2ce3bf8d96af32a13ac60d05d8328f6608d560f6b2dce3b1514045f6b1ef
7
+ data.tar.gz: 371d0d3ccc53cf8c247aed4fa449a232b0a9f3ce46e8c7ebfbc311f5f23eae7043a186e0678c14fe5f3693ced12e3458965d3a93e471fcdbc2f46a1661961eac
data/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ All notable changes to the `bsv-wallet-postgres` gem are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
6
6
  and this gem adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## 0.4.0 — 2026-04-12
9
+
10
+ ### Added
11
+ - `BSV::Wallet::SolidQueueAdapter` — PostgreSQL-backed async broadcast queue implementing the `BroadcastQueue` interface
12
+ - Migration 006: `wallet_broadcast_jobs` table with `FOR UPDATE SKIP LOCKED` polling support
13
+ - Background worker thread broadcasts transactions and promotes/rolls back wallet state
14
+ - Recovery on restart via stale `locked_at` detection
15
+ - Idempotent enqueue on duplicate txid (crash recovery)
16
+ - `MAX_ATTEMPTS` enforcement (5) prevents infinite retry loops
17
+ - Guard refuses MemoryStore attachment
18
+
19
+ ### Fixed
20
+ - Migration timestamps use `timestamptz` (matching migration 004 pattern)
21
+ - `start()` check-and-set is atomic under mutex (prevents TOCTOU double-spawn)
22
+ - Deserialization failures mark job as failed immediately (no tight retry loop)
23
+
8
24
  ## 0.3.1 — 2026-04-12
9
25
 
10
26
  ### Fixed
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Broadcast job queue table for the SolidQueueAdapter.
4
+ #
5
+ # Tracks outbound transactions that need to be broadcast to the network,
6
+ # with state machine, retry counters, and advisory locking support so that
7
+ # multiple worker processes can poll the queue without double-processing.
8
+ #
9
+ # === Columns
10
+ #
11
+ # * +txid+ — TEXT NOT NULL UNIQUE. Prevents duplicate queue entries
12
+ # for the same transaction.
13
+ #
14
+ # * +status+ — TEXT NOT NULL DEFAULT 'unsent'. State machine values:
15
+ # 'unsent', 'sending', 'completed', 'failed'.
16
+ #
17
+ # * +beef_hex+ — TEXT NOT NULL. Serialised BEEF payload to broadcast.
18
+ #
19
+ # * +input_outpoints+ — text[], nullable. Outpoints consumed by the tx, used
20
+ # to release UTXOs on success or rollback on failure.
21
+ #
22
+ # * +change_outpoints+ — text[], nullable. Change outpoints created by the tx,
23
+ # used to mark outputs spendable after confirmation.
24
+ #
25
+ # * +fund_ref+ — TEXT, nullable. Caller-supplied funding reference for
26
+ # observability and reconciliation.
27
+ #
28
+ # * +attempts+ — INTEGER NOT NULL DEFAULT 0. Incremented on each
29
+ # broadcast attempt; gates retry logic.
30
+ #
31
+ # * +last_error+ — TEXT, nullable. Message from the last failed attempt.
32
+ #
33
+ # * +locked_at+ — TIMESTAMPTZ, nullable. Set when a worker claims the
34
+ # job; cleared on completion or stale-lock recovery.
35
+ #
36
+ # * +created_at+ — TIMESTAMPTZ NOT NULL DEFAULT NOW().
37
+ #
38
+ # * +updated_at+ — TIMESTAMPTZ NOT NULL DEFAULT NOW().
39
+ #
40
+ # === Index
41
+ #
42
+ # A composite index on +(status, locked_at)+ (named +broadcast_jobs_poll_idx+)
43
+ # supports the hot-path poll query:
44
+ #
45
+ # WHERE status = 'unsent'
46
+ # OR (status = 'sending' AND locked_at < NOW() - interval '300 seconds'
47
+ # AND attempts < 5)
48
+ # ORDER BY created_at
49
+ # FOR UPDATE SKIP LOCKED
50
+ Sequel.migration do
51
+ change do
52
+ create_table(:wallet_broadcast_jobs) do
53
+ primary_key :id, type: :Bignum
54
+ String :txid, null: false, unique: true
55
+ String :status, null: false, default: 'unsent'
56
+ String :beef_hex, text: true, null: false
57
+ column :input_outpoints, 'text[]'
58
+ column :change_outpoints, 'text[]'
59
+ String :fund_ref
60
+ Integer :attempts, null: false, default: 0
61
+ String :last_error, text: true
62
+ column :locked_at, :timestamptz
63
+ column :created_at, :timestamptz, null: false, default: Sequel::CURRENT_TIMESTAMP
64
+ column :updated_at, :timestamptz, null: false, default: Sequel::CURRENT_TIMESTAMP
65
+ index %i[status locked_at], name: :broadcast_jobs_poll_idx
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,317 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sequel'
4
+
5
+ module BSV
6
+ module Wallet
7
+ # PostgreSQL-backed asynchronous broadcast queue adapter.
8
+ #
9
+ # Persists outbound transactions to the +wallet_broadcast_jobs+ table and
10
+ # processes them in a background worker thread. Designed for multi-process
11
+ # deployments where +InlineQueue+'s synchronous broadcast is unacceptable.
12
+ #
13
+ # The worker uses +SELECT ... FOR UPDATE SKIP LOCKED+ so multiple processes
14
+ # can run +SolidQueueAdapter+ against the same database safely — only one
15
+ # worker will claim a given job.
16
+ #
17
+ # === Lifecycle
18
+ #
19
+ # adapter = BSV::Wallet::SolidQueueAdapter.new(
20
+ # db: sequel_db,
21
+ # storage: postgres_store,
22
+ # broadcaster: arc_broadcaster
23
+ # )
24
+ # adapter.start # spawns background worker thread
25
+ # # ... process transactions ...
26
+ # adapter.drain # stop + join (blocks until current poll cycle completes)
27
+ #
28
+ # === Recovery
29
+ #
30
+ # On restart, the worker's first poll naturally finds stale +sending+ jobs
31
+ # (those whose +locked_at+ is older than +stale_threshold+ seconds) and
32
+ # re-broadcasts them. No special recovery code is needed.
33
+ #
34
+ # === Drain warning
35
+ #
36
+ # +drain+ blocks until the current poll cycle completes. If a job is
37
+ # mid-broadcast this may take several seconds. Jobs enqueued after
38
+ # +@running = false+ but before the worker thread exits will remain
39
+ # +unsent+ until the next +start+.
40
+ class SolidQueueAdapter
41
+ include BSV::Wallet::BroadcastQueue
42
+
43
+ # Default number of seconds between poll cycles.
44
+ DEFAULT_POLL_INTERVAL = 8
45
+
46
+ # Default number of seconds before a +sending+ job is considered stale
47
+ # and eligible for retry. Configurable for testability.
48
+ STALE_THRESHOLD = 300
49
+
50
+ # Maximum number of broadcast attempts before a job is abandoned.
51
+ # After this many failures the job remains +failed+ permanently.
52
+ MAX_ATTEMPTS = 5
53
+
54
+ # @param db [Sequel::Database] a Sequel database handle (shared with PostgresStore)
55
+ # @param storage [StorageAdapter] wallet storage adapter (must not be MemoryStore)
56
+ # @param broadcaster [#broadcast] broadcaster object
57
+ # @param poll_interval [Integer] seconds between worker poll cycles
58
+ # @param stale_threshold [Integer] seconds before a +sending+ job is retried
59
+ # @raise [ArgumentError] if +storage+ is a +BSV::Wallet::MemoryStore+
60
+ # @raise [ArgumentError] if +broadcaster+ is +nil+
61
+ def initialize(db:, storage:, broadcaster:, poll_interval: DEFAULT_POLL_INTERVAL, stale_threshold: STALE_THRESHOLD)
62
+ if storage.is_a?(BSV::Wallet::MemoryStore)
63
+ raise ArgumentError, 'SolidQueueAdapter requires a persistent storage adapter — MemoryStore is not supported'
64
+ end
65
+ raise ArgumentError, 'SolidQueueAdapter requires a broadcaster' if broadcaster.nil?
66
+
67
+ @db = db
68
+ @storage = storage
69
+ @broadcaster = broadcaster
70
+ @poll_interval = poll_interval
71
+ @stale_threshold = stale_threshold
72
+
73
+ @mutex = Mutex.new
74
+ @running = false
75
+ @worker_thread = nil
76
+ end
77
+
78
+ # Returns +true+ — this adapter executes broadcast asynchronously.
79
+ #
80
+ # @return [Boolean]
81
+ def async?
82
+ true
83
+ end
84
+
85
+ # Persists a transaction to the broadcast job queue and returns immediately.
86
+ #
87
+ # Inserts a row into +wallet_broadcast_jobs+ with status +unsent+. If a row
88
+ # already exists for the same +txid+ (e.g. after a crash and restart), the
89
+ # +UniqueConstraintViolation+ is rescued and the existing status is returned
90
+ # instead.
91
+ #
92
+ # @param payload [Hash] broadcast payload (see +BroadcastQueue+ module docs)
93
+ # @return [Hash] +{ txid: String, broadcast_status: 'sending' }+
94
+ def enqueue(payload)
95
+ txid = payload[:txid]
96
+ beef_binary = payload[:beef_binary]
97
+ input_outpoints = payload[:input_outpoints]
98
+ change_outpoints = payload[:change_outpoints]
99
+ fund_ref = payload[:fund_ref]
100
+
101
+ row = {
102
+ txid: txid,
103
+ beef_hex: beef_binary.unpack1('H*'),
104
+ input_outpoints: input_outpoints ? Sequel.pg_array(input_outpoints, :text) : nil,
105
+ change_outpoints: change_outpoints ? Sequel.pg_array(change_outpoints, :text) : nil,
106
+ fund_ref: fund_ref,
107
+ status: 'unsent'
108
+ }
109
+
110
+ begin
111
+ @db[:wallet_broadcast_jobs].insert(row)
112
+ rescue Sequel::UniqueConstraintViolation
113
+ existing_status = @db[:wallet_broadcast_jobs].where(txid: txid).get(:status)
114
+ return { txid: txid, broadcast_status: existing_status || 'sending' }
115
+ end
116
+
117
+ { txid: txid, broadcast_status: 'sending' }
118
+ end
119
+
120
+ # Returns the broadcast status for a previously enqueued transaction.
121
+ #
122
+ # Reads directly from the jobs table — the authoritative source of truth
123
+ # for async broadcast status.
124
+ #
125
+ # @param txid [String] hex transaction identifier
126
+ # @return [String, nil] status string or +nil+ if no job exists
127
+ def status(txid)
128
+ @db[:wallet_broadcast_jobs].where(txid: txid).get(:status)
129
+ end
130
+
131
+ # Spawns the background worker thread.
132
+ #
133
+ # Safe to call multiple times — returns immediately if already running.
134
+ # The check-and-set is atomic under the mutex to prevent two concurrent
135
+ # +start+ calls from spawning duplicate worker threads.
136
+ #
137
+ # @return [void]
138
+ def start
139
+ @mutex.synchronize do
140
+ return if @running || @worker_thread&.alive?
141
+
142
+ @running = true
143
+ end
144
+ @worker_thread = Thread.new { worker_loop }
145
+ end
146
+
147
+ # Signals the worker to stop after the current poll cycle.
148
+ #
149
+ # Non-blocking — returns immediately without waiting for the thread.
150
+ #
151
+ # @return [void]
152
+ def stop
153
+ @mutex.synchronize { @running = false }
154
+ end
155
+
156
+ # Stops the worker and blocks until the current poll cycle completes.
157
+ #
158
+ # Safe to call when +start+ has not been called (+@worker_thread+ is nil).
159
+ #
160
+ # @return [void]
161
+ def drain
162
+ stop
163
+ @worker_thread&.join
164
+ end
165
+
166
+ private
167
+
168
+ # Main loop for the background worker thread.
169
+ #
170
+ # Calls +poll_once+ in a loop, sleeping +poll_interval+ seconds between
171
+ # cycles. Unexpected errors from +poll_once+ are logged to stderr and the
172
+ # loop continues — the worker thread must not die silently.
173
+ def worker_loop
174
+ while running?
175
+ begin
176
+ poll_once
177
+ rescue StandardError => e
178
+ warn "[bsv-wallet] SolidQueueAdapter worker error: #{e.message}"
179
+ end
180
+ sleep(@poll_interval) if running?
181
+ end
182
+ end
183
+
184
+ # Thread-safe check of the running flag.
185
+ #
186
+ # @return [Boolean]
187
+ def running?
188
+ @mutex.synchronize { @running }
189
+ end
190
+
191
+ # Claims and processes a single pending broadcast job.
192
+ #
193
+ # Uses +SELECT ... FOR UPDATE SKIP LOCKED+ so concurrent workers do not
194
+ # double-process the same job. Stale +sending+ jobs (those whose +locked_at+
195
+ # is older than +stale_threshold+ seconds) are also eligible for retry,
196
+ # up to +MAX_ATTEMPTS+.
197
+ #
198
+ # NOTE: The entire +process_job+ call (including the network broadcast)
199
+ # runs inside this transaction, holding the row lock and a database
200
+ # connection for the duration. This is acceptable for a single worker
201
+ # thread with an 8-second poll interval but would need restructuring
202
+ # for high-throughput multi-worker deployments.
203
+ #
204
+ # @return [void]
205
+ def poll_once
206
+ @db.transaction do
207
+ job = @db[:wallet_broadcast_jobs]
208
+ .where(Sequel.lit(
209
+ "status = 'unsent' OR " \
210
+ "(status = 'sending' AND locked_at < NOW() - " \
211
+ "#{@stale_threshold.to_i} * interval '1 second' " \
212
+ "AND attempts < #{MAX_ATTEMPTS})"
213
+ ))
214
+ .order(:created_at)
215
+ .limit(1)
216
+ .for_update
217
+ .skip_locked
218
+ .first
219
+ return unless job
220
+
221
+ process_job(job)
222
+ end
223
+ end
224
+
225
+ # Marks a job as +sending+, broadcasts the transaction, and promotes or
226
+ # rolls back wallet state based on the outcome.
227
+ #
228
+ # Deserialization failures (corrupt +beef_hex+) are caught and mark the
229
+ # job as +failed+ immediately to prevent infinite retry loops.
230
+ #
231
+ # @param job [Hash] row from +wallet_broadcast_jobs+
232
+ # @return [void]
233
+ def process_job(job)
234
+ @db[:wallet_broadcast_jobs].where(id: job[:id]).update(
235
+ status: 'sending',
236
+ locked_at: Sequel.lit('NOW()'),
237
+ attempts: Sequel.lit('attempts + 1'),
238
+ updated_at: Sequel.lit('NOW()')
239
+ )
240
+
241
+ begin
242
+ tx = BSV::Transaction::Transaction.from_beef_hex(job[:beef_hex])
243
+ rescue StandardError => e
244
+ @db[:wallet_broadcast_jobs].where(id: job[:id]).update(
245
+ status: 'failed',
246
+ last_error: "Deserialization failed: #{e.message}",
247
+ updated_at: Sequel.lit('NOW()')
248
+ )
249
+ return
250
+ end
251
+
252
+ input_outpoints = job[:input_outpoints]&.to_a
253
+ change_outpoints = job[:change_outpoints]&.to_a
254
+ txid = job[:txid]
255
+ fund_ref = job[:fund_ref]
256
+
257
+ begin
258
+ @broadcaster.broadcast(tx)
259
+ rescue StandardError => e
260
+ if input_outpoints && !input_outpoints.empty?
261
+ rollback(input_outpoints, change_outpoints, txid, fund_ref)
262
+ elsif txid
263
+ @storage.update_action_status(txid, 'failed')
264
+ end
265
+ @db[:wallet_broadcast_jobs].where(id: job[:id]).update(
266
+ status: 'failed',
267
+ last_error: e.message,
268
+ updated_at: Sequel.lit('NOW()')
269
+ )
270
+ return
271
+ end
272
+
273
+ promote(input_outpoints, change_outpoints, txid)
274
+ @db[:wallet_broadcast_jobs].where(id: job[:id]).update(
275
+ status: 'completed',
276
+ updated_at: Sequel.lit('NOW()')
277
+ )
278
+ end
279
+
280
+ # Promotes UTXO state after a successful broadcast.
281
+ #
282
+ # Marks inputs as +:spent+, change as +:spendable+, and updates the action
283
+ # status to +completed+. When outpoints are +nil+ (finalize path), UTXO
284
+ # transitions are skipped.
285
+ #
286
+ # @param input_outpoints [Array<String>, nil]
287
+ # @param change_outpoints [Array<String>, nil]
288
+ # @param txid [String, nil]
289
+ def promote(input_outpoints, change_outpoints, txid)
290
+ Array(input_outpoints).each { |op| @storage.update_output_state(op, :spent) }
291
+ Array(change_outpoints).each { |op| @storage.update_output_state(op, :spendable) }
292
+ @storage.update_action_status(txid, 'completed') if txid
293
+ end
294
+
295
+ # Rolls back wallet state after a failed broadcast.
296
+ #
297
+ # Releases locked inputs (only those matching +fund_ref+), deletes phantom
298
+ # change outputs, and marks the action as +failed+.
299
+ #
300
+ # @param input_outpoints [Array<String>]
301
+ # @param change_outpoints [Array<String>]
302
+ # @param txid [String, nil]
303
+ # @param fund_ref [String] fund reference used when locking inputs
304
+ def rollback(input_outpoints, change_outpoints, txid, fund_ref)
305
+ Array(input_outpoints).each do |op|
306
+ outputs = @storage.find_outputs({ outpoint: op, include_spent: true, limit: 1, offset: 0 })
307
+ next if outputs.empty?
308
+ next unless outputs.first[:pending_reference] == fund_ref
309
+
310
+ @storage.update_output_state(op, :spendable)
311
+ end
312
+ Array(change_outpoints).each { |op| @storage.delete_output(op) }
313
+ @storage.update_action_status(txid, 'failed') if txid
314
+ end
315
+ end
316
+ end
317
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module BSV
4
4
  module WalletPostgres
5
- VERSION = '0.3.1'
5
+ VERSION = '0.4.0'
6
6
  end
7
7
  end
@@ -8,5 +8,6 @@ module BSV
8
8
 
9
9
  module Wallet
10
10
  autoload :PostgresStore, 'bsv/wallet_postgres/postgres_store'
11
+ autoload :SolidQueueAdapter, 'bsv/wallet_postgres/solid_queue_adapter'
11
12
  end
12
13
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bsv-wallet-postgres
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Simon Bettison
@@ -72,7 +72,9 @@ files:
72
72
  - lib/bsv/wallet_postgres/migrations/003_add_wallet_settings.rb
73
73
  - lib/bsv/wallet_postgres/migrations/004_add_pending_metadata.rb
74
74
  - lib/bsv/wallet_postgres/migrations/005_add_txid_unique_index.rb
75
+ - lib/bsv/wallet_postgres/migrations/006_create_broadcast_jobs.rb
75
76
  - lib/bsv/wallet_postgres/postgres_store.rb
77
+ - lib/bsv/wallet_postgres/solid_queue_adapter.rb
76
78
  - lib/bsv/wallet_postgres/version.rb
77
79
  homepage: https://github.com/sgbett/bsv-ruby-sdk
78
80
  licenses: