trellis-pg 0.1.0.pre.alpha-aarch64-linux

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.
@@ -0,0 +1,386 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trellis
4
+ # Times cross the native boundary as signed microseconds since the Unix
5
+ # epoch (ADR-0010 decision 4) and become Times here, in Ruby, not in Rust.
6
+ # Not part of the public API.
7
+ module EpochMicros
8
+ # The largest and smallest epoch microseconds the engine takes (an i64).
9
+ RANGE = (-(2**63))..((2**63) - 1)
10
+
11
+ def self.to_time(micros)
12
+ ::Time.at(0, micros, :microsecond, in: "UTC")
13
+ end
14
+
15
+ # `time` in whole microseconds, truncated toward the past, or nil if the
16
+ # engine can't represent it.
17
+ def self.from_time(time)
18
+ micros = (time.to_r * 1_000_000).floor
19
+ micros if RANGE.cover?(micros)
20
+ end
21
+ end
22
+
23
+ # A registered transform definition, as Trellis.definitions lists it.
24
+ # target_table (fully qualified, "schema.table") identifies the transform;
25
+ # status is a symbol (:waiting_to_backfill, :backfilling, :live, ...) from
26
+ # a closed set, never one made from a string the database returned;
27
+ # created_at is when it was registered (a Time). backfill_failure is nil unless its build keeps
28
+ # failing. halt is nil unless the drain, or its build's merge, halted on
29
+ # it: a CaptureFailure of kind :halt, the definition :paused until the
30
+ # cause is fixed and it is resumed. Listing the definitions and looking for
31
+ # a halt is the health check for halts.
32
+ DefinitionSummary = Data.define(:target_table, :status, :created_at, :backfill_failure, :halt) do
33
+ def self.from_native(hash)
34
+ failure = hash[:backfill_failure]
35
+ halt = hash[:halt]
36
+ new(**hash.except(:created_at_micros, :backfill_failure, :halt),
37
+ created_at: EpochMicros.to_time(hash.fetch(:created_at_micros)),
38
+ backfill_failure: failure && BackfillFailure.from_native(failure),
39
+ halt: halt && CaptureFailure.from_native(halt))
40
+ end
41
+ end
42
+
43
+ # A definition's status, as Trellis.status returns it.
44
+ #
45
+ # status is one of :waiting_to_backfill, :backfilling, :catching_up,
46
+ # :live, :quarantined or :paused. A newly defined transform starts
47
+ # :waiting_to_backfill and reaches :live once some process in the fleet
48
+ # runs the staging worker and drain threads (see Trellis.connect).
49
+ #
50
+ # The other fields say what the definition is stuck on, and are nil when
51
+ # nothing holds it up:
52
+ # - backfill_failure: its build keeps failing (one of its build chunks).
53
+ # - capture_wait: installing or widening capture on a table it reads waits
54
+ # for a lock another session holds. It clears once that session lets go.
55
+ # - capture_failure: capture of a table it reads is broken, or the drain
56
+ # halted on it. A schema change paused it (resume it once fixed),
57
+ # installing capture keeps failing (it clears once the cause is fixed),
58
+ # or, with kind :halt, a failure no retry gets past reached it, so the
59
+ # drain (or its build's merge) paused it and what depends on it (resume
60
+ # it once fixed). A definition an operator paused has none.
61
+ # - held_keys: it holds keys in quarantine (a HeldKeys): source keys whose
62
+ # changes kept failing in its apply, so it leaves them out and their
63
+ # target rows stay as they were, whatever its status, :live included.
64
+ # Trellis.sample_quarantined lists them, and Trellis.release_key releases
65
+ # one once its cause is fixed.
66
+ # - drain_failure: the drain keeps failing on a page holding changes to a
67
+ # table it reads, with nothing charged or paused (a DrainFailure), so its
68
+ # target stops short of that page, whatever its status, :live included.
69
+ # Every drain pass retries the page; fix the cause its error names. A
70
+ # paused or quarantined definition has none.
71
+ # Every process sees them, whichever one runs the staging worker.
72
+ #
73
+ # source_index_gaps is a warning, never a hold-up: the SourceIndexGaps for
74
+ # the source indexes a GROUP BY or JOIN target needs and lacks ([] when it
75
+ # needs none or they exist). Define and a resume refuse a definition
76
+ # without them, so a gap is an index dropped since, or one the definition
77
+ # opted out of with WITHOUT SOURCE INDEX. Nothing is paused, but
78
+ # Trellis.self_check refuses to walk the target unless it opted out, when
79
+ # the walk scans the source once per page.
80
+ #
81
+ # correcting lists the CorrectingNodes of its partitioned source it is being
82
+ # corrected for, oldest first ([] when none): a partition was attached,
83
+ # detached, dropped or truncated, and the definition re-derives the keys
84
+ # it touched. It reads :backfilling while one is listed, and :live again
85
+ # once they are re-derived and folded into its target. A paused or
86
+ # quarantined definition lists none.
87
+ #
88
+ # paused_upstream_columns names the paused columns of upstream 1-1 targets
89
+ # that make a :live definition report :catching_up, each "target.column"
90
+ # ([] when none). A column is paused when its fuse tripped or PAUSE
91
+ # TRANSFORM t.col froze it; an aggregate or JOIN reading it keeps applying
92
+ # over its frozen values, so it is stale against the source. Nothing is
93
+ # paused or rebuilt: resuming the column writes through to the reader and
94
+ # it reads :live again.
95
+ Status = Data.define(:status, :backfill_failure, :capture_wait, :capture_failure,
96
+ :held_keys, :drain_failure, :source_index_gaps,
97
+ :correcting, :paused_upstream_columns) do
98
+ def self.from_native(hash)
99
+ failure = hash[:backfill_failure]
100
+ wait = hash[:capture_wait]
101
+ capture_failure = hash[:capture_failure]
102
+ held_keys = hash[:held_keys]
103
+ drain_failure = hash[:drain_failure]
104
+ new(status: hash[:status],
105
+ backfill_failure: failure && BackfillFailure.from_native(failure),
106
+ capture_wait: wait && CaptureWait.from_native(wait),
107
+ capture_failure: capture_failure && CaptureFailure.from_native(capture_failure),
108
+ held_keys: held_keys && HeldKeys.from_native(held_keys),
109
+ drain_failure: drain_failure && DrainFailure.from_native(drain_failure),
110
+ source_index_gaps: hash.fetch(:source_index_gaps).map { |g| SourceIndexGap.new(**g) },
111
+ correcting: hash.fetch(:correcting).map { |c| CorrectingNode.from_native(c) },
112
+ paused_upstream_columns: hash.fetch(:paused_upstream_columns))
113
+ end
114
+ end
115
+
116
+ # A node of a partitioned source a definition is being corrected for, as
117
+ # Trellis.status reports it: the schema-qualified node (node), when the
118
+ # staging worker queued its correction (since, a Time), and the keys still
119
+ # to re-derive (keys_remaining, nil until the correction has counted them;
120
+ # an estimate while it reads the node's own rows).
121
+ CorrectingNode = Data.define(:node, :since, :keys_remaining) do
122
+ def self.from_native(hash)
123
+ new(node: hash.fetch(:node),
124
+ since: EpochMicros.to_time(hash.fetch(:since_micros)),
125
+ keys_remaining: hash.fetch(:keys_remaining))
126
+ end
127
+ end
128
+
129
+ # A source index a GROUP BY or JOIN target needs and lacks, as
130
+ # Trellis.status reports it: the qualified table (table), the columns the
131
+ # index should lead with (columns), the part of the definition that needs it
132
+ # (role: :group_by for the GROUP BY columns, :join_from or :join_joined for a
133
+ # JOIN's ON columns on its from or joined side), whether the definition opted out with
134
+ # WITHOUT SOURCE INDEX (opted_out), and a create index statement to run
135
+ # (fix). Trellis doesn't create indexes on your tables.
136
+ SourceIndexGap = Data.define(:table, :columns, :role, :opted_out, :fix)
137
+
138
+ # A drain page that keeps failing with nothing charged or paused, as
139
+ # Trellis.status and Trellis.self_check report it: the segment whose page
140
+ # fails (seg_seq), the qualified source tables it holds changes to
141
+ # (tables), the latest failure's error and its SQLSTATE (sqlstate, nil when
142
+ # it didn't come from Postgres), when a drain first and last failed on it
143
+ # (since and last_seen, Times), and how many drain passes have (attempts).
144
+ DrainFailure = Data.define(:seg_seq, :tables, :error, :sqlstate, :since, :last_seen,
145
+ :attempts) do
146
+ def self.from_native(hash)
147
+ new(**hash.except(:since_micros, :last_seen_micros),
148
+ since: EpochMicros.to_time(hash.fetch(:since_micros)),
149
+ last_seen: EpochMicros.to_time(hash.fetch(:last_seen_micros)))
150
+ end
151
+ end
152
+
153
+ # The keys a definition holds in quarantine, as Trellis.status and
154
+ # Trellis.self_check report them: how many (count), and when the one held
155
+ # longest was last poisoned (oldest_poisoned_at, a Time).
156
+ HeldKeys = Data.define(:count, :oldest_poisoned_at) do
157
+ def self.from_native(hash)
158
+ new(count: hash.fetch(:count),
159
+ oldest_poisoned_at: EpochMicros.to_time(hash.fetch(:oldest_poisoned_at_micros)))
160
+ end
161
+ end
162
+
163
+ # What a definition's capture waits on: the staging worker's install,
164
+ # widen or uninstall (operation, a String) of the capture triggers on
165
+ # table needs lock_mode, and another session holds or is queued for a
166
+ # conflicting lock. blockers has one line per such session. waiting_since
167
+ # and observed_at are Times.
168
+ CaptureWait = Data.define(:table, :operation, :lock_mode, :waiting_since, :observed_at,
169
+ :blockers) do
170
+ def self.from_native(hash)
171
+ new(**hash.except(:waiting_since_micros, :observed_at_micros),
172
+ waiting_since: EpochMicros.to_time(hash.fetch(:waiting_since_micros)),
173
+ observed_at: EpochMicros.to_time(hash.fetch(:observed_at_micros)))
174
+ end
175
+ end
176
+
177
+ # Why capture of source_table is broken, or why the drain (or a build's
178
+ # merge) halted on the definition: kind is :capture or :halt, error is a
179
+ # sentence naming the cause, columns the columns it is about (empty when it
180
+ # isn't about a column, and for a halt), and detected_at (a Time) when it
181
+ # was first found.
182
+ CaptureFailure = Data.define(:kind, :source_table, :columns, :error, :detected_at) do
183
+ def self.from_native(hash)
184
+ new(**hash.except(:detected_at_micros),
185
+ detected_at: EpochMicros.to_time(hash.fetch(:detected_at_micros)))
186
+ end
187
+ end
188
+
189
+ # Why a definition's build isn't finishing: the build of source_table has
190
+ # failed `attempts` times, the latest being last_error, and won't be
191
+ # retried before next_attempt_at (a Time). It retries on its own, except
192
+ # that a build failing for a reason no row explains pauses the definition
193
+ # after a few attempts (next_attempt_at is then when it paused). Fix the
194
+ # cause, resume a paused definition, and the next attempt goes through.
195
+ BackfillFailure = Data.define(:source_table, :attempts, :last_error, :next_attempt_at) do
196
+ def self.from_native(hash)
197
+ new(**hash.except(:next_attempt_at_micros),
198
+ next_attempt_at: EpochMicros.to_time(hash.fetch(:next_attempt_at_micros)))
199
+ end
200
+ end
201
+
202
+ # What Trellis.apply did. `kind` says which statement form ran, and each
203
+ # other field is set only for the kinds that carry it (nil otherwise):
204
+ #
205
+ # - :transform_defined: a TRANSFORM statement registered the transform
206
+ # writing `target_table` (fully qualified), at :waiting_to_backfill.
207
+ # - :paused: a PAUSE TRANSFORM statement froze its subject, or found it
208
+ # already frozen.
209
+ # - :resumed: a RESUME TRANSFORM statement unfroze its subject. `columns`
210
+ # lists every column a column resume resumed, as "transform.column": the
211
+ # one it named, then any dependent whose pause was only that one's
212
+ # cascade. It is [] for a whole-transform resume, which drops the
213
+ # transform to :waiting_to_backfill to rebuild.
214
+ # - :dropped: a DROP statement removed its subject, or found it already
215
+ # gone.
216
+ # - :altered: an ALTER TRANSFORM statement edited the transform writing `target_table`. `added`,
217
+ # `dropped` and `altered` name only the fields this call changed.
218
+ # - :unknown: the statement was applied, but its outcome is newer than this
219
+ # version of the binding, which has no shape for it. It is still a
220
+ # success: don't retry the statement, which would apply it twice.
221
+ #
222
+ # It pattern-matches by kind:
223
+ #
224
+ # case Trellis.apply("RESUME TRANSFORM order_totals.total")
225
+ # in { kind: :resumed, columns: } then puts "resumed #{columns.join(', ')}"
226
+ # end
227
+ Applied = Data.define(:kind, :target_table, :columns, :added, :dropped, :altered) do
228
+ def self.from_native(hash)
229
+ new(**hash)
230
+ end
231
+ end
232
+
233
+ # One target's quarantine state, as Trellis.quarantined lists it and
234
+ # Trellis.quarantine_status reports it.
235
+ #
236
+ # target is an address: a transform's bare target table name
237
+ # ("order_totals") for the whole transform, or "order_totals.total" for one
238
+ # of its columns. Pass it straight back to Trellis.quarantine_status,
239
+ # Trellis.sample_quarantined, or a RESUME TRANSFORM statement.
240
+ #
241
+ # state is :live or :paused for a column; a whole transform reports its
242
+ # lifecycle status (see Status). paused_at (a Time) and last_error are set
243
+ # only for a paused column: when its pause tripped, and its most recent
244
+ # failure (nil for a pause that cascaded from an upstream column rather
245
+ # than failing on its own).
246
+ QuarantineEntry = Data.define(:target, :state, :paused_at, :last_error) do
247
+ def self.from_native(hash)
248
+ micros = hash[:paused_at_micros]
249
+ new(**hash.except(:paused_at_micros), paused_at: micros && EpochMicros.to_time(micros))
250
+ end
251
+ end
252
+
253
+ # One source row the apply path gave up on, as Trellis.poisoned_since
254
+ # lists it: the transform it's held for (every other transform reading the
255
+ # row keeps applying it), its fully-qualified src_table, its key as Trellis
256
+ # renders it, the error that poisoned it, and when (poisoned_at, a Time).
257
+ PoisonEntry = Data.define(:transform, :src_table, :key, :last_error, :poisoned_at) do
258
+ def self.from_native(hash)
259
+ new(**hash.except(:poisoned_at_micros),
260
+ poisoned_at: EpochMicros.to_time(hash.fetch(:poisoned_at_micros)))
261
+ end
262
+ end
263
+
264
+ # One quarantined source row, as Trellis.sample_quarantined pages them: its
265
+ # fully-qualified src_table, its key as Trellis renders it, and the error
266
+ # it failed with.
267
+ PoisonSample = Data.define(:src_table, :key, :error_message)
268
+
269
+ # One page of Trellis.sample_quarantined, ordered by (src_table, key).
270
+ #
271
+ # next_cursor is opaque: pass it back as the `after:` option for the next
272
+ # page, and don't read or build one. The last page is the one with fewer
273
+ # rows than the limit. An empty page hands back the cursor it was asked
274
+ # for, so polling from where you got to never falls back to the first page;
275
+ # next_cursor is nil only for an empty first page.
276
+ SamplePage = Data.define(:samples, :next_cursor) do
277
+ def self.from_native(hash)
278
+ new(samples: hash.fetch(:samples).map { |sample| PoisonSample.new(**sample) },
279
+ next_cursor: hash.fetch(:next_cursor))
280
+ end
281
+ end
282
+
283
+ # A background check of a target table, as Trellis.self_check starts it and
284
+ # Trellis.self_check_job reads it back.
285
+ #
286
+ # - id: what Trellis.self_check_job takes.
287
+ # - target: the audited target's bare table name.
288
+ # - mode: :standard or :strict, as the job was started. A second
289
+ # Trellis.self_check of a target whose job is still running returns that
290
+ # job, so its mode is the first call's.
291
+ # - state: :queued (no worker has taken it up yet; with no drain worker
292
+ # anywhere in the fleet it stays so), :running, then :done, :failed
293
+ # (error says why) or :cancelled (the worker running it shut down; error
294
+ # says so). Only :done carries a report.
295
+ # - rows_compared: the distinct keys the pages so far compared; it moves
296
+ # while the job is :running.
297
+ # - report: the SelfCheckReport, once the job is :done; nil before.
298
+ # - error: why a :failed or :cancelled job ended; nil otherwise.
299
+ #
300
+ # A job ends when its transform is dropped, and Trellis.self_check_job then
301
+ # returns nil for it.
302
+ SelfCheckJob = Data.define(:id, :target, :mode, :state, :rows_compared, :report, :error) do
303
+ def self.from_native(hash)
304
+ report = hash[:report]
305
+ new(**hash, report: report && SelfCheckReport.from_native(report))
306
+ end
307
+
308
+ # Whether the job has ended, so polling it again changes nothing.
309
+ def finished?
310
+ %i[done failed cancelled].include?(state)
311
+ end
312
+ end
313
+
314
+ # What a finished Trellis.self_check job found auditing the whole of a
315
+ # target table against a fresh recompute of its definition from the source.
316
+ #
317
+ # - outcome: :converged (every compared cell, row and column matched),
318
+ # :not_caught_up (the target didn't catch up within timeout_ms: not a
319
+ # verdict on correctness), :not_live (the transform isn't :live, so
320
+ # nothing was awaited or compared; see status), or :diverged (see
321
+ # divergences).
322
+ # - status: the transform's status when outcome is :not_live (a rebuild
323
+ # Trellis.request_backfill starts reads :backfilling from the call's
324
+ # return, so poll status until it is :live, then check again); nil for
325
+ # every other outcome.
326
+ # - divergences: the Divergences found; [] unless outcome is :diverged.
327
+ # - rows_compared: the distinct keys compared.
328
+ # - truncated: true when the job stopped before the end of the target's
329
+ # keys: it found 1,000 divergences, or the transform stopped being :live,
330
+ # stopped catching up or lost its capture partway. rows_compared says how
331
+ # far it got.
332
+ # - checked_through: the watermark the outcome holds through, a token
333
+ # Trellis.await_converged takes.
334
+ # - held_keys: whatever the outcome, a HeldKeys while the transform holds
335
+ # keys in quarantine, nil otherwise; read when the job is polled. Their
336
+ # target rows are ones the audit can't vouch for, and a key with held
337
+ # changes keeps the target from catching up, so the outcome is
338
+ # :not_caught_up until it is released.
339
+ # - drain_failures: whatever the outcome, every DrainFailure open on the
340
+ # instance when the job is polled, oldest first, whichever definitions
341
+ # read its tables; [] when there is none. Each holds back the targets of
342
+ # the tables it holds changes to, and with them the convergence the audit
343
+ # waits on.
344
+ SelfCheckReport = Data.define(:target, :outcome, :status, :divergences, :rows_compared,
345
+ :truncated, :checked_through, :held_keys, :drain_failures) do
346
+ def self.from_native(hash)
347
+ held_keys = hash[:held_keys]
348
+ new(**hash, divergences: hash.fetch(:divergences).map { |d| Divergence.new(**d) },
349
+ held_keys: held_keys && HeldKeys.from_native(held_keys),
350
+ drain_failures: hash.fetch(:drain_failures).map { |f| DrainFailure.from_native(f) })
351
+ end
352
+ end
353
+
354
+ # One thing Trellis.self_check found out of step, by kind:
355
+ #
356
+ # - :cell: the row exists on both sides but `column` differs at `key`.
357
+ # `persisted` is the target table's value and `recomputed` the fresh
358
+ # recompute's, both as Postgres renders them as text (nil for NULL).
359
+ # - :missing_row: the recompute produced `key`, but the target has no row
360
+ # for it.
361
+ # - :extra_row: the target has a row for `key` that the recompute didn't
362
+ # produce.
363
+ # - :missing_column: the definition expects `column`, but the target table
364
+ # doesn't have it.
365
+ # - :extra_column: the target table has `column`, but the definition
366
+ # doesn't expect it.
367
+ # - :capture: a table the target is computed from isn't being captured as
368
+ # Trellis installed it (a capture trigger missing, disabled or calling
369
+ # the wrong function, a capture function missing or with the wrong
370
+ # owner, the Trellis role missing a privilege, or the table joined a
371
+ # partition or inheritance hierarchy). `detail` says what, and `table`
372
+ # names the table (nil for a missing privilege). self_check reports these
373
+ # before, and instead of, comparing any rows.
374
+ #
375
+ # Fields a kind doesn't carry are nil.
376
+ Divergence = Data.define(:kind, :key, :column, :persisted, :recomputed, :table, :detail)
377
+
378
+ # The configuration this process's handle connected with, as Trellis.config
379
+ # returns it: connect's schema and target_schema, and the connection pool's
380
+ # size cap and how long a call waits for a free connection.
381
+ #
382
+ # Not the url: a connection string can carry a password, and a Config is
383
+ # the kind of value that ends up in a log line whole. The caller already
384
+ # has the url it connected with.
385
+ Config = Data.define(:schema, :target_schema, :pool_max_size, :pool_wait_timeout_ms)
386
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trellis
4
+ # The trellis-pg gem's version: the Trellis release's, which a
5
+ # `v<VERSION>` tag releases (docs/releasing.md).
6
+ VERSION = "0.1.0-alpha"
7
+ end
metadata ADDED
@@ -0,0 +1,66 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: trellis-pg
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0.pre.alpha
5
+ platform: aarch64-linux
6
+ authors:
7
+ - Salesforce
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-10-26 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: |
14
+ A native extension over the trellis crate's BlockingTrellis, so a Rails
15
+ app can define and run Trellis's streaming transforms in its own process,
16
+ against its own Postgres database, without deploying a separate service.
17
+ email:
18
+ executables: []
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - README.md
23
+ - lib/trellis/3.3/trellis_ruby.so
24
+ - lib/trellis/3.4/trellis_ruby.so
25
+ - lib/trellis/4.0/trellis_ruby.so
26
+ - lib/trellis/error.rb
27
+ - lib/trellis/instance.rb
28
+ - lib/trellis/migration.rb
29
+ - lib/trellis/pg.rb
30
+ - lib/trellis/railtie.rb
31
+ - lib/trellis/schema_dump.rb
32
+ - lib/trellis/test_support.rb
33
+ - lib/trellis/values.rb
34
+ - lib/trellis/version.rb
35
+ homepage: https://github.com/salesforce-misc/trellis/tree/main/clients/ruby
36
+ licenses:
37
+ - Apache-2.0
38
+ metadata:
39
+ source_code_uri: https://github.com/salesforce-misc/trellis/tree/main/clients/ruby
40
+ bug_tracker_uri: https://github.com/salesforce-misc/trellis/issues
41
+ rubygems_mfa_required: 'true'
42
+ cargo_crate_name: trellis_ruby
43
+ post_install_message:
44
+ rdoc_options: []
45
+ require_paths:
46
+ - lib
47
+ required_ruby_version: !ruby/object:Gem::Requirement
48
+ requirements:
49
+ - - ">="
50
+ - !ruby/object:Gem::Version
51
+ version: '3.3'
52
+ - - "<"
53
+ - !ruby/object:Gem::Version
54
+ version: 4.1.dev
55
+ required_rubygems_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '0'
60
+ requirements: []
61
+ rubygems_version: 3.5.23
62
+ signing_key:
63
+ specification_version: 4
64
+ summary: 'Embedded Trellis: streaming SQL transforms inside your Postgres, run from
65
+ a Ruby app'
66
+ test_files: []