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 +7 -0
- data/README.md +111 -0
- data/lib/acts_as_tbackend/circuit_breaker.rb +50 -0
- data/lib/acts_as_tbackend/client.rb +80 -0
- data/lib/acts_as_tbackend/config.rb +41 -0
- data/lib/acts_as_tbackend/connection.rb +226 -0
- data/lib/acts_as_tbackend/extension.rb +104 -0
- data/lib/acts_as_tbackend/fact.rb +60 -0
- data/lib/acts_as_tbackend/mirror.rb +75 -0
- data/lib/acts_as_tbackend/pool.rb +32 -0
- data/lib/acts_as_tbackend/shadow_comparison.rb +172 -0
- data/lib/acts_as_tbackend/version.rb +5 -0
- data/lib/acts_as_tbackend.rb +60 -0
- metadata +72 -0
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,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: []
|