acts-as-tbackend 0.2.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9b2e9c9d9dc5ee83794cd1021c42cd1ba5ba6244a2f4542b02e4d902b57e4590
4
+ data.tar.gz: adbc87746afd734bc452bd42c54c4b954347f5bc3268598e59c0bcb87b34c7b0
5
+ SHA512:
6
+ metadata.gz: 575d695ec8ffdae22d415774f697d92fc9dcb1fb24f1f1e2fbcb9803b12931444ef1e608b78b7dd8d4af64a4a44a74b0b39ea50281fe14d105ff86ad88a88806
7
+ data.tar.gz: d70b29f9e4317627e44c8843a2f6b06a99fb09fb79bdc49d7484494a3e26798a78156f2f9684d3f8a4bd87f8e792071e0e9414f4a25ec41e5b90e09c053c33a3
data/README.md ADDED
@@ -0,0 +1,111 @@
1
+ # acts-as-tbackend
2
+
3
+ Production Ruby connector for the **TBackend** temporal-ledger daemon: pooled,
4
+ circuit-broken, idempotent writes over the framed loopback protocol. Built for
5
+ multi-threaded Rails (Puma) — persistent sockets, a connection pool sized to the
6
+ worker threads, and soft, non-fatal results when the daemon is down.
7
+
8
+ Status: connector is **prod-shaped**; TBackend itself stays a **shadow-ready**
9
+ side ledger (Rails/Postgres authoritative) until convergence + ops gates. See
10
+ `../igniter-tbackend/docs/tbackend-onboarding.md`.
11
+
12
+ Canonical repository:
13
+
14
+ ```text
15
+ https://github.com/alexander-s-f/acts-as-tbackend
16
+ ```
17
+
18
+ Forgejo may mirror this repository for internal navigation, but GitHub is the
19
+ team-facing source and RubyGems is the package authority.
20
+
21
+ ## Layers (deliberately separate)
22
+
23
+ | Layer | File | Responsibility |
24
+ | --- | --- | --- |
25
+ | **Connection** | `lib/acts_as_tbackend/connection.rb` | one persistent framed socket + protocol (token, `write_fact_once`, rich status mapping, reconnect). **Not thread-safe.** |
26
+ | **Pool** | `lib/acts_as_tbackend/pool.rb` | N connections, checkout per thread (`connection_pool`). The concurrency layer. |
27
+ | **Client** | `lib/acts_as_tbackend/client.rb` | app-facing facade: pool + circuit breaker. |
28
+ | **Fact** | `lib/acts_as_tbackend/fact.rb` | deterministic derived ids + fact builder. |
29
+ | **Config** | `lib/acts_as_tbackend/config.rb` | host/port/token/timeouts/pool size/durability (ENV-defaulted). |
30
+ | **Mirror** | `lib/acts_as_tbackend/mirror.rb` | plain-Ruby record to fact envelope + soft `write_fact_once_safe`. |
31
+ | **Extension** | `lib/acts_as_tbackend/extension.rb` | optional ActiveRecord macro, loaded explicitly by Rails apps. |
32
+
33
+ ## Usage
34
+
35
+ ```ruby
36
+ ActsAsTbackend.configure do |c|
37
+ c.host = "127.0.0.1"; c.port = 7401
38
+ c.token = ENV["TBACKEND_TOKEN"] # sent on every request when set
39
+ c.pool_size = 12 # ≈ Puma threads per process
40
+ c.durability_default = "accepted" # or "durable" (group-commit fdatasync)
41
+ end
42
+
43
+ # Deterministic id → a retry is an idempotent replay, not a duplicate.
44
+ id = ActsAsTbackend::Fact.derive_id(store: "orders", record_id: order.id,
45
+ event_type: "order.accepted", source_version: order.updated_at)
46
+ fact = ActsAsTbackend::Fact.build(id:, store: "orders", key: "order:#{order.id}",
47
+ value: { status: "accepted" }, valid_time: order.scheduled_at)
48
+
49
+ result = ActsAsTbackend.client.write_fact_once(fact)
50
+ # => { ok:, status:, committed:, retryable:, response:, error: }
51
+ # status ∈ committed_acked | idempotent_replay | duplicate_fact_id_conflict
52
+ # | rejected_before_commit | timeout_unknown | unavailable | circuit_open
53
+
54
+ ActsAsTbackend.client.facts_by_seq(store: "orders", after_seq: 0) # clock-free ordered read
55
+ ActsAsTbackend.client.latest_for(store: "orders", key: "order:42") # point-in-time
56
+ ```
57
+
58
+ Reads/writes never raise for a down daemon (unless `strict`) — they return a soft
59
+ result so a shadow write stays non-fatal, and the circuit breaker fails fast while
60
+ the daemon is unreachable.
61
+
62
+ ## Rails mirror
63
+
64
+ The core `require "acts_as_tbackend"` stays ActiveRecord-free. Rails apps opt into
65
+ the macro by requiring the extension:
66
+
67
+ ```ruby
68
+ require "acts_as_tbackend/extension"
69
+
70
+ class Order < ApplicationRecord
71
+ acts_as_tbackend store: "orders", except: %i[created_at updated_at]
72
+ end
73
+ ```
74
+
75
+ The callback path is intentionally synchronous and soft for v0:
76
+
77
+ ```text
78
+ after_commit -> Mirror.build_fact -> client.write_fact_once_safe
79
+ ```
80
+
81
+ If the daemon is down, the write returns a soft result such as
82
+ `status: "unavailable"` or `status: "circuit_open"` and the Rails request path is
83
+ not raised by default. For heavier paths, call `record.tbackend_fact(...)` or
84
+ `ActsAsTbackend::Mirror.mirror!(...)` from an app-owned background job.
85
+
86
+ ## Fork-safety (Puma / Sidekiq)
87
+
88
+ Sockets created before a fork are invalid in the child. Reset in the forking hook:
89
+
90
+ ```ruby
91
+ # config/puma.rb
92
+ on_worker_boot { ActsAsTbackend.reset! }
93
+ # Sidekiq
94
+ Sidekiq.configure_server { |cfg| cfg.on(:startup) { ActsAsTbackend.reset! } }
95
+ ```
96
+
97
+ ## Throughput
98
+
99
+ Persistent pooled sockets + `TCP_NODELAY` make 5–8k rpm (≈83–133 rps) modest. The
100
+ daemon sheds load past `max_inflight_requests` with a retryable `overloaded` →
101
+ `rejected_before_commit`, which `write_fact_once_safe` retries with backoff. A live
102
+ load test proving the number (and finding the ceiling) is the next step.
103
+
104
+ ## Legacy files
105
+
106
+ `shadow_comparison.rb`, `demo.rb`, and `verify_shadow.rb` are retained as
107
+ pre-refresh reference material for the shadow-parity/demo layer. They are not
108
+ loaded by the core entrypoint and still need a separate port if that layer becomes
109
+ active again.
110
+
111
+ The refreshed core + optional Rails mirror are the supported v0 surface.
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsTbackend
4
+ # A small thread-safe circuit breaker per (host, port). After `threshold`
5
+ # consecutive failures it opens for `cooldown` seconds, then allows a single
6
+ # half-open probe. Keeps a down daemon from stalling every request thread
7
+ # (fail-fast) while a shadow write stays non-fatal.
8
+ class CircuitBreaker
9
+ def initialize(threshold:, cooldown:)
10
+ @threshold = threshold
11
+ @cooldown = cooldown
12
+ @failures = 0
13
+ @opened_at = nil
14
+ @mutex = Mutex.new
15
+ end
16
+
17
+ def allow_request?
18
+ @mutex.synchronize do
19
+ return true if @opened_at.nil?
20
+ return true if (monotonic - @opened_at) >= @cooldown # half-open probe
21
+
22
+ false
23
+ end
24
+ end
25
+
26
+ def record_success
27
+ @mutex.synchronize do
28
+ @failures = 0
29
+ @opened_at = nil
30
+ end
31
+ end
32
+
33
+ def record_failure
34
+ @mutex.synchronize do
35
+ @failures += 1
36
+ @opened_at = monotonic if @failures >= @threshold
37
+ end
38
+ end
39
+
40
+ def open?
41
+ @mutex.synchronize { !@opened_at.nil? && (monotonic - @opened_at) < @cooldown }
42
+ end
43
+
44
+ private
45
+
46
+ def monotonic
47
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsTbackend
4
+ # The app-facing facade: a pooled, circuit-broken TBackend client. Checks the
5
+ # breaker, checks out a persistent Connection, delegates, and feeds transport
6
+ # health back to the breaker. Thread-safe (the Pool serialises per connection).
7
+ #
8
+ # ActsAsTbackend.client.write_fact_once(fact)
9
+ # ActsAsTbackend.client.facts_by_seq(store: "orders", after_seq: 0)
10
+ #
11
+ # Every method returns the Connection's soft result hash
12
+ # ({ ok:, status:, committed:, retryable:, response:, error: }) — never raises for
13
+ # a down daemon unless `config.strict` is set. When the breaker is open it
14
+ # short-circuits with status "circuit_open" (retryable) without touching the socket.
15
+ class Client
16
+ WRITE_STATUSES_OK = %w[committed_acked idempotent_replay].freeze
17
+
18
+ def initialize(config)
19
+ @config = config
20
+ @pool = Pool.new(config)
21
+ @breaker = CircuitBreaker.new(threshold: config.breaker_threshold, cooldown: config.breaker_cooldown)
22
+ end
23
+
24
+ def write_fact_once(fact, **opts)
25
+ call { |c| c.write_fact_once(fact, **opts) }
26
+ end
27
+
28
+ def write_fact_once_safe(fact, **opts)
29
+ call { |c| c.write_fact_once_safe(fact, **opts) }
30
+ end
31
+
32
+ def latest_for(**opts)
33
+ call { |c| c.latest_for(**opts) }
34
+ end
35
+
36
+ def facts_for(**opts)
37
+ call { |c| c.facts_for(**opts) }
38
+ end
39
+
40
+ def facts_by_seq(**opts)
41
+ call { |c| c.facts_by_seq(**opts) }
42
+ end
43
+
44
+ def ping(**opts)
45
+ call { |c| c.ping(**opts) }
46
+ end
47
+
48
+ def shutdown
49
+ @pool.shutdown
50
+ end
51
+
52
+ private
53
+
54
+ def call
55
+ return circuit_open_result unless @breaker.allow_request?
56
+
57
+ begin
58
+ result = @pool.with { |conn| yield conn }
59
+ rescue Connection::TransportUnavailable, Connection::TransportUnknown => e
60
+ # strict mode — Connection raised instead of soft-resulting.
61
+ @breaker.record_failure
62
+ raise e
63
+ end
64
+
65
+ transport_healthy?(result) ? @breaker.record_success : @breaker.record_failure
66
+ result
67
+ end
68
+
69
+ # A completed round-trip (even a domain error like duplicate_fact_id_conflict) is
70
+ # transport-healthy. Only connect/ack transport states trip the breaker.
71
+ def transport_healthy?(result)
72
+ !%w[unavailable timeout_unknown].include?(result[:status])
73
+ end
74
+
75
+ def circuit_open_result
76
+ { ok: false, status: "circuit_open", committed: nil, retryable: true, response: nil,
77
+ error: "TBackend circuit breaker open for #{@config.host}:#{@config.port}" }
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsTbackend
4
+ # Process-wide configuration for the TBackend connector. Defaults come from ENV so
5
+ # the same code runs in dev / CI / prod without edits.
6
+ #
7
+ # ActsAsTbackend.configure do |c|
8
+ # c.host = "127.0.0.1"; c.port = 7401
9
+ # c.token = ENV["TBACKEND_TOKEN"]
10
+ # c.pool_size = 12 # ~ Puma threads per process
11
+ # c.durability_default = "accepted"
12
+ # end
13
+ class Config
14
+ attr_accessor :host, :port, :token,
15
+ :connect_timeout, :request_timeout,
16
+ :pool_size, :pool_checkout_timeout,
17
+ :durability_default, :strict,
18
+ # circuit breaker
19
+ :breaker_threshold, :breaker_cooldown,
20
+ # producer stamped onto facts built via Fact.build
21
+ :producer,
22
+ # master kill-switch for the mirror (extension callbacks no-op when false)
23
+ :enabled
24
+
25
+ def initialize
26
+ @enabled = ENV.fetch("TBACKEND_ENABLED", "1") != "0"
27
+ @host = ENV.fetch("TBACKEND_HOST", "127.0.0.1")
28
+ @port = Integer(ENV.fetch("TBACKEND_PORT", 7401))
29
+ @token = ENV["TBACKEND_TOKEN"]
30
+ @connect_timeout = Float(ENV.fetch("TBACKEND_CONNECT_TIMEOUT", 1.0))
31
+ @request_timeout = Float(ENV.fetch("TBACKEND_REQUEST_TIMEOUT", 2.0))
32
+ @pool_size = Integer(ENV.fetch("TBACKEND_POOL_SIZE", 5))
33
+ @pool_checkout_timeout = Float(ENV.fetch("TBACKEND_POOL_CHECKOUT_TIMEOUT", 1.0))
34
+ @durability_default = ENV.fetch("TBACKEND_DURABILITY", "accepted")
35
+ @strict = ENV["TBACKEND_STRICT"] == "1"
36
+ @breaker_threshold = Integer(ENV.fetch("TBACKEND_BREAKER_THRESHOLD", 5))
37
+ @breaker_cooldown = Float(ENV.fetch("TBACKEND_BREAKER_COOLDOWN", 5.0))
38
+ @producer = ENV.fetch("TBACKEND_PRODUCER", "acts-as-tbackend")
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "socket"
5
+ require "timeout"
6
+ require "zlib"
7
+
8
+ module ActsAsTbackend
9
+ # One persistent framed connection to a TBackend daemon. Protocol parity with the
10
+ # canonical P16 client (token, write_fact_once, rich status mapping,
11
+ # Unavailable/Unknown split) — but the socket is **kept open and reused** across
12
+ # requests (reconnect only on error), which is what makes pooled throughput cheap.
13
+ #
14
+ # NOT thread-safe: one in-flight request per connection. Concurrency is the Pool's
15
+ # job — check a Connection out, use it, check it back in.
16
+ class Connection
17
+ DEFAULT_HOST = "127.0.0.1"
18
+ DEFAULT_PORT = 7401
19
+ MAX_FRAME_BYTES = 64 * 1024 * 1024
20
+
21
+ class TransportUnavailable < StandardError; end # connect failed — nothing was sent
22
+ class TransportUnknown < StandardError; end # sent, no ack — may or may not have committed
23
+ class InvalidFrame < StandardError; end
24
+
25
+ def initialize(host: DEFAULT_HOST, port: DEFAULT_PORT, token: nil,
26
+ connect_timeout: 1.0, request_timeout: 2.0,
27
+ durability_default: "accepted", strict: false)
28
+ @host = host
29
+ @port = Integer(port)
30
+ @token = token
31
+ @connect_timeout = Float(connect_timeout)
32
+ @request_timeout = Float(request_timeout)
33
+ @durability_default = durability_default
34
+ @strict = strict
35
+ @socket = nil
36
+ end
37
+
38
+ def ping(timeout: nil)
39
+ map_generic(request({ op: "ping" }, timeout))
40
+ end
41
+
42
+ # Idempotent durable write — the recommended write path. Derive `fact["id"]`
43
+ # deterministically (see Fact.derive_id) so a retry is a replay, not a duplicate.
44
+ def write_fact_once(fact, durability: nil, timeout: nil)
45
+ req = { op: "write_fact_once", fact: fact, durability: durability || @durability_default }
46
+ map_write_once(request(req, timeout))
47
+ end
48
+
49
+ # Bounded retry of write_fact_once for the retry-safe transient states
50
+ # (rejected_before_commit / timeout_unknown). Never loops unbounded.
51
+ def write_fact_once_safe(fact, durability: nil, timeout: nil, attempts: 2, backoff: 0.05)
52
+ max = [Integer(attempts), 1].max
53
+ seen = []
54
+ max.times do |i|
55
+ result = write_fact_once(fact, durability: durability, timeout: timeout)
56
+ seen << summary(result)
57
+ return result.merge(attempt_count: seen.length, attempts: seen) unless retry_safe?(result[:status]) && i + 1 < max
58
+
59
+ sleep(backoff.to_f) if backoff.to_f.positive?
60
+ end
61
+ end
62
+
63
+ def latest_for(store:, key:, as_of: nil, timeout: nil)
64
+ req = { op: "latest_for", store: store.to_s, key: key.to_s }
65
+ req[:as_of] = as_of unless as_of.nil?
66
+ map_generic(request(req, timeout))
67
+ end
68
+
69
+ def facts_for(store:, key: nil, since: nil, as_of: nil, timeout: nil)
70
+ req = { op: "facts_for", store: store.to_s }
71
+ req[:key] = key.to_s unless key.nil?
72
+ req[:since] = since unless since.nil?
73
+ req[:as_of] = as_of unless as_of.nil?
74
+ map_generic(request(req, timeout))
75
+ end
76
+
77
+ # Clock-free ordered read (the ordering authority). Prefer this over timestamp
78
+ # ordering for replay/audit/pull.
79
+ def facts_by_seq(store:, after_seq: 0, until_seq: nil, timeout: nil)
80
+ req = { op: "facts_by_seq", store: store.to_s, after_seq: after_seq }
81
+ req[:until_seq] = until_seq unless until_seq.nil?
82
+ map_generic(request(req, timeout))
83
+ end
84
+
85
+ def close
86
+ @socket&.close
87
+ rescue StandardError
88
+ nil
89
+ ensure
90
+ @socket = nil
91
+ end
92
+
93
+ private
94
+
95
+ # Returns { transport_ok: true, response: <hash> } on a completed round-trip, or a
96
+ # soft transport result (unavailable / timeout_unknown). Raises in strict mode.
97
+ def request(req, timeout)
98
+ raw = raw_request(with_token(req), request_timeout(timeout))
99
+ { transport_ok: true, response: raw }
100
+ rescue TransportUnavailable => e
101
+ raise if @strict
102
+
103
+ transport_result("unavailable", e, retryable: true)
104
+ rescue TransportUnknown => e
105
+ raise if @strict
106
+
107
+ transport_result("timeout_unknown", e, retryable: nil)
108
+ end
109
+
110
+ def raw_request(req, timeout)
111
+ sock = live_socket
112
+ Timeout.timeout(timeout) do
113
+ sock.write(encode_frame(req))
114
+ decode_frame(sock)
115
+ end
116
+ rescue Timeout::Error, EOFError, IOError, SystemCallError, JSON::ParserError, InvalidFrame => e
117
+ close # desynced/broken socket — force reconnect next time
118
+ raise TransportUnknown, e.message
119
+ end
120
+
121
+ def live_socket
122
+ return @socket if @socket && !@socket.closed?
123
+
124
+ @socket = open_socket
125
+ end
126
+
127
+ def open_socket
128
+ socket = nil
129
+ Timeout.timeout(@connect_timeout) { socket = TCPSocket.new(@host, @port) }
130
+ socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, true)
131
+ socket
132
+ rescue Timeout::Error, SocketError, SystemCallError => e
133
+ raise TransportUnavailable, e.message
134
+ end
135
+
136
+ def with_token(req)
137
+ return req unless @token && !@token.to_s.empty?
138
+ return req if req.key?(:token) || req.key?("token")
139
+
140
+ req.merge(token: @token)
141
+ end
142
+
143
+ def request_timeout(override)
144
+ override.nil? ? @request_timeout : Float(override)
145
+ end
146
+
147
+ def encode_frame(req)
148
+ body = JSON.generate(req).b
149
+ [body.bytesize].pack("N") + body + [Zlib.crc32(body)].pack("N")
150
+ end
151
+
152
+ def decode_frame(socket)
153
+ len = read_exact(socket, 4).unpack1("N")
154
+ raise InvalidFrame, "response frame too large" if len > MAX_FRAME_BYTES
155
+
156
+ body = read_exact(socket, len)
157
+ expected = read_exact(socket, 4).unpack1("N")
158
+ raise InvalidFrame, "CRC mismatch" unless Zlib.crc32(body) == expected
159
+
160
+ JSON.parse(body, symbolize_names: true)
161
+ end
162
+
163
+ def read_exact(socket, bytes)
164
+ data = +"".b
165
+ while data.bytesize < bytes
166
+ chunk = socket.read(bytes - data.bytesize)
167
+ raise EOFError, "socket closed" if chunk.nil? || chunk.empty?
168
+
169
+ data << chunk
170
+ end
171
+ data
172
+ end
173
+
174
+ # ---- response mapping (parity with canonical client) ----
175
+
176
+ def map_write_once(env)
177
+ return env unless env[:transport_ok]
178
+
179
+ r = env[:response]
180
+ return rejected(r) if overloaded?(r)
181
+ return dup_conflict(r) if r[:error_code] == "duplicate_fact_id_conflict"
182
+
183
+ if r[:ok] == true && r[:committed] == true
184
+ return result(true, "idempotent_replay", true, false, r, nil) if r[:idempotent_replay] == true
185
+
186
+ return result(true, "committed_acked", true, false, r, nil)
187
+ end
188
+ generic_error(r)
189
+ end
190
+
191
+ def map_generic(env)
192
+ return env unless env[:transport_ok]
193
+
194
+ r = env[:response]
195
+ return result(true, "ok", nil, false, r, nil) if r[:ok] == true
196
+ return rejected(r) if overloaded?(r)
197
+
198
+ generic_error(r)
199
+ end
200
+
201
+ def overloaded?(r) = r[:error_code] == "overloaded" && r[:committed] == false
202
+ def rejected(r) = result(false, "rejected_before_commit", false, true, r, r[:error])
203
+ def dup_conflict(r) = result(false, "duplicate_fact_id_conflict", false, false, r, r[:error])
204
+ def generic_error(r) = result(false, "error", r[:committed], r[:retryable], r, r[:error])
205
+ def transport_result(status, error, retryable:) = result(false, status, nil, retryable, nil, sanitize(error))
206
+
207
+ def result(ok, status, committed, retryable, response, error)
208
+ { ok: ok, status: status, committed: committed, retryable: retryable, response: response, error: error }
209
+ end
210
+
211
+ def retry_safe?(status)
212
+ status == "rejected_before_commit" || status == "timeout_unknown"
213
+ end
214
+
215
+ def summary(result)
216
+ { ok: result[:ok], status: result[:status], committed: result[:committed], retryable: result[:retryable] }
217
+ end
218
+
219
+ def sanitize(error)
220
+ text = "#{error.class}: #{error.message}"
221
+ return text unless @token && !@token.to_s.empty?
222
+
223
+ text.gsub(@token.to_s, "[REDACTED]")
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require_relative "mirror"
5
+
6
+ module ActsAsTbackend
7
+ # ActiveRecord integration for the refreshed core. `acts_as_tbackend` mirrors model
8
+ # lifecycle to TBackend on after_commit — synchronously, with a soft/non-fatal
9
+ # result (a down daemon never raises into the request path). For heavy write paths,
10
+ # call `record.tbackend_fact(...)` from your own background job instead.
11
+ #
12
+ # class Order < ApplicationRecord
13
+ # acts_as_tbackend store: "orders", except: %i[created_at updated_at]
14
+ # end
15
+ module Extension
16
+ extend ActiveSupport::Concern
17
+
18
+ included do
19
+ # Optional domain valid-time for the fact; defaults to nil.
20
+ attr_accessor :valid_time unless method_defined?(:valid_time)
21
+ end
22
+
23
+ class_methods do
24
+ def acts_as_tbackend(store: nil, only: nil, except: nil)
25
+ class_attribute :tbackend_options
26
+ self.tbackend_options = { store: (store || table_name).to_s, only: only, except: except }
27
+
28
+ after_commit :mirror_tbackend_create_update, on: %i[create update]
29
+ after_commit :mirror_tbackend_destroy, on: :destroy
30
+ end
31
+
32
+ # ---- class-level read API (routes through the shared pooled client) ----
33
+
34
+ def tbackend_history(id)
35
+ tbackend_guard([]) { ActsAsTbackend.client.facts_for(store: tbackend_options[:store], key: id.to_s) }
36
+ end
37
+
38
+ def tbackend_latest_for(id, as_of: nil)
39
+ tbackend_guard(nil) { ActsAsTbackend.client.latest_for(store: tbackend_options[:store], key: id.to_s, as_of: as_of) }
40
+ end
41
+
42
+ def tbackend_facts_by_seq(after_seq: 0, until_seq: nil)
43
+ tbackend_guard([]) do
44
+ ActsAsTbackend.client.facts_by_seq(store: tbackend_options[:store], after_seq: after_seq, until_seq: until_seq)
45
+ end
46
+ end
47
+
48
+ def tbackend_guard(default)
49
+ return default unless ActsAsTbackend.enabled?
50
+
51
+ yield
52
+ rescue StandardError => e
53
+ ActsAsTbackend::Extension.log("query error: #{e.message}")
54
+ default
55
+ end
56
+ end
57
+
58
+ # Public: build the fact for this record (no write) — for apps mirroring from
59
+ # their own job.
60
+ def tbackend_fact(event_type:, tombstone: false)
61
+ opts = self.class.tbackend_options
62
+ ActsAsTbackend::Mirror.build_fact(
63
+ record: self, store: opts[:store], event_type: event_type,
64
+ only: opts[:only], except: opts[:except], tombstone: tombstone, valid_time: valid_time
65
+ )
66
+ end
67
+
68
+ def mirror_tbackend(event_type:, tombstone: false)
69
+ opts = self.class.tbackend_options
70
+ ActsAsTbackend::Mirror.mirror!(
71
+ record: self, store: opts[:store], event_type: event_type,
72
+ only: opts[:only], except: opts[:except], tombstone: tombstone, valid_time: valid_time
73
+ )
74
+ rescue StandardError => e
75
+ ActsAsTbackend::Extension.log("mirror error: #{e.message}")
76
+ { ok: false, status: "error", committed: nil, retryable: nil, response: nil, error: e.message }
77
+ end
78
+
79
+ def self.log(message)
80
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
81
+ Rails.logger.error("[ActsAsTbackend] #{message}")
82
+ else
83
+ warn "[ActsAsTbackend] #{message}"
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ def mirror_tbackend_create_update
90
+ event = respond_to?(:previously_new_record?) && previously_new_record? ? "create" : "update"
91
+ mirror_tbackend(event_type: event)
92
+ end
93
+
94
+ def mirror_tbackend_destroy
95
+ mirror_tbackend(event_type: "destroy", tombstone: true)
96
+ end
97
+ end
98
+ end
99
+
100
+ # Self-install the macro when ActiveRecord loads (opt-in: the core entry does not
101
+ # require this file, keeping `require "acts_as_tbackend"` ActiveRecord-free).
102
+ if defined?(ActiveSupport) && ActiveSupport.respond_to?(:on_load)
103
+ ActiveSupport.on_load(:active_record) { include ActsAsTbackend::Extension }
104
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ActsAsTbackend
6
+ # Builds facts with a **deterministic, domain-derived id** so a retry re-sends the
7
+ # same id and collapses to an idempotent replay instead of a duplicate.
8
+ #
9
+ # NEVER put wall-clock in the id. Derive it from the source record's own version
10
+ # stamp (e.g. `updated_at`), which is stable across retries and advances on every
11
+ # real edit. The observation wall-clock belongs in `transaction_time` only.
12
+ #
13
+ # id = ActsAsTbackend::Fact.derive_id(
14
+ # store: "orders", record_id: order.id, event_type: "order.accepted",
15
+ # source_version: order.updated_at)
16
+ # fact = ActsAsTbackend::Fact.build(
17
+ # id:, store: "orders", key: "order:#{order.id}", value: {...},
18
+ # valid_time: order.scheduled_at)
19
+ module Fact
20
+ module_function
21
+
22
+ # Deterministic occurrence id. Components must be colon-free (the ":" is the id
23
+ # separator); source_version is normalised to a colon-free token.
24
+ def derive_id(store:, record_id:, event_type:, source_version:)
25
+ "#{store}:#{record_id}:#{event_type}:#{version_token(source_version)}"
26
+ end
27
+
28
+ # A Time is encoded as an integer microsecond epoch — fully deterministic
29
+ # (no float formatting) and colon-free. Anything else is used as-is (stringified).
30
+ def version_token(source_version)
31
+ if source_version.respond_to?(:usec) && source_version.respond_to?(:to_i)
32
+ source_version.to_i * 1_000_000 + source_version.usec
33
+ else
34
+ source_version.to_s
35
+ end
36
+ end
37
+
38
+ # Builds a fact envelope. `value_hash` is intentionally omitted: the daemon
39
+ # stamps its own canonical hash on write_fact_once and is the authority.
40
+ def build(id:, store:, key:, value:, valid_time: nil, transaction_time: nil,
41
+ causation: nil, schema_version: 1, producer: nil)
42
+ fact = {
43
+ "id" => id,
44
+ "store" => store.to_s,
45
+ "key" => key.to_s,
46
+ "value" => value,
47
+ "transaction_time" => (transaction_time || now).to_f,
48
+ "schema_version" => schema_version
49
+ }
50
+ fact["valid_time"] = valid_time.to_f unless valid_time.nil?
51
+ fact["causation"] = causation unless causation.nil?
52
+ fact["producer"] = producer unless producer.nil?
53
+ fact
54
+ end
55
+
56
+ def now
57
+ Process.clock_gettime(Process::CLOCK_REALTIME)
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module ActsAsTbackend
6
+ # Plain-Ruby record -> fact mirror, deliberately independent of ActiveSupport so it
7
+ # is unit-testable without Rails. The AR Extension delegates here; an app can also
8
+ # call `mirror!` directly from its own background job.
9
+ #
10
+ # A "record" is any object that responds to `#id`, `#attributes` (a Hash), and
11
+ # ideally `#updated_at` (used as the deterministic id version — a retry re-sends the
12
+ # same id and collapses to an idempotent replay).
13
+ module Mirror
14
+ module_function
15
+
16
+ def build_fact(record:, store:, event_type:, only: nil, except: nil, tombstone: false, valid_time: nil)
17
+ store = store.to_s
18
+ record_id = record.id
19
+ value = tombstone ? { "_tombstone" => true } : select_value(record, only: only, except: except)
20
+
21
+ Fact.build(
22
+ id: Fact.derive_id(store: store, record_id: record_id, event_type: event_type,
23
+ source_version: source_version(record)),
24
+ store: store,
25
+ key: "#{store}:#{record_id}",
26
+ value: value,
27
+ valid_time: valid_time || record_valid_time(record),
28
+ causation: "#{store}:#{record_id}:#{event_type}",
29
+ producer: ActsAsTbackend.config.producer
30
+ )
31
+ end
32
+
33
+ # Build + idempotent bounded-safe write. Soft/non-fatal: returns the client's soft
34
+ # result and never raises for a down daemon unless `config.strict`.
35
+ def mirror!(record:, store:, event_type:, **opts)
36
+ return disabled_result unless ActsAsTbackend.enabled?
37
+
38
+ fact = build_fact(record: record, store: store, event_type: event_type, **opts)
39
+ ActsAsTbackend.client.write_fact_once_safe(fact)
40
+ end
41
+
42
+ def select_value(record, only:, except:)
43
+ attrs = stringify(record.attributes)
44
+ if only
45
+ attrs.slice(*Array(only).map(&:to_s))
46
+ elsif except
47
+ attrs.except(*Array(except).map(&:to_s))
48
+ else
49
+ attrs
50
+ end
51
+ end
52
+
53
+ # A persisted version stamp, stable across retries; falls back to wall-clock only
54
+ # when the record has none (then the id is best-effort, not retry-stable).
55
+ def source_version(record)
56
+ if record.respond_to?(:updated_at) && record.updated_at
57
+ record.updated_at
58
+ else
59
+ Time.now
60
+ end
61
+ end
62
+
63
+ def record_valid_time(record)
64
+ record.respond_to?(:valid_time) ? record.valid_time : nil
65
+ end
66
+
67
+ def stringify(hash)
68
+ hash.each_with_object({}) { |(k, v), out| out[k.to_s] = v }
69
+ end
70
+
71
+ def disabled_result
72
+ { ok: true, status: "disabled", committed: nil, retryable: false, response: nil, error: nil }
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "connection_pool"
4
+
5
+ module ActsAsTbackend
6
+ # A pool of persistent Connections — the concurrency layer, kept deliberately
7
+ # separate from the Connection (protocol) so each can be reasoned about and tested
8
+ # on its own. Sized to the process's worker threads (≈ Puma `threads`).
9
+ #
10
+ # Fork-safety: sockets created before a fork are invalid in the child. Call
11
+ # `ActsAsTbackend.reset!` in the forking hook (Puma `on_worker_boot`, Sidekiq
12
+ # `on(:startup)`) so children build fresh connections.
13
+ class Pool
14
+ def initialize(config)
15
+ @pool = ConnectionPool.new(size: config.pool_size, timeout: config.pool_checkout_timeout) do
16
+ Connection.new(
17
+ host: config.host, port: config.port, token: config.token,
18
+ connect_timeout: config.connect_timeout, request_timeout: config.request_timeout,
19
+ durability_default: config.durability_default, strict: config.strict
20
+ )
21
+ end
22
+ end
23
+
24
+ def with(&block)
25
+ @pool.with(&block)
26
+ end
27
+
28
+ def shutdown
29
+ @pool.shutdown { |conn| conn.close }
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ # LEGACY (pre-refresh) — targets the old ActsAsTbackend API (write_fact / query_scope /
4
+ # async enqueue_job) and is NOT loaded by `require "acts_as_tbackend"`. Pending port to
5
+ # the Connection/Pool/Client/Mirror core. Kept for reference only; do not wire into apps.
6
+
7
+ require "open3"
8
+ require "json"
9
+ require "tempfile"
10
+ require "digest"
11
+ require "securerandom"
12
+ require "time"
13
+
14
+ module ActsAsTbackend
15
+ module ShadowComparison
16
+ class << self
17
+ # Submits a CRM result to be asynchronously compared against the Igniter VM execution.
18
+ #
19
+ # contract - String name of the contract (e.g., "BidSummary")
20
+ # inputs - Hash of input values (matching the contract's inputs signature)
21
+ # result - The result computed by the CRM (to compare against)
22
+ # opts - Hash of options (e.g., :host, :port)
23
+ def submit_crm_result(contract:, inputs:, result:, **opts)
24
+ return unless ActsAsTbackend.enabled?
25
+
26
+ host = opts[:host] || "127.0.0.1"
27
+ port = opts[:port] || 7401
28
+
29
+ job_args = {
30
+ contract: contract,
31
+ inputs: inputs,
32
+ result: result,
33
+ opts: { host: host, port: port }
34
+ }
35
+ ActsAsTbackend.enqueue_job("shadow_comparison", job_args)
36
+ end
37
+
38
+ def execute_comparison(contract:, inputs:, result:, **opts)
39
+ return unless ActsAsTbackend.enabled?
40
+
41
+ host = opts[:host] || "127.0.0.1"
42
+ port = opts[:port] || 7401
43
+
44
+ begin
45
+ # 1. Locate the compiled contract JSON under igniter-compiler/out/
46
+ contract_filename = "#{contract.gsub(/(.)([A-Z])/, '\1_\2').downcase}.json"
47
+ search_pattern = File.expand_path("../../../../igniter-compiler/out/*/contracts/#{contract_filename}", __FILE__)
48
+ contract_path = Dir.glob(search_pattern).first
49
+
50
+ unless contract_path && File.exist?(contract_path)
51
+ # Also search fallback in Out conformance test directory or igniter-vm test fixtures
52
+ fallback_pattern = File.expand_path("../../../../igniter-compiler/out_conformance_test/*/contracts/#{contract_filename}", __FILE__)
53
+ contract_path = Dir.glob(fallback_pattern).first
54
+ end
55
+
56
+ raise "Could not locate compiled contract JSON for #{contract}" unless contract_path
57
+
58
+ # 2. Write inputs to a temp file
59
+ temp_inputs = Tempfile.new(["inputs", ".json"])
60
+ temp_inputs.write(JSON.generate(inputs))
61
+ temp_inputs.close
62
+
63
+ # 3. Locate VM CLI binary and prepare command
64
+ vm_bin = File.expand_path("../../../../igniter-vm/target/release/igniter-vm", __FILE__)
65
+ unless File.exist?(vm_bin)
66
+ # Check debug target fallback
67
+ vm_bin = File.expand_path("../../../../igniter-vm/target/debug/igniter-vm", __FILE__)
68
+ end
69
+
70
+ cmd = [
71
+ vm_bin, "run",
72
+ "--contract", contract_path,
73
+ "--inputs", temp_inputs.path,
74
+ "--json",
75
+ "-b", "#{host}:#{port}"
76
+ ]
77
+
78
+ # 4. Execute VM CLI
79
+ start_time = Time.now
80
+ stdout, stderr, status = Open3.capture3(*cmd)
81
+ latency_ms = ((Time.now - start_time) * 1000).round(2)
82
+
83
+ temp_inputs.unlink # Clean up temp inputs file
84
+
85
+ # 5. Parse output and perform comparison
86
+ if status.success?
87
+ response = JSON.parse(stdout, symbolize_names: true)
88
+ if response[:status] == "success"
89
+ vm_result = response[:result]
90
+ matched = results_match?(result, vm_result)
91
+ delta = matched ? nil : compute_delta(result, vm_result)
92
+
93
+ # Commit result fact to TBackend
94
+ payload = {
95
+ contract_name: contract,
96
+ inputs_hash: Digest::SHA256.hexdigest(JSON.generate(inputs)),
97
+ crm_result: result,
98
+ igniter_result: vm_result,
99
+ matched: matched,
100
+ delta_json: delta,
101
+ latency_ms: latency_ms,
102
+ executed_at: Time.now.iso8601
103
+ }
104
+
105
+ client = ActsAsTbackend.client(host, port)
106
+ client.write_fact(
107
+ store: "shadow_results",
108
+ key: SecureRandom.uuid,
109
+ value: payload
110
+ )
111
+ else
112
+ log_error("VM reported execution failure: #{response[:error]}", stderr)
113
+ end
114
+ else
115
+ log_error("VM CLI exited with status #{status.exitstatus}", stderr)
116
+ end
117
+ rescue => e
118
+ log_error("Error in ShadowComparison: #{e.message}\n#{e.backtrace.join("\n")}")
119
+ end
120
+ end
121
+
122
+ private
123
+
124
+ def results_match?(crm_val, vm_val)
125
+ # Normalize and compare decimals vs normal numbers
126
+ crm_norm = normalize_val(crm_val)
127
+ vm_norm = normalize_val(vm_val)
128
+ crm_norm == vm_norm
129
+ end
130
+
131
+ def normalize_val(val)
132
+ if val.is_a?(Hash) && (val.key?(:value) || val.key?("value")) && (val.key?(:scale) || val.key?("scale"))
133
+ # It's a decimal hash representation
134
+ v = (val[:value] || val["value"]).to_i
135
+ s = (val[:scale] || val["scale"]).to_i
136
+ # Return float representation for comparison
137
+ v.to_f / (10**s)
138
+ elsif val.is_a?(Numeric)
139
+ val.to_f
140
+ elsif val.is_a?(String)
141
+ val.strip
142
+ elsif val.is_a?(Array)
143
+ val.map { |item| normalize_val(item) }
144
+ else
145
+ val
146
+ end
147
+ end
148
+
149
+ def compute_delta(crm_val, vm_val)
150
+ crm_norm = normalize_val(crm_val)
151
+ vm_norm = normalize_val(vm_val)
152
+
153
+ if crm_norm.is_a?(Numeric) && vm_norm.is_a?(Numeric)
154
+ { diff: (crm_norm - vm_norm).round(6) }
155
+ else
156
+ { crm: crm_val, vm: vm_val }
157
+ end
158
+ end
159
+
160
+ def log_error(msg, stderr = nil)
161
+ full_msg = "[ActsAsTbackend::ShadowComparison] #{msg}"
162
+ full_msg += "\nSTDERR: #{stderr}" if stderr && !stderr.empty?
163
+
164
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
165
+ Rails.logger.error(full_msg)
166
+ else
167
+ warn full_msg
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActsAsTbackend
4
+ VERSION = "0.2.0"
5
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "acts_as_tbackend/version"
4
+ require_relative "acts_as_tbackend/config"
5
+ require_relative "acts_as_tbackend/circuit_breaker"
6
+ require_relative "acts_as_tbackend/fact"
7
+ require_relative "acts_as_tbackend/connection"
8
+ require_relative "acts_as_tbackend/pool"
9
+ require_relative "acts_as_tbackend/client"
10
+ require_relative "acts_as_tbackend/mirror"
11
+
12
+ # Production connector for the TBackend temporal-ledger daemon.
13
+ #
14
+ # ActsAsTbackend.configure do |c|
15
+ # c.host = "127.0.0.1"; c.port = 7401
16
+ # c.token = ENV["TBACKEND_TOKEN"]
17
+ # c.pool_size = 12 # ≈ Puma threads/process
18
+ # end
19
+ #
20
+ # id = ActsAsTbackend::Fact.derive_id(store: "orders", record_id: o.id,
21
+ # event_type: "order.accepted", source_version: o.updated_at)
22
+ # fact = ActsAsTbackend::Fact.build(id:, store: "orders", key: "order:#{o.id}", value: {...})
23
+ # ActsAsTbackend.client.write_fact_once(fact) # idempotent, pooled, circuit-broken
24
+ #
25
+ # Layers, deliberately separate:
26
+ # Connection — one persistent framed socket + protocol (not thread-safe)
27
+ # Pool — N connections, checkout per thread (connection_pool)
28
+ # Client — facade: pool + circuit breaker; the app-facing API
29
+ module ActsAsTbackend
30
+ class << self
31
+ def config
32
+ @config ||= Config.new
33
+ end
34
+
35
+ # Master kill-switch (Config#enabled). When false the extension callbacks no-op.
36
+ def enabled?
37
+ config.enabled
38
+ end
39
+
40
+ def configure
41
+ yield config
42
+ reset!
43
+ config
44
+ end
45
+
46
+ # Shared pooled client. Thread-safe; memoized per process.
47
+ def client
48
+ @client ||= Client.new(config)
49
+ end
50
+
51
+ # Rebuild pool + client. Call in the forking hook (Puma `on_worker_boot`,
52
+ # Sidekiq `configure_server`) so a child never inherits a parent's sockets.
53
+ def reset!
54
+ old = @client
55
+ @client = nil
56
+ old&.shutdown
57
+ nil
58
+ end
59
+ end
60
+ end
metadata ADDED
@@ -0,0 +1,72 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: acts-as-tbackend
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Alexander
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: connection_pool
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.4'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.4'
26
+ description: 'Pooled, circuit-broken, idempotent client for TBackend: persistent framed
27
+ sockets, write_fact_once with deterministic ids, token auth, and a connection pool
28
+ sized for multi-threaded Rails.'
29
+ email:
30
+ - alexander.s.fokin@gmail.com
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - README.md
36
+ - lib/acts_as_tbackend.rb
37
+ - lib/acts_as_tbackend/circuit_breaker.rb
38
+ - lib/acts_as_tbackend/client.rb
39
+ - lib/acts_as_tbackend/config.rb
40
+ - lib/acts_as_tbackend/connection.rb
41
+ - lib/acts_as_tbackend/extension.rb
42
+ - lib/acts_as_tbackend/fact.rb
43
+ - lib/acts_as_tbackend/mirror.rb
44
+ - lib/acts_as_tbackend/pool.rb
45
+ - lib/acts_as_tbackend/shadow_comparison.rb
46
+ - lib/acts_as_tbackend/version.rb
47
+ homepage: https://github.com/alexander-s-f/acts-as-tbackend
48
+ licenses:
49
+ - MIT
50
+ metadata:
51
+ allowed_push_host: https://rubygems.org
52
+ rubygems_mfa_required: 'true'
53
+ homepage_uri: https://github.com/alexander-s-f/acts-as-tbackend
54
+ source_code_uri: https://github.com/alexander-s-f/acts-as-tbackend
55
+ rdoc_options: []
56
+ require_paths:
57
+ - lib
58
+ required_ruby_version: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '3.0'
63
+ required_rubygems_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ requirements: []
69
+ rubygems_version: 4.0.10
70
+ specification_version: 4
71
+ summary: Production Ruby connector for the TBackend temporal-ledger daemon.
72
+ test_files: []