openreceive-rails 0.4.10 → 0.4.13

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: 624dd853dd3e316cd20bacb9a6c8f669ddb61558c6540d05da6000cc71d3efd2
4
- data.tar.gz: b2acac5e32343925c1d6974cb7ee1451f4ea34335aec82921feebe05c33b90e8
3
+ metadata.gz: c17784f21721177e139d27349d7fa0367f2e6f90cce005e5dd61906ac5fae96a
4
+ data.tar.gz: f285c9fb57805628956740699f078115672f1485916784fbc2e4b6d8738b1578
5
5
  SHA512:
6
- metadata.gz: 86971442440a6ce6dc19c7f884ebac554e0a28999976ad6761b3843fbe1dee6daa9bb03087863e14f651d644140be222dace9e84df6b37de823af5b4151c71a0
7
- data.tar.gz: 1cf150cc363e76166a216f830a550a42d4615f0c99f287f9e9d69dc537fd60754e1fc26e3a1558a584b33056c88a49ccfe23d420ab565fdb4b4a14c65f3cfbeb
6
+ metadata.gz: 16634ea35e356230c34b4da9ecf58bc8c089835d1b919692e41ddda40ef127fbddc23e5aa3090ba62835f90ba23b393d562a9006df015cbb5a84c2c16ec0ee35
7
+ data.tar.gz: 5d70f67f044bfa1003fb963da3e3e8ffe620f27517662d8fd1199ae0972906c7fcc1172196b8b8704d9c4354485700e2732dcf7d99ea818772cb19999a045296
data/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.13 - 2026-10-01
4
+
5
+ Reconciliation no longer livelocks on a slow wallet. A wallet-history page
6
+ still in flight at the scan deadline (`Timeout` in the nwc-ruby adapter) failed
7
+ the whole pass and dropped its resume offset, so a long walk restarted at
8
+ offset 0 on every pass and logged `reconciliation failed (will retry):
9
+ OpenReceive::Server::WalletFailureError: execution expired` forever. A cut page
10
+ now ends the slice and keeps the completed pages' progress. A first page cut
11
+ before the wallet answers still fails the pass. Host-clock attempts (legacy
12
+ rows without `created_at_source`) get their own full-history cohort instead of
13
+ widening the window of every wallet-timed attempt pending with them. The gate
14
+ floor rises from 2 s to 3 s (`OpenReceive::MIN_RECONCILE_INTERVAL_SECONDS`).
15
+
16
+ ## 0.4.12 - 2026-10-01
17
+
18
+ Release in lockstep with the 0.4.12 checkout fixes. The Rails Buy a Button
19
+ demo's checkout now returns to the method grid on every "Switch payment
20
+ method" click. No Rails engine API change.
21
+
22
+ ## 0.4.11 - 2026-09-21
23
+
24
+ Reconciliation progress is durable and bounded. A capped wallet walk now
25
+ stores its window — attempt identities, page offset, view and page
26
+ fingerprint — in `OpenReceive::ReconcileScan`, so the next request or worker
27
+ resumes where the last one stopped instead of restarting the history, and a
28
+ resumed scan is never treated as proof that a payment is absent. Scans carry
29
+ a deadline into the wallet request, and fulfillment commits in the same
30
+ transaction as settlement.
31
+
32
+ The default repository maps an unexpected persistence failure to
33
+ `HostPersistenceError` (retryable 503, payer instructions withheld) rather
34
+ than letting it surface as an unrelated error.
35
+
3
36
  ## 0.4.10 - 2026-09-16
4
37
 
5
38
  Release alongside the packaged checkout fix that shows one amount to send
data/README.md CHANGED
@@ -98,3 +98,36 @@ overrides it. Keep ordinary settings such as `config.price_currencies` in
98
98
  - Changelog: [CHANGELOG.md](CHANGELOG.md)
99
99
 
100
100
  MIT license.
101
+
102
+ ### Reconciliation upgrades and operator recovery
103
+
104
+ Explicit jobs, the notifications worker, notification fallback and mounted routes
105
+ share the durable lease/CAS gate and bounded scheduler. Disabling
106
+ `opportunistic_reconcile` disables request triggers only. Stop old application and
107
+ worker processes before deploying this scheduler contract, then start the updated
108
+ processes together. Resumed history slices discover positive wallet finality but
109
+ never prove absence; unpaid dense histories stay pending until a fresh complete
110
+ covering scan fits the budget. Deposit countdown/reuse expiry remains separate
111
+ from the saved Lightning invoice's settlement deadline.
112
+
113
+ `OpenReceivePayment.maintenance_candidates(after: cursor, limit: 100)` is a
114
+ read-only report with `candidates`, `next_cursor` and `scanned`. An operator can
115
+ review a selected candidate and call
116
+ `OpenReceivePayment.requeue_reviewed_attempt!(candidate, decision_id: "ticket-42")`.
117
+ The model checks the unchanged row under its reference lock, records the decision
118
+ in `openreceive_meta`, and requeues it without granting fulfillment. The ordinary
119
+ gated reconciler must still discover wallet finality. Settled rows remain
120
+ immutable, and genuine sibling payments never grant a second entitlement.
121
+ Candidates distinguish early swap-deadline closure from ordinary operator
122
+ attention; reports contain no swap credentials. Follow the
123
+ [coordinated upgrade guide](https://github.com/OpenReceive/openreceive/blob/master/docs/guides/payment-safety-upgrade.md).
124
+
125
+ MySQL repository operations require an outermost transaction; wrapping them in
126
+ an ambient ActiveRecord transaction is rejected before taking the named lock.
127
+
128
+ Fulfillment database writes and a host outbox belong in `on_paid`; a callback can
129
+ run again after rollback. External jobs need their own durable idempotency.
130
+ Storage-free `openreceive-server` handlers can call `on_paid` on every settled
131
+ poll, so advanced hosts own the conditional write/outbox. A raw create hook
132
+ refusal returns 409 with instructions withheld; repository infrastructure failures
133
+ remain retryable 503.
@@ -68,29 +68,58 @@ class OpenReceiveMeta < ActiveRecord::Base
68
68
  end
69
69
  end
70
70
 
71
- # Claim the durable global reconcile gate. Returns true when this caller may
72
- # run a wallet scan now; false (gate_busy) when another worker scanned within
73
- # interval_seconds. The winner is identified by reading back its own token —
74
- # the portable equivalent of an affected-row count, matching the JS
75
- # claimReconcileGate. A failed scan leaves claimed_at in place on purpose —
76
- # the next interval retries without a stampede.
77
- def self.claim_reconcile_gate(now:, interval_seconds:)
71
+ # The durable global scan gate returns a token/scheduler claim or nil.
72
+ # Checkpoints require that token and an unexpired lease; failed scans retain
73
+ # the interval and pre-scan queue so another worker can retry fairly.
74
+ def self.claim_reconcile_gate(now:, interval_seconds:, lease_seconds: 10)
78
75
  assert_supported_schema!
79
- claim = JSON.generate("claimed_at" => Integer(now), "token" => SecureRandom.uuid)
76
+ now = Integer(now)
80
77
  CAS_RETRIES.times do
81
78
  row = find_by(key: RECONCILE_GATE_KEY)
82
- if row.nil?
83
- cas(RECONCILE_GATE_KEY, claim, nil)
84
- else
85
- claimed_at = parse_claimed_at(row.value)
86
- if claimed_at && fresh_timestamp?(Integer(now), claimed_at, Integer(interval_seconds))
87
- return false
88
- end
89
- cas(RECONCILE_GATE_KEY, claim, row.rev)
90
- end
91
- return true if where(key: RECONCILE_GATE_KEY).pick(:value) == claim
79
+ current = parse_gate(row&.value)
80
+ claimed = current["claimed_at"]
81
+ return nil if claimed && fresh_timestamp?(now, claimed, interval_seconds)
82
+ return nil if current.fetch("lease_until", 0) > now && current.fetch("claimed_at", 0) <= now + 60
83
+
84
+ gate = {
85
+ "version" => 1, "claimed_at" => now, "token" => SecureRandom.uuid,
86
+ "lease_until" => now + lease_seconds, "interval_seconds" => interval_seconds,
87
+ "scheduler" => current.fetch("scheduler", { "cursor" => nil, "windows" => [] })
88
+ }
89
+ next unless cas(RECONCILE_GATE_KEY, JSON.generate(gate), row&.rev)
90
+
91
+ return { "token" => gate.fetch("token"), "scheduler" => gate.fetch("scheduler") }
92
92
  end
93
- false
93
+ nil
94
+ end
95
+
96
+ def self.checkpoint_reconcile_gate(claim, scheduler, now:, release: false)
97
+ assert_supported_schema!
98
+ row = find_by(key: RECONCILE_GATE_KEY)
99
+ return false if row.nil?
100
+
101
+ gate = parse_gate(row.value)
102
+ return false unless gate["token"] == claim.fetch("token") && gate.fetch("lease_until", 0) > now
103
+
104
+ windows = scheduler.fetch("windows")
105
+ raise ArgumentError, "Reconciliation checkpoint exceeded bounded cohorts" if windows.length > 2 || windows.any? { |w| w.fetch("attempts").length > 200 }
106
+
107
+ gate["scheduler"] = scheduler
108
+ gate["lease_until"] = 0 if release
109
+ encoded = JSON.generate(gate)
110
+ raise ArgumentError, "Reconciliation checkpoint exceeded 128 KiB" if encoded.bytesize > 128 * 1024
111
+
112
+ cas(RECONCILE_GATE_KEY, encoded, row.rev)
113
+ end
114
+
115
+ def self.parse_gate(value)
116
+ gate = value.nil? ? {} : JSON.parse(value.to_s)
117
+ gate = {} unless gate.is_a?(Hash)
118
+ raise OpenReceive::ConfigurationError, "Unsupported reconciliation checkpoint version; upgrade OpenReceive." if gate.fetch("version", 0) > 1
119
+
120
+ gate["version"] == 1 ? gate : { "scheduler" => { "cursor" => nil, "windows" => [] } }
121
+ rescue JSON::ParserError
122
+ { "scheduler" => { "cursor" => nil, "windows" => [] } }
94
123
  end
95
124
 
96
125
  def self.stored_schema_version
@@ -113,13 +142,5 @@ class OpenReceiveMeta < ActiveRecord::Base
113
142
  age < window_seconds
114
143
  end
115
144
 
116
- def self.parse_claimed_at(value)
117
- parsed = JSON.parse(value.to_s)
118
- claimed_at = parsed["claimed_at"]
119
- claimed_at.is_a?(Numeric) ? Integer(claimed_at) : nil
120
- rescue JSON::ParserError
121
- nil
122
- end
123
-
124
- private_class_method :stored_schema_version, :fresh_timestamp?, :parse_claimed_at
145
+ private_class_method :stored_schema_version, :fresh_timestamp?
125
146
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "digest"
4
+ require "time"
4
5
 
5
6
  # Engine-owned payment attempts. The table lives in the host application's
6
7
  # database (the install generator emits the migration), but the schema, locking,
@@ -88,6 +89,10 @@ class OpenReceivePayment < ActiveRecord::Base
88
89
  # COMMIT — releasing inside the transaction would leave a window where a
89
90
  # second worker could commit against state this one has already read.
90
91
  def self.with_mysql_reference_lock(key)
92
+ if connection.transaction_open?
93
+ raise RuntimeError, "OpenReceive MySQL reference operations require an outermost transaction; ambient transactions cannot retain the connection-scoped reference lock."
94
+ end
95
+
91
96
  name = "openreceive:#{Digest::SHA256.hexdigest(key)[0, 40]}"
92
97
  acquired = connection.select_value(
93
98
  sanitize_sql_array(["SELECT GET_LOCK(?, ?)", name, MYSQL_LOCK_TIMEOUT_SECONDS])
@@ -218,7 +223,7 @@ class OpenReceivePayment < ActiveRecord::Base
218
223
 
219
224
  with_reference_lock(payment.reference) do
220
225
  payment.reload
221
- return payment if payment.status == "settled"
226
+ return payment unless payment.status == "pending"
222
227
 
223
228
  first_for_order = !where(reference: payment.reference).settled.exists?
224
229
  payment.update!(
@@ -240,11 +245,16 @@ class OpenReceivePayment < ActiveRecord::Base
240
245
  raise ArgumentError, "invalid reconciliation status: #{status_text}"
241
246
  end
242
247
 
243
- where(payment_hash: payment_hash.to_s.downcase, status: "pending").update_all(
244
- status: status_text,
245
- status_reason: reason,
246
- updated_at: Time.at(Integer(observed_at)).utc
247
- )
248
+ payment = find_by(payment_hash: payment_hash.to_s.downcase)
249
+ return if payment.nil?
250
+
251
+ with_reference_lock(payment.reference) do
252
+ where(payment_hash: payment.payment_hash, status: "pending").update_all(
253
+ status: status_text,
254
+ status_reason: reason,
255
+ updated_at: Time.at(Integer(observed_at)).utc
256
+ )
257
+ end
248
258
  end
249
259
 
250
260
  # Pending attempts for the reconciler's next wallet scan — oldest first, one
@@ -252,19 +262,103 @@ class OpenReceivePayment < ActiveRecord::Base
252
262
  # closest to their closure deadline are always covered, and a backlog drains
253
263
  # over several passes instead of widening one wallet scan window without
254
264
  # bound. Terminal rows never return.
255
- def self.reconcilable_attempts
265
+ def self.reconcilable_attempts(after: nil, limit: OpenReceive::Server::RECONCILE_BATCH_SIZE)
256
266
  OpenReceiveMeta.assert_supported_schema!
257
- pending.order(created_at: :asc)
258
- .limit(OpenReceive::Server::RECONCILE_BATCH_SIZE)
259
- .pluck(:payment_hash, :created_at, :expires_at).map do |hash, created_at, expires_at|
267
+ query = pending.order(created_at: :asc, payment_hash: :asc)
268
+ if after
269
+ created = Time.at(after.fetch("created_at")).utc
270
+ query = query.where("created_at > ? OR (created_at = ? AND payment_hash > ?)", created, created, after.fetch("payment_hash"))
271
+ end
272
+ query.limit([limit, OpenReceive::Server::RECONCILE_BATCH_SIZE].min)
273
+ .pluck(:payment_hash, :created_at, :checkout_data).map do |hash, created_at, checkout|
260
274
  {
261
- "payment_hash" => hash,
262
- "created_at" => created_at.to_i,
263
- "expires_at" => expires_at.to_i
275
+ "payment_hash" => hash, "created_at" => created_at.to_i,
276
+ "expires_at" => settlement_expires_at(checkout, hash),
277
+ "created_at_source" => checkout["created_at_source"] || checkout[:created_at_source] || "host"
264
278
  }
265
279
  end
266
280
  end
267
281
 
282
+ # Same reconciliation DTO for authenticated by-hash notification lookup.
283
+ def self.find_pending_attempt(payment_hash)
284
+ OpenReceiveMeta.assert_supported_schema!
285
+ row = pending.where(payment_hash: payment_hash.to_s.downcase).pick(:payment_hash, :created_at, :checkout_data)
286
+ return nil if row.nil?
287
+
288
+ hash, created_at, checkout = row
289
+ {
290
+ "payment_hash" => hash, "created_at" => created_at.to_i,
291
+ "expires_at" => settlement_expires_at(checkout, hash),
292
+ "created_at_source" => checkout["created_at_source"] || checkout[:created_at_source] || "host"
293
+ }
294
+ end
295
+
296
+ # Saved Lightning deadline, independent of the payer's deposit countdown.
297
+ def self.settlement_expires_at(checkout, payment_hash)
298
+ value = checkout[:expires_at] || checkout["expires_at"] || checkout[:expiresAt] || checkout["expiresAt"]
299
+ raise ArgumentError unless value.is_a?(Integer) || (value.is_a?(String) && value.match?(/\A[0-9]+\z/))
300
+
301
+ expiry = Integer(value)
302
+ raise ArgumentError if expiry <= 0
303
+
304
+ expiry
305
+ rescue TypeError, ArgumentError, NoMethodError
306
+ raise RuntimeError, "Corrupt checkout_data wallet expiry on openreceive payment attempt #{payment_hash}."
307
+ end
308
+
309
+ # Host-invoked dry-run report. Normal routes never read terminal repair candidates.
310
+ def self.maintenance_candidates(after: nil, limit: 100)
311
+ OpenReceiveMeta.assert_supported_schema!
312
+ limit = [[limit, 1].max, 1000].min
313
+ query = where(status: %w[attention expired])
314
+ if after
315
+ stamp = Time.iso8601(after.fetch("updated_at"))
316
+ query = query.where("updated_at > ? OR (updated_at = ? AND payment_hash > ?)", stamp, stamp, after.fetch("payment_hash"))
317
+ end
318
+ rows = query.order(:updated_at, :payment_hash).limit(limit).to_a
319
+ cursor = rows.length == limit ? { "updated_at" => rows.last.updated_at.utc.iso8601(6), "payment_hash" => rows.last.payment_hash } : nil
320
+ { "candidates" => rows.filter_map { |row| repair_candidate(row) }, "next_cursor" => cursor, "scanned" => rows.length }
321
+ end
322
+
323
+ def self.requeue_reviewed_attempt!(candidate, decision_id:)
324
+ OpenReceiveMeta.assert_supported_schema!
325
+ unless /\A[A-Za-z0-9._:-]{1,120}\z/.match?(decision_id.to_s)
326
+ raise ArgumentError, "decision_id must be a nonsecret operator ticket identifier."
327
+ end
328
+ payment = find_by(payment_hash: candidate.fetch("payment_hash"))
329
+ return false if payment.nil?
330
+
331
+ with_reference_lock(payment.reference) do
332
+ payment.reload
333
+ return false unless repair_candidate(payment) == candidate
334
+
335
+ audit_key = "repair:#{payment.payment_hash}:#{decision_id}"
336
+ return false if OpenReceiveMeta.exists?(key: audit_key)
337
+
338
+ audit = candidate.merge("decision_id" => decision_id, "requeued_at" => Time.now.to_i)
339
+ OpenReceiveMeta.create!(key: audit_key, value: JSON.generate(audit), rev: 0)
340
+ payment.update!(status: "pending", status_reason: "operator_requeued")
341
+ true
342
+ end
343
+ end
344
+
345
+ def self.repair_candidate(row)
346
+ return nil unless %w[attention expired].include?(row.status)
347
+
348
+ wallet_expiry = settlement_expires_at(row.checkout_data, row.payment_hash)
349
+ reason = row.status == "attention" ? "operator_attention" : nil
350
+ if row.swap_data.present? && %w[not_found_after_expiry no_finality_after_expiry unsettled_after_expiry].include?(row.status_reason) &&
351
+ wallet_expiry > row.expires_at.to_i && row.updated_at.to_i >= row.expires_at.to_i + 900 && row.updated_at.to_i < wallet_expiry + 900
352
+ reason = "early_deposit_deadline_closure"
353
+ end
354
+ return nil if reason.nil?
355
+
356
+ { "reference" => row.reference, "payment_hash" => row.payment_hash,
357
+ "status" => row.status, "status_reason" => row.status_reason,
358
+ "updated_at" => row.updated_at.utc.iso8601(6), "instruction_expires_at" => row.expires_at.to_i,
359
+ "wallet_expires_at" => wallet_expiry, "reason" => reason }
360
+ end
361
+
268
362
  def self.reusable?(payment, now = Time.current)
269
363
  payment.expires_at.to_i - now.to_i > REUSE_BUFFER_SECONDS
270
364
  end
@@ -90,7 +90,7 @@ OpenReceive.configure do |config|
90
90
 
91
91
  # Settlement discovery is opportunistic by default: every engine request
92
92
  # first runs one reconcile pass through the durable openreceive_meta gate
93
- # (shared by all Puma workers; min 2s between real wallet scans), so pending
93
+ # (shared by all Puma workers; min 3s between real wallet scans), so pending
94
94
  # attempts settle or close on any later OpenReceive call — no scheduled job
95
95
  # required. Set false only if a dedicated worker owns scanning.
96
96
  # config.opportunistic_reconcile = false
@@ -81,7 +81,7 @@ module OpenReceive
81
81
  # durably gated reconcile pass when attempts are pending, so abandoned
82
82
  # checkouts settle on any later OpenReceive call with no scheduled job.
83
83
  # The openreceive_meta gate row is shared by every Puma worker/process on
84
- # the host database (min 2s between real wallet scans, stretched by
84
+ # the host database (min 3s between real wallet scans, stretched by
85
85
  # invoice age). Set false to disable (e.g. when the optional
86
86
  # `bin/rails openreceive:notifications` worker owns scanning), or a Hash
87
87
  # with min_interval_seconds to tune.
@@ -359,6 +359,8 @@ module OpenReceive
359
359
  # 409 CONFLICT. Leaving AttemptConflict unwrapped lets request_handler
360
360
  # #commit treat it as infrastructure failure (retryable 503 persist).
361
361
  raise OpenReceive::Server::ConflictError, e.message
362
+ rescue StandardError
363
+ raise OpenReceive::Server::HostPersistenceError
362
364
  end
363
365
  end
364
366
  end
@@ -13,13 +13,22 @@ module OpenReceive
13
13
  "",
14
14
  "WHAT OPENRECEIVE GUARANTEES",
15
15
  "",
16
- "Across every settlement path OpenReceive itself owns (wallet notifications,",
17
- "the opportunistic reconcile pass, an explicit reconcile job), the settlement",
18
- "hook runs AT MOST ONCE per reference. The library serializes on its own",
19
- "`{{table}}` rows, decides the winner there, and runs your hook",
20
- "inside that same transaction. A second payment to a second invoice for the",
21
- "same order is still recorded - with `status_reason = 'duplicate_settlement'`",
22
- "- but never fulfills a second time. You do not need to add a lock for this.",
16
+ "Repository-backed settlement paths (wallet notifications, gated reconciliation,",
17
+ "and explicit jobs) commit fulfillment for only the first settled attempt per",
18
+ "reference. The library locks its `{{table}}` rows, decides the winner,",
19
+ "and awaits your hook inside the same database transaction. A failure rolls",
20
+ "back payment and host writes together; the hook can run again on a retry.",
21
+ "A genuine second payment is still recorded with",
22
+ "`status_reason = 'duplicate_settlement'`, without another fulfillment.",
23
+ "",
24
+ "Write an entitlement or an outbox job through the supplied transaction. Email,",
25
+ "shipping APIs, and other external effects cannot commit atomically with it;",
26
+ "your outbox worker must use durable idempotency for external delivery.",
27
+ "A best-effort after-paid callback may be lost after commit and is not an outbox.",
28
+ "",
29
+ "Raw storage-free handlers do not provide this transaction: their on_paid hook",
30
+ "may run on every settled poll. Those hosts own the durable conditional write",
31
+ "or outbox themselves; process-local deduplication does not replace it.",
23
32
  "",
24
33
  "That makes the reference the unit of fulfillment: give every payable order",
25
34
  "its own reference, created before checkout, kept across retries, and never",
@@ -51,7 +60,7 @@ module OpenReceive
51
60
  "conditional UPDATE, take a row lock for the duration instead:",
52
61
  "",
53
62
  " SELECT * FROM orders WHERE id = :reference FOR UPDATE; -- postgres/mysql",
54
- " -- ...check state, ship, write the new state, all before COMMIT.",
63
+ " -- ...check state, grant entitlement/enqueue work, all before COMMIT.",
55
64
  "",
56
65
  "Run either one inside the transaction OpenReceive hands your settlement",
57
66
  "hook, so the order transition and the payment record commit together.",
@@ -5,6 +5,6 @@ module OpenReceive
5
5
  # top-level `::Rails` framework constant — engine code always references the framework as
6
6
  # `::Rails` to avoid shadowing.
7
7
  module Rails
8
- VERSION = "0.4.10"
8
+ VERSION = "0.4.13"
9
9
  end
10
10
  end