mixin_bot 3.0.0 → 3.0.2
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/AGENTS.md +1 -1
- data/API_COVERAGE.md +1 -0
- data/CHANGELOG.md +11 -1
- data/README.md +75 -1
- data/docs/agent/cookbook.md +23 -0
- data/lib/generators/mixin_bot/authentication/USAGE +29 -0
- data/lib/generators/mixin_bot/authentication/authentication_generator.rb +82 -0
- data/lib/generators/mixin_bot/authentication/templates/app/controllers/concerns/authentication.rb.tt +52 -0
- data/lib/generators/mixin_bot/authentication/templates/app/controllers/sessions_controller.rb.tt +24 -0
- data/lib/generators/mixin_bot/authentication/templates/app/models/current.rb.tt +4 -0
- data/lib/generators/mixin_bot/authentication/templates/app/models/session.rb.tt +3 -0
- data/lib/generators/mixin_bot/authentication/templates/app/models/user.rb.tt +12 -0
- data/lib/generators/mixin_bot/authentication/templates/app/views/sessions/new.html.erb.tt +3 -0
- data/lib/generators/mixin_bot/authentication/templates/config/initializers/omniauth.rb.tt +21 -0
- data/lib/generators/mixin_bot/blaze/USAGE +19 -0
- data/lib/generators/mixin_bot/blaze/blaze_generator.rb +43 -0
- data/lib/generators/mixin_bot/blaze/templates/app/mixin/handlers/text_handler.rb +14 -0
- data/lib/generators/mixin_bot/blaze/templates/config/initializers/mixin_bot_blaze.rb +23 -0
- data/lib/generators/mixin_bot/install/USAGE +24 -0
- data/lib/generators/mixin_bot/install/install_generator.rb +50 -0
- data/lib/generators/mixin_bot/notifications/USAGE +13 -0
- data/lib/generators/mixin_bot/notifications/notifications_generator.rb +49 -0
- data/lib/generators/mixin_bot/notifications/templates/app/notifications/payment_received_notification.rb +24 -0
- data/lib/generators/mixin_bot/outputs/USAGE +29 -0
- data/lib/generators/mixin_bot/outputs/outputs_generator.rb +50 -0
- data/lib/generators/mixin_bot/outputs/templates/app/mixin/processors/deposit_processor.rb +21 -0
- data/lib/generators/mixin_bot/outputs/templates/app/models/mixin_output.rb +8 -0
- data/lib/generators/mixin_bot/outputs/templates/db/migrate/create_mixin_outputs.rb.tt +26 -0
- data/lib/generators/mixin_bot/transfers/USAGE +37 -0
- data/lib/generators/mixin_bot/transfers/templates/app/jobs/mixin_transfers/perform_job.rb +13 -0
- data/lib/generators/mixin_bot/transfers/templates/app/jobs/mixin_transfers/reconcile_job.rb +14 -0
- data/lib/generators/mixin_bot/transfers/templates/app/models/mixin_transfer.rb +11 -0
- data/lib/generators/mixin_bot/transfers/templates/db/migrate/create_mixin_transfers.rb.tt +23 -0
- data/lib/generators/mixin_bot/transfers/transfers_generator.rb +67 -0
- data/lib/generators/mixin_bot.rb +10 -0
- data/lib/mixin_bot/api/app.rb +20 -0
- data/lib/mixin_bot/api/snapshot.rb +5 -0
- data/lib/mixin_bot/blaze/reactor.rb +8 -3
- data/lib/mixin_bot/blaze/router.rb +333 -0
- data/lib/mixin_bot/cache_store_adapter.rb +3 -1
- data/lib/mixin_bot/client.rb +2 -1
- data/lib/mixin_bot/computer.rb +3 -1
- data/lib/mixin_bot/json_decoder.rb +14 -0
- data/lib/mixin_bot/notifications/mixin_channel.rb +80 -0
- data/lib/mixin_bot/outputs/cursor_model.rb +33 -0
- data/lib/mixin_bot/outputs/envelope.rb +160 -0
- data/lib/mixin_bot/outputs/memory_stores.rb +97 -0
- data/lib/mixin_bot/outputs/poller.rb +178 -0
- data/lib/mixin_bot/outputs/processing_job.rb +54 -0
- data/lib/mixin_bot/outputs/processors.rb +124 -0
- data/lib/mixin_bot/outputs/receipt_model.rb +86 -0
- data/lib/mixin_bot/outputs.rb +42 -0
- data/lib/mixin_bot/rails/railtie.rb +44 -0
- data/lib/mixin_bot/rails/tasks/poller.rake +33 -0
- data/lib/mixin_bot/rails.rb +34 -0
- data/lib/mixin_bot/registry.rb +79 -0
- data/lib/mixin_bot/transfers/model.rb +112 -0
- data/lib/mixin_bot/transfers/performer.rb +64 -0
- data/lib/mixin_bot/transfers/reconcile.rb +53 -0
- data/lib/mixin_bot/transfers.rb +23 -0
- data/lib/mixin_bot/version.rb +1 -1
- data/lib/mixin_bot.rb +3 -0
- data/llms.txt +1 -1
- metadata +51 -2
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MixinBot
|
|
4
|
+
module Outputs
|
|
5
|
+
##
|
|
6
|
+
# In-memory receipt store: the dedup gate + enqueue bookkeeping the poller
|
|
7
|
+
# needs, backed by nothing but a Hash. Used by the offline test suite and
|
|
8
|
+
# available for non-ActiveRecord hosts; Rails applications use the
|
|
9
|
+
# generated +MixinOutput+ model (see {ReceiptModel}), which implements the
|
|
10
|
+
# same interface:
|
|
11
|
+
#
|
|
12
|
+
# record!(bot_app_id:, output:) # => [receipt, :created] or
|
|
13
|
+
# # [existing, :duplicate]
|
|
14
|
+
# mark_enqueued!(receipt) # stamp the receipt as dispatched
|
|
15
|
+
# unenqueued(bot_app_id:, older_than:) # records not yet dispatched
|
|
16
|
+
# cursor_value(bot_app_id:) # poller resume position
|
|
17
|
+
#
|
|
18
|
+
class MemoryReceiptStore
|
|
19
|
+
##
|
|
20
|
+
# A receipt row: the poller's dedup + cache record for one output.
|
|
21
|
+
# Field names match the generated +mixin_outputs+ table columns.
|
|
22
|
+
#
|
|
23
|
+
Receipt = Struct.new(
|
|
24
|
+
:id, :bot_app_id, :output_id, :amount, :asset_id, :state,
|
|
25
|
+
:transaction_hash, :output_index, :sequence, :created_at,
|
|
26
|
+
:memo, :opponent_id, :trace_id, :snapshot_bridged,
|
|
27
|
+
:enqueued_at, :recorded_at,
|
|
28
|
+
keyword_init: true
|
|
29
|
+
) do
|
|
30
|
+
def snapshot_bridged?
|
|
31
|
+
snapshot_bridged ? true : false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Writes resolved snapshot data back onto the receipt (one-time cache)
|
|
35
|
+
# and marks the bridge as attempted — even when it failed with all-nil
|
|
36
|
+
# fields, so it is not retried per job.
|
|
37
|
+
def cache_snapshot!(memo:, opponent_id:, trace_id:)
|
|
38
|
+
self.memo = memo
|
|
39
|
+
self.opponent_id = opponent_id
|
|
40
|
+
self.trace_id = trace_id
|
|
41
|
+
self.snapshot_bridged = true
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def initialize(clock: -> { Time.now })
|
|
46
|
+
@records = {}
|
|
47
|
+
@sequence = 0
|
|
48
|
+
@clock = clock
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def record!(bot_app_id:, output:)
|
|
52
|
+
key = [bot_app_id, output['output_id']]
|
|
53
|
+
existing = @records[key]
|
|
54
|
+
return [existing, :duplicate] if existing
|
|
55
|
+
|
|
56
|
+
@sequence += 1
|
|
57
|
+
receipt = Receipt.new(
|
|
58
|
+
id: @sequence,
|
|
59
|
+
bot_app_id:,
|
|
60
|
+
output_id: output['output_id'],
|
|
61
|
+
amount: output['amount'],
|
|
62
|
+
asset_id: output['asset_id'],
|
|
63
|
+
state: output['state'],
|
|
64
|
+
transaction_hash: output['transaction_hash'],
|
|
65
|
+
output_index: output['output_index'],
|
|
66
|
+
sequence: output['sequence'],
|
|
67
|
+
created_at: output['created_at'],
|
|
68
|
+
recorded_at: @clock.call
|
|
69
|
+
)
|
|
70
|
+
@records[key] = receipt
|
|
71
|
+
[receipt, :created]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def mark_enqueued!(receipt)
|
|
75
|
+
receipt.enqueued_at = @clock.call
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def unenqueued(bot_app_id:, older_than:)
|
|
79
|
+
@records.values.select do |receipt|
|
|
80
|
+
receipt.bot_app_id == bot_app_id &&
|
|
81
|
+
receipt.enqueued_at.nil? &&
|
|
82
|
+
receipt.recorded_at < older_than
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The poller's resume position: the newest output sequence this bot has
|
|
87
|
+
# recorded — unique and monotonic, and the key the outputs API paginates
|
|
88
|
+
# on (nil = fetch from the beginning).
|
|
89
|
+
def cursor_value(bot_app_id:)
|
|
90
|
+
@records.values
|
|
91
|
+
.select { |receipt| receipt.bot_app_id == bot_app_id }
|
|
92
|
+
.filter_map(&:sequence)
|
|
93
|
+
.max
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MixinBot
|
|
4
|
+
module Outputs
|
|
5
|
+
##
|
|
6
|
+
# The polling loop: fetches a page of the bot's outputs oldest-first,
|
|
7
|
+
# records unseen outputs in the receipt store (the dedup gate), enqueues
|
|
8
|
+
# every matching processor through the job queue, and advances its cursor
|
|
9
|
+
# only after the whole page is absorbed.
|
|
10
|
+
#
|
|
11
|
+
# The resume cursor is derived from the receipts themselves — the newest
|
|
12
|
+
# output sequence recorded for the bot (unique, monotonic, and the key
|
|
13
|
+
# the outputs API paginates on) — and kept in memory afterwards: receipts
|
|
14
|
+
# recorded past a mid-page failure cover the remainder (the API returns
|
|
15
|
+
# outputs strictly after the offset), so there is no gap and no separate
|
|
16
|
+
# cursor state.
|
|
17
|
+
#
|
|
18
|
+
# Each loop iteration first sweeps receipts that were recorded but never
|
|
19
|
+
# dispatched (a crash between the two steps), re-evaluating predicates and
|
|
20
|
+
# enqueueing late — making dispatch at-least-once.
|
|
21
|
+
#
|
|
22
|
+
# All collaborators are injectable for offline testing: sleeper, clock,
|
|
23
|
+
# receipt store, processor list, and the enqueue hook. {#run} blocks until
|
|
24
|
+
# {#stop}; signal wiring is the process's job (the mixin_bot:poller rake
|
|
25
|
+
# task).
|
|
26
|
+
#
|
|
27
|
+
# poller = Poller.new(api: MixinBot.bot(:shop), interval: 5)
|
|
28
|
+
# trap('TERM') { poller.stop }
|
|
29
|
+
# poller.run
|
|
30
|
+
#
|
|
31
|
+
class Poller
|
|
32
|
+
DEFAULT_INTERVAL = 5
|
|
33
|
+
DEFAULT_PAGE_SIZE = 500
|
|
34
|
+
|
|
35
|
+
attr_reader :api, :receipts, :interval, :page_size
|
|
36
|
+
|
|
37
|
+
# rubocop:disable Metrics/ParameterLists
|
|
38
|
+
##
|
|
39
|
+
# @param api [MixinBot::API] the bot whose outputs are polled
|
|
40
|
+
# @param receipts [#record!, #mark_enqueued!, #unenqueued, #cursor_value]
|
|
41
|
+
# receipt store (default: the registered store from the generated
|
|
42
|
+
# model, else in-memory; Rails hosts normally rely on the registration)
|
|
43
|
+
# @param processors [Array<Class>, nil] processor classes; nil discovers
|
|
44
|
+
# them from the Mixin::Processors namespace on first use
|
|
45
|
+
# @param interval [Numeric] seconds between fetch cycles
|
|
46
|
+
# @param page_size [Integer] outputs fetched per cycle
|
|
47
|
+
# @param state [String] output state filter ('' = all states, covering
|
|
48
|
+
# inbound unspent outputs and outbound spent ones)
|
|
49
|
+
# @param asset [String, nil] restrict polling to one asset id
|
|
50
|
+
# @param members [Array<String>, String, nil] restrict polling to these
|
|
51
|
+
# member user ids (defaults to the bot itself at the API)
|
|
52
|
+
# @param threshold [Integer, nil] members threshold (defaults to the
|
|
53
|
+
# members count at the API)
|
|
54
|
+
# @param enqueuer [#call, nil] called with (processor_class, receipt);
|
|
55
|
+
# defaults to ProcessingJob.perform_later
|
|
56
|
+
# @param sleeper [#call, nil] called with seconds between cycles
|
|
57
|
+
# @param clock [#call, nil] returns current Time (sweep bookkeeping)
|
|
58
|
+
# @param logger [#call, nil] called with (level, detail)
|
|
59
|
+
#
|
|
60
|
+
def initialize(api: MixinBot.api, receipts: nil,
|
|
61
|
+
processors: nil, interval: DEFAULT_INTERVAL, page_size: DEFAULT_PAGE_SIZE, state: '',
|
|
62
|
+
asset: nil, members: nil, threshold: nil,
|
|
63
|
+
enqueuer: nil, sleeper: nil, clock: nil, logger: nil)
|
|
64
|
+
@api = api
|
|
65
|
+
@bot_app_id = api.config.app_id
|
|
66
|
+
@receipts = receipts || Outputs.receipt_store || MemoryReceiptStore.new
|
|
67
|
+
@processors = processors
|
|
68
|
+
@interval = interval
|
|
69
|
+
@page_size = page_size
|
|
70
|
+
@state = state
|
|
71
|
+
@asset = asset
|
|
72
|
+
@members = Array(members) if members
|
|
73
|
+
@threshold = threshold
|
|
74
|
+
@enqueuer = enqueuer || ->(processor, receipt) { ProcessingJob.perform_later(receipt.id, processor.name) }
|
|
75
|
+
@sleeper = sleeper || ->(seconds) { sleep seconds }
|
|
76
|
+
@clock = clock || -> { Time.now }
|
|
77
|
+
@logger = logger || Outputs.logger
|
|
78
|
+
@stopping = false
|
|
79
|
+
@cursor_value = nil
|
|
80
|
+
end
|
|
81
|
+
# rubocop:enable Metrics/ParameterLists
|
|
82
|
+
|
|
83
|
+
##
|
|
84
|
+
# Runs fetch cycles until {#stop} is called. One cycle = sweep stale
|
|
85
|
+
# receipts, fetch a page, absorb every output, advance the cursor.
|
|
86
|
+
# Cycle errors are logged and retried after the interval; the cursor
|
|
87
|
+
# only moves after a fully absorbed page.
|
|
88
|
+
#
|
|
89
|
+
# @return [void]
|
|
90
|
+
#
|
|
91
|
+
def run
|
|
92
|
+
until stopped?
|
|
93
|
+
begin
|
|
94
|
+
poll_once
|
|
95
|
+
rescue StandardError => e
|
|
96
|
+
@logger.call(:poll_error, e)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
break if stopped?
|
|
100
|
+
|
|
101
|
+
@sleeper.call @interval
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
##
|
|
106
|
+
# Requests termination; the in-flight cycle finishes first.
|
|
107
|
+
#
|
|
108
|
+
# Signal-safe: a plain flag (no Mutex — trap handlers cannot synchronize).
|
|
109
|
+
def stop
|
|
110
|
+
@stopping = true
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def stopped?
|
|
114
|
+
@stopping
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
##
|
|
118
|
+
# One fetch cycle. Exposed for tests and callers that drive the loop
|
|
119
|
+
# themselves.
|
|
120
|
+
#
|
|
121
|
+
# @return [Integer] number of outputs in the fetched page
|
|
122
|
+
#
|
|
123
|
+
def poll_once
|
|
124
|
+
sweep_undispatched
|
|
125
|
+
|
|
126
|
+
page = fetch_page
|
|
127
|
+
page.each { |output| absorb(output) }
|
|
128
|
+
|
|
129
|
+
@cursor_value = page.last['sequence'] if page.last
|
|
130
|
+
page.size
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def sweep_undispatched
|
|
136
|
+
cutoff = @clock.call - @interval
|
|
137
|
+
@receipts.unenqueued(bot_app_id: @bot_app_id, older_than: cutoff).each do |receipt|
|
|
138
|
+
dispatch(Envelope.new(receipt, api: @api), receipt)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Resume position: the newest recorded receipt for this bot, held in
|
|
143
|
+
# memory afterwards (advance = in-memory, no persistence needed).
|
|
144
|
+
def fetch_page
|
|
145
|
+
@cursor_value ||= @receipts.cursor_value(bot_app_id: @bot_app_id)
|
|
146
|
+
|
|
147
|
+
response = @api.safe_outputs(
|
|
148
|
+
state: @state, limit: @page_size, offset: @cursor_value.to_s, order: 'ASC',
|
|
149
|
+
asset: @asset, members: @members, threshold: @threshold
|
|
150
|
+
)
|
|
151
|
+
Array(response['data'])
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def absorb(output)
|
|
155
|
+
receipt, status = @receipts.record!(bot_app_id: @bot_app_id, output:)
|
|
156
|
+
return if status == :duplicate
|
|
157
|
+
|
|
158
|
+
dispatch(Envelope.new(receipt, api: @api), receipt)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Evaluates every processor against the envelope and enqueues the
|
|
162
|
+
# matches, then stamps the receipt as dispatched. An output matching no
|
|
163
|
+
# processor is stamped without work.
|
|
164
|
+
def dispatch(envelope, receipt)
|
|
165
|
+
processor_list(envelope).each { |processor| @enqueuer.call(processor, receipt) }
|
|
166
|
+
@receipts.mark_enqueued!(receipt)
|
|
167
|
+
rescue StandardError => e
|
|
168
|
+
# Leave the receipt undispatched so the next sweep retries the enqueue.
|
|
169
|
+
@logger.call(:dispatch_error, "#{e.class}: #{e.message} (output #{receipt.output_id})")
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def processor_list(envelope)
|
|
173
|
+
@processors ||= Processors.discover
|
|
174
|
+
Processors.for_envelope(envelope, processors: @processors)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
begin
|
|
4
|
+
require 'active_job/base'
|
|
5
|
+
rescue LoadError
|
|
6
|
+
nil
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
module MixinBot
|
|
10
|
+
module Outputs
|
|
11
|
+
##
|
|
12
|
+
# The poller's processing job — carries only (receipt id, processor class
|
|
13
|
+
# name) in its payload and rehydrates everything else at run time: the
|
|
14
|
+
# receipt from the application's receipt loader, the bot API client from
|
|
15
|
+
# the receipt's app id, and the envelope from the receipt.
|
|
16
|
+
#
|
|
17
|
+
# Defined only when ActiveJob is available (it is in every Rails stack
|
|
18
|
+
# that runs a job queue).
|
|
19
|
+
#
|
|
20
|
+
if defined?(ActiveJob::Base)
|
|
21
|
+
class ProcessingJob < ActiveJob::Base
|
|
22
|
+
queue_as :mixin_outputs
|
|
23
|
+
|
|
24
|
+
# @param receipt_id [Object] the receipt record's primary key
|
|
25
|
+
# @param processor_name [String] fully-qualified processor class name
|
|
26
|
+
def perform(receipt_id, processor_name)
|
|
27
|
+
loader = MixinBot::Outputs.receipt_loader
|
|
28
|
+
unless loader
|
|
29
|
+
raise ArgumentError,
|
|
30
|
+
'no receipt loader configured: include MixinBot::Outputs::ReceiptModel ' \
|
|
31
|
+
'in your receipt model (the mixin_bot:outputs generator does this)'
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
receipt = loader.call(receipt_id)
|
|
35
|
+
envelope = Envelope.new(receipt, api: api_for(receipt))
|
|
36
|
+
Processors.resolve(processor_name).new(envelope).call
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# The receipt's own bot: nil bot_app_id = the default bot; an explicit
|
|
42
|
+
# app id that no longer resolves is a configuration error, never a
|
|
43
|
+
# reason to spend the default bot's credentials on it.
|
|
44
|
+
def api_for(receipt)
|
|
45
|
+
app_id = receipt.respond_to?(:bot_app_id) ? receipt.bot_app_id : nil
|
|
46
|
+
return MixinBot.api if app_id.blank?
|
|
47
|
+
|
|
48
|
+
MixinBot.bot_by_app_id(app_id) ||
|
|
49
|
+
raise(MixinBot::NotFoundError, "no bot registered for app_id #{app_id}")
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MixinBot
|
|
4
|
+
module Outputs
|
|
5
|
+
##
|
|
6
|
+
# Base class for output processors. Subclass, implement class-level
|
|
7
|
+
# {matches?} and instance-level {#process}:
|
|
8
|
+
#
|
|
9
|
+
# module Mixin
|
|
10
|
+
# module Processors
|
|
11
|
+
# class OrderDeposit < MixinBot::Outputs::Processor
|
|
12
|
+
# def self.matches?(envelope)
|
|
13
|
+
# !envelope.spent? && envelope.memo.to_s.start_with?('ORDER-')
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# def process
|
|
17
|
+
# Order.settle_by_deposit!(envelope)
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# Processors live in app/mixin/processors (Rails autoloads them as
|
|
24
|
+
# Mixin::Processors::*). Every processor whose predicate matches an output
|
|
25
|
+
# gets a job enqueued — matches are independent side effects, and an
|
|
26
|
+
# output matching no processor is recorded only.
|
|
27
|
+
#
|
|
28
|
+
# Enqueue is at-least-once: processors should tolerate rare duplicate
|
|
29
|
+
# runs and can use the envelope's output_id as a natural idempotency key.
|
|
30
|
+
#
|
|
31
|
+
class Processor
|
|
32
|
+
def initialize(envelope)
|
|
33
|
+
@envelope = envelope
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def call
|
|
37
|
+
process
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
attr_reader :envelope
|
|
41
|
+
|
|
42
|
+
# Override with the processor's selection predicate.
|
|
43
|
+
def self.matches?(_envelope)
|
|
44
|
+
false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def process
|
|
48
|
+
raise NotImplementedError, "#{self.class} must implement #process"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
##
|
|
53
|
+
# Processor discovery and selection over the Mixin::Processors namespace.
|
|
54
|
+
#
|
|
55
|
+
module Processors
|
|
56
|
+
NAMESPACE_NAME = 'Mixin::Processors'
|
|
57
|
+
|
|
58
|
+
module_function
|
|
59
|
+
|
|
60
|
+
##
|
|
61
|
+
# All processor classes currently defined under Mixin::Processors. Under
|
|
62
|
+
# Rails the namespace is eager-loaded first so the poller process sees
|
|
63
|
+
# every processor without referencing each one.
|
|
64
|
+
#
|
|
65
|
+
# @return [Array<Class>]
|
|
66
|
+
#
|
|
67
|
+
def discover
|
|
68
|
+
eager_load_namespace! if defined?(::Rails.application) && ::Rails.application.respond_to?(:autoloaders)
|
|
69
|
+
|
|
70
|
+
namespace = namespace_class
|
|
71
|
+
return [] if namespace.nil?
|
|
72
|
+
|
|
73
|
+
namespace.constants.filter_map do |const|
|
|
74
|
+
candidate = namespace.const_get(const)
|
|
75
|
+
candidate if candidate.is_a?(Class) && candidate < Processor
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Finds a processor by fully-qualified class name (the payload the
|
|
80
|
+
# poller's jobs carry).
|
|
81
|
+
#
|
|
82
|
+
# @param name [String]
|
|
83
|
+
# @return [Class]
|
|
84
|
+
# @raise [NameError] when no such processor is loaded
|
|
85
|
+
#
|
|
86
|
+
def resolve(name)
|
|
87
|
+
constant = Object.const_get(name)
|
|
88
|
+
raise NameError, "#{name} is not a MixinBot::Outputs::Processor" unless constant.is_a?(Class) && constant < Processor
|
|
89
|
+
|
|
90
|
+
constant
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
##
|
|
94
|
+
# Processors whose predicate matches the envelope.
|
|
95
|
+
#
|
|
96
|
+
# @return [Array<Class>]
|
|
97
|
+
#
|
|
98
|
+
def for_envelope(envelope, processors:)
|
|
99
|
+
processors.select { |processor| processor.matches?(envelope) }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def namespace_class
|
|
103
|
+
Object.const_get(NAMESPACE_NAME)
|
|
104
|
+
rescue NameError
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def eager_load_namespace!
|
|
109
|
+
autoloader = ::Rails.autoloaders.main
|
|
110
|
+
namespace = namespace_class
|
|
111
|
+
return if namespace.nil?
|
|
112
|
+
|
|
113
|
+
if autoloader.respond_to?(:eager_load_namespace)
|
|
114
|
+
autoloader.eager_load_namespace(namespace)
|
|
115
|
+
else
|
|
116
|
+
::Rails.application.eager_load!
|
|
117
|
+
end
|
|
118
|
+
rescue Zeitwerk::SetupRequired, StandardError
|
|
119
|
+
# Best effort: an eager-load problem must not stop polling.
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/concern'
|
|
4
|
+
|
|
5
|
+
module MixinBot
|
|
6
|
+
module Outputs
|
|
7
|
+
##
|
|
8
|
+
# ActiveRecord concern for the generated +MixinOutput+ receipt model
|
|
9
|
+
# (mixin_bot:outputs generator). Implements the receipt-store interface
|
|
10
|
+
# the poller consumes, on top of the table's unique
|
|
11
|
+
# +(bot_app_id, output_id)+ index:
|
|
12
|
+
#
|
|
13
|
+
# class MixinOutput < ApplicationRecord
|
|
14
|
+
# include MixinBot::Outputs::ReceiptModel
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# Including the model also registers it as the integration's receipt
|
|
18
|
+
# store (the mixin_bot:poller rake task) and as the processing job's
|
|
19
|
+
# receipt loader. The poller's resume cursor is derived from the receipts
|
|
20
|
+
# themselves — the newest output sequence per bot — so there is no
|
|
21
|
+
# separate cursor table.
|
|
22
|
+
#
|
|
23
|
+
# Required columns (see the generated migration): bot_app_id, output_id,
|
|
24
|
+
# amount, asset_id, state, transaction_hash, output_index, sequence
|
|
25
|
+
# (the outputs API's pagination key — the poller's cursor), memo,
|
|
26
|
+
# opponent_id, trace_id, snapshot_bridged, enqueued_at, plus Rails
|
|
27
|
+
# timestamps (created_at backs the sweep query).
|
|
28
|
+
#
|
|
29
|
+
module ReceiptModel
|
|
30
|
+
extend ActiveSupport::Concern
|
|
31
|
+
|
|
32
|
+
class_methods do
|
|
33
|
+
# Records the output or reports it as already seen. Uniqueness is
|
|
34
|
+
# enforced by the (bot_app_id, output_id) index, so concurrent pollers
|
|
35
|
+
# race safely.
|
|
36
|
+
def record!(bot_app_id:, output:)
|
|
37
|
+
existing = find_by(bot_app_id:, output_id: output['output_id'])
|
|
38
|
+
return [existing, :duplicate] if existing
|
|
39
|
+
|
|
40
|
+
receipt = create!(
|
|
41
|
+
bot_app_id:,
|
|
42
|
+
output_id: output['output_id'],
|
|
43
|
+
amount: output['amount'],
|
|
44
|
+
asset_id: output['asset_id'],
|
|
45
|
+
state: output['state'],
|
|
46
|
+
transaction_hash: output['transaction_hash'],
|
|
47
|
+
output_index: output['output_index'],
|
|
48
|
+
output_created_at: output['created_at']
|
|
49
|
+
)
|
|
50
|
+
[receipt, :created]
|
|
51
|
+
rescue ActiveRecord::RecordNotUnique
|
|
52
|
+
[find_by!(bot_app_id:, output_id: output['output_id']), :duplicate]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def mark_enqueued!(receipt)
|
|
56
|
+
receipt.update!(enqueued_at: Time.now)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# created_at is the Rails timestamp (when the receipt row was written),
|
|
60
|
+
# so the sweep's cutoff means "recorded before this moment".
|
|
61
|
+
def unenqueued(bot_app_id:, older_than:)
|
|
62
|
+
where(bot_app_id:, enqueued_at: nil).where(created_at: ...older_than)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The poller's resume position: the newest output sequence recorded
|
|
66
|
+
# for this bot — unique and monotonic, and the key the outputs API
|
|
67
|
+
# paginates on (nil = fetch from the beginning). Derived from the
|
|
68
|
+
# receipts themselves — no separate cursor state.
|
|
69
|
+
def cursor_value(bot_app_id:)
|
|
70
|
+
where(bot_app_id:).maximum(:sequence)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Caches the envelope's resolved snapshot data (one bridge attempt per
|
|
75
|
+
# output — including failures, which cache nils and the flag).
|
|
76
|
+
def cache_snapshot!(memo:, opponent_id:, trace_id:)
|
|
77
|
+
update!(memo:, opponent_id:, trace_id:, snapshot_bridged: true) unless frozen?
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
included do
|
|
81
|
+
MixinBot::Outputs.receipt_loader = method(:find)
|
|
82
|
+
MixinBot::Outputs.receipt_store = self
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MixinBot
|
|
4
|
+
##
|
|
5
|
+
# Output (transfer) polling integration: delivers every output touching the
|
|
6
|
+
# bot's Safe UTXO set — inbound or outbound — to application processor
|
|
7
|
+
# classes exactly once, via a standalone polling process and the
|
|
8
|
+
# application's job queue.
|
|
9
|
+
#
|
|
10
|
+
# poller = MixinBot::Outputs::Poller.new(api: MixinBot.bot(:shop))
|
|
11
|
+
# poller.run # blocks; see the mixin_bot:poller rake task
|
|
12
|
+
#
|
|
13
|
+
module Outputs
|
|
14
|
+
class << self
|
|
15
|
+
# Shared logger for the outputs integration; called with (level, detail),
|
|
16
|
+
# same shape as MixinBot::Blaze::Reactor's logger. Injectable per
|
|
17
|
+
# component; this default is the fallback.
|
|
18
|
+
attr_accessor :logger
|
|
19
|
+
|
|
20
|
+
# Called with a receipt id to reload the receipt record when a processing
|
|
21
|
+
# job runs — set by the generated receipt model (ActiveRecord host apps).
|
|
22
|
+
# Without a loader, processing jobs raise a clear error explaining how to
|
|
23
|
+
# wire one.
|
|
24
|
+
attr_accessor :receipt_loader
|
|
25
|
+
|
|
26
|
+
# The receipt store the poller process uses — set by the generated
|
|
27
|
+
# receipt model when it loads. The mixin_bot:poller rake task refuses to
|
|
28
|
+
# run without one (polling with an in-memory store would silently lose
|
|
29
|
+
# dedup across restarts).
|
|
30
|
+
attr_accessor :receipt_store
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
self.logger = ->(level, detail) { warn "[mixin_outputs] #{level}: #{detail}" }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
require_relative 'outputs/envelope'
|
|
38
|
+
require_relative 'outputs/memory_stores'
|
|
39
|
+
require_relative 'outputs/processors'
|
|
40
|
+
require_relative 'outputs/poller'
|
|
41
|
+
require_relative 'outputs/processing_job'
|
|
42
|
+
require_relative 'outputs/receipt_model'
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/railtie'
|
|
4
|
+
|
|
5
|
+
module MixinBot
|
|
6
|
+
# Registers the Rails integration's rake tasks and application-code
|
|
7
|
+
# conventions. Lives at lib/mixin_bot/rails/railtie.rb (loaded by
|
|
8
|
+
# `require 'mixin_bot/rails'`), named +MixinBot::Railtie+ — deliberately not
|
|
9
|
+
# +MixinBot::Rails+, which would lexically shadow ::Rails for code written
|
|
10
|
+
# inside module MixinBot.
|
|
11
|
+
#
|
|
12
|
+
# Application conventions:
|
|
13
|
+
# - +app/mixin/handlers+ hosts Blaze message handler classes
|
|
14
|
+
# (+Mixin::Handlers::*+) and +app/mixin/processors+ hosts output
|
|
15
|
+
# processor classes (+Mixin::Processors::*+). Rails' default path
|
|
16
|
+
# inference would make +app/mixin+ itself a Zeitwerk root (mapping files
|
|
17
|
+
# to +Handlers::*+ / +Processors::*+, not +Mixin::Handlers::*+), so the
|
|
18
|
+
# railtie excludes it from the defaults and re-registers it with the
|
|
19
|
+
# +Mixin+ namespace explicitly. Eager loading is the poller process's
|
|
20
|
+
# responsibility (it eager-loads before discovering processors),
|
|
21
|
+
# mirroring how Solid Queue's supervisor boots.
|
|
22
|
+
#
|
|
23
|
+
# Generator discovery needs no registration either: railties scans loaded
|
|
24
|
+
# gems for +lib/generators+ automatically.
|
|
25
|
+
class Railtie < ::Rails::Railtie
|
|
26
|
+
railtie_name 'mixin_bot'
|
|
27
|
+
|
|
28
|
+
initializer 'mixin_bot.exclude_default_mixin_root', before: :set_autoload_paths do |app|
|
|
29
|
+
app.config.paths.add 'app/mixin', autoload: false, eager_load: false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
initializer 'mixin_bot.push_mixin_root', after: :set_autoload_paths, before: :setup_main_autoloader do |app|
|
|
33
|
+
mixin_root = app.root.join('app/mixin')
|
|
34
|
+
next unless mixin_root.directory?
|
|
35
|
+
|
|
36
|
+
Object.const_set(:Mixin, Module.new) unless Object.const_defined?(:Mixin)
|
|
37
|
+
app.autoloaders.main.push_dir(mixin_root, namespace: Mixin)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
rake_tasks do
|
|
41
|
+
load File.expand_path('tasks/poller.rake', __dir__)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :mixin_bot do
|
|
4
|
+
desc 'Poll Mixin outputs for the default bot (or the given [bot_name]) and enqueue processor jobs'
|
|
5
|
+
task :poller, [:bot] => :environment do |_task, args|
|
|
6
|
+
# Processor classes live in app/mixin/processors; eager load so discovery
|
|
7
|
+
# sees them (the poller process never references them directly).
|
|
8
|
+
Rails.application.eager_load!
|
|
9
|
+
|
|
10
|
+
unless MixinBot::Outputs.receipt_store
|
|
11
|
+
raise MixinBot::ArgumentError,
|
|
12
|
+
'no receipt store configured: run `rails g mixin_bot:outputs` (or include ' \
|
|
13
|
+
'MixinBot::Outputs::ReceiptModel in your receipt model) so polling dedup ' \
|
|
14
|
+
'survives restarts'
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
api = args[:bot] ? MixinBot.bot(args[:bot]) : MixinBot.api
|
|
18
|
+
poller = MixinBot::Outputs::Poller.new(
|
|
19
|
+
api:,
|
|
20
|
+
receipts: MixinBot::Outputs.receipt_store,
|
|
21
|
+
interval: Integer(ENV.fetch('MIXIN_BOT_POLL_INTERVAL', 5)),
|
|
22
|
+
asset: ENV.fetch('MIXIN_BOT_POLL_ASSET', nil),
|
|
23
|
+
members: ENV['MIXIN_BOT_POLL_MEMBERS']&.split(','),
|
|
24
|
+
threshold: ENV['MIXIN_BOT_POLL_THRESHOLD']&.then { |t| Integer(t) }
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
%w[TERM INT].each { |signal| trap(signal) { poller.stop } }
|
|
28
|
+
|
|
29
|
+
MixinBot::Outputs.logger.call(:info, "poller started (pid=#{Process.pid})")
|
|
30
|
+
poller.run
|
|
31
|
+
MixinBot::Outputs.logger.call(:info, 'poller stopped')
|
|
32
|
+
end
|
|
33
|
+
end
|