trellis-pg 0.1.0.pre.alpha-x86_64-darwin

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: '068d596b53d3f71554be6027bdbe424704494adbe442cb101b0e7aec3bc82fed'
4
+ data.tar.gz: c2bc544d80aa6e83b030ca6e48cbd0ea8ec6bcb7a2728aa6679cf349012c152c
5
+ SHA512:
6
+ metadata.gz: 3a36f2129ce5b9f065eff7d8ad8452a5f2acf394306b3f72863592823a1d813910ad1c9bbaf6756f6f118e306e506b26cbbf688bd01beef2556dd634dbd209d6
7
+ data.tar.gz: f331e9a76a17647caffd0f11fd12a67434ca63c26a6594172b38ae5e1847e9fa8838b013cf034f5e97261e20c6d1c049859a02626673261f6dfc28c3139588f6
data/README.md ADDED
@@ -0,0 +1,567 @@
1
+ # Trellis for Ruby
2
+
3
+ Embedded Trellis for Ruby apps: a [Magnus](https://github.com/matsadler/magnus)
4
+ native extension over the `trellis` crate's `BlockingTrellis`, so a Rails app
5
+ can define and run streaming transforms without deploying a separate service.
6
+ The design is [ADR-0010](../../docs/decisions/0010-embeddable-clients.md).
7
+
8
+ The surface mirrors the Rust crate's `BlockingTrellis`, and the Elixir binding's (`clients/elixir`) wherever Ruby and Elixir
9
+ don't call for a difference. Every method is on `Trellis::Instance`, one
10
+ catalog schema's handle, and a process holds as many as it has schemas. The
11
+ `Trellis` module is the shortcut for the common case: it holds the process's
12
+ default instance and delegates every call to it.
13
+
14
+ ## Installing
15
+
16
+ The gem is `trellis-pg` (the name `trellis` is taken on RubyGems), required
17
+ as `trellis/pg`. The module is still `Trellis`.
18
+
19
+ ```ruby
20
+ # Gemfile
21
+ gem "trellis-pg", "0.1.0.pre.alpha"
22
+ ```
23
+
24
+ A prerelease is installed by naming it: `gem install trellis-pg --pre`, or the
25
+ version above in a Gemfile.
26
+
27
+ `Bundler.require`, which a Rails app's `config/application.rb` runs,
28
+ requires it by itself. Anywhere else, `require "trellis/pg"`.
29
+
30
+ Platform gems carry a prebuilt extension for Ruby 3.3, 3.4 and 4.0 on:
31
+
32
+ | Platform | Gem platform |
33
+ |---|---|
34
+ | Linux, glibc 2.29 or later, x86_64 and arm64 | `x86_64-linux`, `aarch64-linux` |
35
+ | Linux, musl (Alpine), x86_64 and arm64 | `x86_64-linux-musl`, `aarch64-linux-musl` |
36
+ | macOS, Apple silicon and Intel | `arm64-darwin`, `x86_64-darwin` |
37
+
38
+ Anywhere else, RubyGems installs the source gem, which builds the extension
39
+ as it installs. That needs a Rust toolchain (releases are built with Rust
40
+ 1.98), libclang (rb-sys generates its bindings with it: `libclang-dev` on
41
+ Debian and Ubuntu, `clang-devel` on Fedora) and network access, since Cargo
42
+ fetches the `trellis` crate from this repository at the released commit, and
43
+ its dependencies from crates.io. To build from source on a listed platform
44
+ too, on a glibc older than 2.29 say, ask for the source gem:
45
+ `gem install trellis-pg --platform ruby`, or
46
+ `gem "trellis-pg", force_ruby_platform: true` in a Gemfile.
47
+
48
+ Neither kind of gem includes the `trellis` crate's optional `otlp` feature
49
+ (OTLP trace export), so neither pulls in an OpenTelemetry or gRPC stack.
50
+ Nothing in the binding uses it.
51
+
52
+ ## Usage
53
+
54
+ - `Trellis.connect(url:, ...)`, `Trellis.migrate`, `Trellis.config`,
55
+ `Trellis.shutdown`, `Trellis.connected?`: the default instance's
56
+ lifecycle. `Trellis::Instance.connect(url:, ...)` returns another
57
+ instance, which has every call below as a method (see
58
+ [Several instances](#several-instances)).
59
+ - `Trellis.apply(statement)`: any statement of Trellis's grammar
60
+ (`TRANSFORM`, `PAUSE TRANSFORM`, `RESUME TRANSFORM`, `DROP TRANSFORM`,
61
+ `ALTER TRANSFORM`), returning what
62
+ it did as a `Trellis::Applied` whose `kind` names the outcome
63
+ (`:transform_defined`, `:paused`, `:resumed`, ...).
64
+ `Trellis.define(statement)` is an `apply` that refuses anything but a
65
+ `TRANSFORM`, and returns its fully qualified target table.
66
+ - `Trellis.status(target_table)`, `Trellis.definitions`: the catalog reads.
67
+ - `Trellis.quarantined`, `Trellis.quarantine_status(target)`,
68
+ `Trellis.sample_quarantined(target, limit:, after:)`,
69
+ `Trellis.poisoned_since(time)`: the quarantine reads. Resuming is a
70
+ statement: `Trellis.apply("RESUME TRANSFORM order_totals.total")`.
71
+ `Trellis.release_key(transform, source_table, key)` releases one key a
72
+ transform holds, once its cause is fixed; `status` reports how many it
73
+ holds (`held_keys`). `status` also reports a page the drain keeps failing
74
+ on with nothing charged or paused (`drain_failure`, a
75
+ `Trellis::DrainFailure`), and a finished `self_check` job's report lists
76
+ every one open (`drain_failures`). `status` names the paused upstream columns
77
+ (`paused_upstream_columns`, each `"target.column"`) that make a `live`
78
+ definition report `catching_up`. `status` lists the source indexes a
79
+ `GROUP BY` or `JOIN` target
80
+ needs and lacks (`source_index_gaps`, each a `Trellis::SourceIndexGap` with
81
+ `table`, `columns`, `role` (`:group_by`, `:join_from` or `:join_joined`: the part of
82
+ the definition that needs it), `opted_out` and a `create index` statement as `fix`);
83
+ `self_check` refuses such a target unless it was defined `WITHOUT SOURCE
84
+ INDEX`. `status` lists the partitions of a partitioned source a definition
85
+ is being corrected for while it reads `:backfilling` for them
86
+ (`correcting`, each a `Trellis::CorrectingNode` with `node`, `since` and
87
+ `keys_remaining`).
88
+ - `Trellis.request_backfill(source_table)`,
89
+ `Trellis.detect_capture_disruptions` (pauses now the readers of every table
90
+ whose Trellis capture was changed outside Trellis, and returns the tables),
91
+ `Trellis.has_live_drain_workers?`, `Trellis.has_live_staging_worker?`,
92
+ `Trellis.watermark_token`, `Trellis.await_converged(token, timeout_ms:)`.
93
+ - `Trellis.self_check(target_table, timeout_ms:, mode:)` and
94
+ `Trellis.self_check_job(id)`: check a target against a fresh recompute from
95
+ its source, as a background job. `self_check` starts it and returns a
96
+ `Trellis::SelfCheckJob` (`id`, `target`, `mode`, `state`, `rows_compared`,
97
+ `report`, `error`) at once; a drain worker runs it, and `self_check_job`
98
+ reads it back by `id` until `state` is `:done` (`:queued` and `:running` are
99
+ in progress; `:failed` and `:cancelled` carry an `error`; `finished?` says
100
+ which), when `report` is a `Trellis::SelfCheckReport` (`outcome`,
101
+ `divergences`, `rows_compared`, `truncated`, ...). A target whose job is
102
+ still unfinished gets that job back; the next call replaces a finished one,
103
+ and `self_check_job` returns `nil` for the replaced job or one whose
104
+ transform was dropped. With no drain worker anywhere in the fleet a job
105
+ stays `:queued`.
106
+
107
+ ```ruby
108
+ require "trellis/pg"
109
+
110
+ # A deploy's migration step: the defaults run nothing in the background.
111
+ Trellis.connect(url: "host=localhost dbname=app")
112
+ # TLS options go in the URL (docs/embedding.md, "TLS to Postgres"):
113
+ # url: "postgres://db.example.com/app?sslmode=verify-full&sslrootcert=/etc/ssl/ca.pem"
114
+ Trellis.migrate
115
+ Trellis.shutdown
116
+
117
+ # The app, once migrated. The staging worker reads the catalog as it starts,
118
+ # so a `staging: true` handle can't connect before `migrate` has run.
119
+ Trellis.connect(url: "host=localhost dbname=app", staging: true, drain_threads: 2)
120
+ Trellis.define("TRANSFORM widget_prices FROM widgets SELECT price AS price")
121
+ # => "public.widget_prices"
122
+ Trellis.status("widget_prices")
123
+ # => #<data Trellis::Status status=:live, backfill_failure=nil, capture_wait=nil,
124
+ # capture_failure=nil>, once backfilled
125
+
126
+ # A column that keeps failing is paused, not fatal: find it, see why, fix
127
+ # the data, resume it.
128
+ Trellis.quarantined
129
+ # => [#<data Trellis::QuarantineEntry target="widget_prices.price", state=:paused, ...>]
130
+ page = Trellis.sample_quarantined("widget_prices.price", limit: 50)
131
+ page.samples # => [#<data Trellis::PoisonSample src_table="public.widgets", key="7", ...>]
132
+ Trellis.apply("RESUME TRANSFORM widget_prices.price").columns
133
+ # => ["widget_prices.price"]
134
+ Trellis.shutdown
135
+ ```
136
+
137
+ `connect`'s options, all explicit (nothing is read from the environment):
138
+
139
+ | Option | Default | |
140
+ |---|---|---|
141
+ | `url:` | required | a libpq connection string or URL |
142
+ | `schema:` | `"trellis"` | the schema Trellis keeps its own tables in; not `"public"`, not `target_schema:`, and not another instance's schema or target schema |
143
+ | `target_schema:` | `"public"` | where a bare target table name is created |
144
+ | `staging:` | `false` | run the staging worker (installs change capture, starts backfills) here |
145
+ | `drain_threads:` | `0` | threads applying staged changes to the targets |
146
+ | `worker_threads:` | `2` | the threads of each Rust runtime a handle owns (its calls', and its background client's), invisible to Ruby's own sizing |
147
+
148
+ The defaults run nothing in the background. Exactly one process in a fleet
149
+ should set `staging: true`, and some process must run drain threads, or no
150
+ definition ever reaches `:live`.
151
+
152
+ ### Several instances
153
+
154
+ A process can hold several Trellis instances, for different catalog schemas
155
+ in one database or for different databases. `Trellis::Instance.connect`
156
+ takes `Trellis.connect`'s options and returns an instance with the same
157
+ methods:
158
+
159
+ ```ruby
160
+ reports = Trellis::Instance.connect(url: "host=localhost dbname=app", schema: "reports")
161
+ reports.migrate
162
+ reports.define("TRANSFORM monthly_totals FROM orders SELECT total AS total")
163
+ reports.status("monthly_totals").status # => :live, once it has backfilled
164
+ reports.shutdown # every other instance keeps running
165
+ ```
166
+
167
+ Each instance has its own connections, threads and `worker_threads` cap;
168
+ nothing is shared between them. `Trellis.connect` opens the default
169
+ instance, `Trellis.default_instance` returns it, and a second
170
+ `Trellis.connect` raises while it is connected. `Trellis::Instance.connect`
171
+ has no such limit. `Trellis::Instance.connected` lists the instances the
172
+ process holds, and `Trellis::Instance.shutdown_all` shuts them all down, the
173
+ default one included.
174
+
175
+ ### Conventions
176
+
177
+ - **One default instance, held by the `Trellis` module** (ADR-0010
178
+ decision 3). `Trellis.connect` raises if it is already connected;
179
+ `Trellis.shutdown` it first. An `at_exit` hook shuts down every instance
180
+ the process holds if the app didn't.
181
+ - **Every call releases the GVL** while it waits on the database, so other
182
+ threads (Puma's request threads, say) keep running, and `Thread#kill`,
183
+ `Thread#raise`, `Timeout.timeout` and Ctrl-C interrupt a thread waiting in
184
+ one. The thread waits for the reply itself, so an interrupt leaves no thread
185
+ behind. It abandons the wait, not the work: a `define` that was interrupted
186
+ may still register its transform (`Trellis.status` tells you whether it
187
+ did). Every call ends within 30 seconds on its own, and
188
+ the server stops what it started at that deadline, so an abandoned call
189
+ holds a pooled connection for at most that long. A call that runs out of
190
+ time raises `Trellis::TimeoutError`. A call stuck on a lock doesn't hold up the others:
191
+ calls from different threads run in parallel, in no particular order.
192
+ `shutdown` doesn't wait for a call stuck on a lock either: it cancels the
193
+ calls in flight, and they raise `Trellis::ValidationError`.
194
+ - **A handle doesn't survive `fork`.** Connect after forking: Puma's
195
+ `before_worker_boot` (`on_worker_boot` before Puma 7), Passenger's
196
+ `starting_worker_process`. A forked child's
197
+ calls on a handle it inherited raise `Trellis::ForkedHandleError` rather
198
+ than hang. `Trellis.shutdown` in a child that hasn't connected does
199
+ nothing. If the parent connects too (to migrate, say), shut it down before
200
+ forking. A child forked while the parent's handle was running (connected,
201
+ connecting on another thread, or not yet fully shut down) may have
202
+ inherited a lock one of that handle's threads held, which nothing in the
203
+ child can release, so its `connect` raises `Trellis::ForkedHandleError`
204
+ too, rather than risk hanging on it. Forks whose children
205
+ never connect, and `system`/`spawn`, are unaffected. [Forking
206
+ servers](#forking-servers) has the hooks for Puma (with `preload_app!`
207
+ and `fork_worker`), Unicorn and Passenger.
208
+
209
+ ```ruby
210
+ # config/puma.rb, in a Rails app (see "In a Rails app" below)
211
+ before_fork { Trellis::Instance.shutdown_all }
212
+ before_worker_boot { Trellis::Railtie.connect }
213
+ ```
214
+ - **Errors** are raised as a subclass of `Trellis::Error` per error code
215
+ (`ParseError`, `ValidationError`, `ConnectivityError`, `ConflictError`,
216
+ `NotFoundError`, `InternalError`, `TimeoutError`), each with `#code` (a
217
+ symbol) and `#message`. A code newer than the binding arrives as
218
+ `UnknownError`, naming the code in its message. `ForkedHandleError` is a
219
+ `ValidationError`. Raising is Ruby's idiom, so where the Elixir binding
220
+ has `{:ok, value}` / `{:error, error}` and bang variants, every Ruby
221
+ method returns its value or raises.
222
+ - **Words are symbols** (statuses, quarantine states, cardinalities,
223
+ `Applied` kinds, `self_check` modes, states and outcomes, and divergence kinds), each from a
224
+ closed set: none is ever made from a string the database returned.
225
+ - **Times are `Time`s** in UTC, at microsecond precision. They cross the
226
+ extension as epoch microseconds.
227
+ - **A quarantine target is an address string**, `"transform"` or
228
+ `"transform.column"`, exactly as `Trellis.quarantined` reports it.
229
+ - **Cursors and tokens are opaque strings**: `sample_quarantined`'s
230
+ `next_cursor` and `watermark_token`'s token. Pass back what the previous call
231
+ returned.
232
+ - **Durations are milliseconds**, as `timeout_ms:`, matching the Elixir
233
+ binding.
234
+ - **Results are `Data` values** (`Trellis::DefinitionSummary`,
235
+ `Trellis::QuarantineEntry`, ...), so they compare by value and
236
+ pattern-match: `case Trellis.apply(stmt) in { kind: :resumed, columns: }`.
237
+
238
+ ## In a Rails app
239
+
240
+ `require "trellis/pg"` after Rails (which `Bundler.require` in
241
+ `config/application.rb` does) loads `Trellis::Railtie`. Without Rails, the
242
+ gem loads no Rails code and requires no gem at all; `activerecord` and
243
+ `railties` are only ever the app's own dependencies.
244
+
245
+ ### Configuration and boot
246
+
247
+ The Rails integration reads `Trellis.connect`'s options from one place,
248
+ `config.trellis.connect`:
249
+
250
+ ```ruby
251
+ # config/application.rb (or config/environments/*.rb)
252
+ config.trellis.connect = {
253
+ url: ENV.fetch("DATABASE_URL"),
254
+ # The one dedicated worker process sets TRELLIS_WORKER=1.
255
+ staging: ENV["TRELLIS_WORKER"] == "1",
256
+ drain_threads: ENV["TRELLIS_WORKER"] == "1" ? 2 : 0
257
+ }
258
+ ```
259
+
260
+ With it set, the app's default instance connects as the app boots (an
261
+ `after_initialize` hook), and the `at_exit` hook shuts it down. Left `nil`,
262
+ the default, the Railtie connects no default instance.
263
+
264
+ An app with more instances names them in `config.trellis.instances`, each
265
+ with `Trellis::Instance.connect`'s options. They connect with the default
266
+ instance (which is optional when they are set), and
267
+ `Trellis::Railtie.named_instance(:reports)` returns one:
268
+
269
+ ```ruby
270
+ config.trellis.instances = {
271
+ reports: { url: ENV.fetch("REPORTS_DATABASE_URL"), schema: "reports" }
272
+ }
273
+ ```
274
+
275
+ If any instance fails to connect, the ones that did are shut down again,
276
+ and `Trellis::Railtie.connect` raises while one of them is already
277
+ connected, as `Trellis.connect` does.
278
+ With neither set, the Railtie does nothing. Two exceptions:
279
+
280
+ - **An app booted by a rake task gets no boot handle.** `rails db:create`
281
+ may run before the database exists, and `rails db:migrate` must not start
282
+ a worker's background work, so the process stays disconnected, as Ecto
283
+ migrates without starting the app. The migration helpers connect a handle
284
+ of their own. A rake task of your own that calls Trellis calls
285
+ `Trellis::Railtie.connect` first. It's the running task that counts:
286
+ `rails test` boots the app outside any task and connects, but tests run
287
+ by a rake task (`bin/rails db:test:prepare test`, or plain `rake`) don't,
288
+ so a test helper that needs the handles can call
289
+ `Trellis::Railtie.connect if Trellis::Instance.connected.empty?`.
290
+ - **`config.trellis.connect_on_boot = false`** skips the boot connect, for a
291
+ server that forks without a hook to shut the handle down first (a
292
+ preloading Passenger, say). Connect in each child with
293
+ `Trellis::Railtie.connect`.
294
+
295
+ ### Forking servers
296
+
297
+ A handle doesn't survive `fork`. A server that loads the app
298
+ before it forks has to shut the boot handle down in the parent and connect
299
+ one in each child. Puma preloads by default whenever it runs more than one
300
+ worker:
301
+
302
+ ```ruby
303
+ # config/puma.rb
304
+ before_fork { Trellis::Instance.shutdown_all } # the parent's boot handles
305
+ before_worker_boot { Trellis::Railtie.connect } # on_worker_boot before Puma 7
306
+
307
+ # Only with fork_worker, where worker 0 forks the others (at boot, on a
308
+ # respawn and on a refork): shut its handle down around each fork. The
309
+ # master, already disconnected by before_fork, stays that way.
310
+ trellis_was_connected = false
311
+ before_worker_fork { trellis_was_connected = !Trellis::Instance.connected.empty?; Trellis::Instance.shutdown_all }
312
+ after_worker_fork { Trellis::Railtie.connect if trellis_was_connected }
313
+ ```
314
+
315
+ (`before_refork`/`after_refork` aren't enough for `fork_worker`: worker 0
316
+ forks the first workers at boot, and respawns a dead one, without running
317
+ them.) Without `preload_app!`, each worker boots the app itself and the boot
318
+ connect is all it needs: leave these hooks out, since `Trellis` isn't loaded
319
+ yet when they run.
320
+
321
+ Unicorn's `before_fork` and `after_fork`, and Passenger's
322
+ `starting_worker_process` (with `connect_on_boot = false`), work the same
323
+ way. If the hooks are missing, the child's calls raise
324
+ `Trellis::ForkedHandleError` rather than hang. (Puma logs a hook's
325
+ exception as a warning and boots the worker anyway, so a failed connect
326
+ shows up as that warning and then as errors from the worker's calls.)
327
+
328
+ ### Migrations
329
+
330
+ `Trellis::Migration` makes Trellis statements read like the migration's
331
+ own:
332
+
333
+ ```ruby
334
+ class DefineOrderTotals < ActiveRecord::Migration[8.1]
335
+ include Trellis::Migration
336
+
337
+ # Required: the helpers raise before applying anything if the migration
338
+ # has a transaction open.
339
+ disable_ddl_transaction!
340
+
341
+ def up
342
+ define "TRANSFORM order_totals FROM orders SELECT price + tax AS total"
343
+ end
344
+
345
+ def down
346
+ apply "PAUSE TRANSFORM order_totals"
347
+ apply "DROP TRANSFORM order_totals"
348
+ end
349
+ end
350
+ ```
351
+
352
+ - `define`, `apply` and `status` are `Trellis.define`, `Trellis.apply` and
353
+ `Trellis.status`, run as the migration reaches them, so a transform can
354
+ read a table created earlier in the same migration.
355
+ - **No transaction.** Trellis never joins the migration's transaction: each
356
+ call commits on Trellis's own connections. `define` and `apply` raise
357
+ `ActiveRecord::MigrationError` before applying anything if the
358
+ migration's connection has a transaction open, so a migration missing
359
+ `disable_ddl_transaction!` rolls back with nothing applied on either
360
+ side.
361
+ - **`up` and `down`, not `change`.** ActiveRecord can't reverse a Trellis
362
+ statement: undoing a define is a `PAUSE TRANSFORM` and a `DROP
363
+ TRANSFORM` of a target only the statement names. A migration that
364
+ includes `Trellis::Migration` and defines a public `change` raises
365
+ before anything runs.
366
+ - **Which handle.** The helpers run on the default instance. If the process
367
+ is connected, they use its handle. Otherwise (`rails db:migrate` is never connected), each call
368
+ connects one from `config.trellis.connect`, always with `staging: false`
369
+ and `drain_threads: 0`, runs `Trellis.migrate`, makes its call and shuts
370
+ the handle down.
371
+ - **Once per database.** A define isn't idempotent, and the helpers add no
372
+ guard: `schema_migrations` already records which migrations have run.
373
+ A migration that has to tolerate a transform defined some other way can
374
+ guard its define itself, if you choose to:
375
+
376
+ ```ruby
377
+ def up
378
+ define "TRANSFORM order_totals FROM orders SELECT price + tax AS total" if status("order_totals").nil?
379
+ end
380
+ ```
381
+
382
+ The guard checks the target's name, not its definition. A changed
383
+ statement for a target that exists is an `ALTER TRANSFORM`, in a
384
+ migration of its own.
385
+ - **Schema dumps carry the definitions** (see "Schema dumps" below), when the
386
+ app uses `structure.sql`.
387
+
388
+ `rails trellis:migrate` creates or upgrades Trellis's own tables the same
389
+ way. The helpers run it whenever a migration defines something; run it in
390
+ the deploy step as well (`bin/rails trellis:migrate db:migrate`), so a
391
+ deploy that upgrades the gem without a new define still upgrades the
392
+ tables before the worker restarts.
393
+
394
+ `Trellis::Railtie.with_handle { ... }` is what `trellis:migrate` and the
395
+ helpers use, for a script or rake task of your own that needs Trellis the
396
+ way a migration does. It yields the instance, and `with_handle(:reports)`
397
+ runs on an instance of `config.trellis.instances`, which is how a migration
398
+ reaches one (`Trellis::Railtie.with_handle(:reports) { |reports|
399
+ reports.define "TRANSFORM ..." }`). `trellis:migrate` migrates the default
400
+ instance and every instance of `config.trellis.instances`. Neither it nor
401
+ `trellis:migrate` needs ActiveRecord.
402
+ Outside Rails, `require "trellis/migration"` and `Trellis.connect` before
403
+ migrating.
404
+
405
+ ## Fixtures and bulk loads in tests
406
+
407
+ Rails fixtures load with `disable_referential_integrity`, which disables
408
+ every trigger on the loaded tables, Trellis's capture triggers included, and
409
+ ends with `ENABLE TRIGGER ALL`. The rows it wrote never reached a target, so
410
+ Trellis pauses every definition that reads the table (`status.capture_failure`
411
+ has `kind: :disruption`) instead of letting a test read silently wrong data,
412
+ and waits for a `RESUME`. `Trellis::TestSupport` does that `RESUME` for a
413
+ suite, then waits for the rebuilt targets to catch up:
414
+
415
+ ```ruby
416
+ # test/test_helper.rb
417
+ require "trellis/test_support"
418
+
419
+ class ActiveSupport::TestCase
420
+ include Trellis::TestSupport
421
+ fixtures :all
422
+ # Rails loads the fixtures in its own setup, before this block runs.
423
+ setup { resume_capture_disruptions }
424
+ end
425
+ ```
426
+
427
+ `Trellis::TestSupport.resume_capture_disruptions(instance = Trellis.default_instance, timeout_ms: 30_000)`
428
+ does the same without the mixin, after any bulk load. It first pauses the
429
+ readers of any disrupted table itself (`Trellis.detect_capture_disruptions`),
430
+ so it doesn't wait for the staging worker's next pass; resumes only the
431
+ definitions paused with `kind: :disruption`; waits until each is `:live` and
432
+ every write committed so far has reached its targets; and returns the targets
433
+ it resumed (`[]` when nothing was disrupted, which costs one catalog read per
434
+ captured table). A definition an operator paused, or one paused by a schema
435
+ change or another failure, stays paused. A `RESUME` the engine refuses (a
436
+ table that is gone, a table define would now refuse) leaves that definition
437
+ paused; the rest are still resumed, and the call then raises
438
+ `Trellis::TestSupport::ResumeRefused` with `refused`, a hash of target to
439
+ reason. The instance needs its staging worker and a drain worker (`staging:
440
+ true, drain_threads: 1`), because the resume rebuilds through them.
441
+
442
+ ## Schema dumps
443
+
444
+ A Rails app that uses Trellis dumps its schema as `structure.sql`
445
+ (`config.active_record.schema_format = :sql`), and the dump carries Trellis's
446
+ definitions, the way it carries `schema_migrations`. `schema.rb` can't carry
447
+ another schema's tables or triggers, so with `schema_format = :ruby` the dump
448
+ (`db:migrate`'s and `db:schema:dump`) raises `Trellis::ValidationError` and
449
+ writes nothing.
450
+
451
+ ```ruby
452
+ # config/application.rb
453
+ config.active_record.schema_format = :sql
454
+ config.trellis.connect = { url: ENV.fetch("DATABASE_URL") }
455
+ ```
456
+
457
+ Nothing else is set up. After `pg_dump`, `db:migrate`'s dump and
458
+ `db:schema:dump` append `Trellis::Instance#dump_definitions_sql` for
459
+ `config.trellis.connect` and each of `config.trellis.instances`: Trellis's
460
+ migration history, the instance marker, the transform
461
+ definitions and the seed rows capture needs. The Railtie also adds each
462
+ instance's `schema` and `target_schema` to the `--schema` flags Rails passes
463
+ `pg_dump` (nothing is added when Rails dumps every schema), so the Trellis
464
+ tables, the target tables and the capture triggers are in the file.
465
+ `db:schema:load`, `db:prepare`, `db:setup` and the test database
466
+ `maintain_test_schema!` loads then build a database with every definition in
467
+ place. Each loads as `waiting_to_backfill`, and the worker builds it from the
468
+ source rows; a test database has none, so that is immediate. Runtime state
469
+ (ledger, ring, build chunks, quarantine, claims, statuses) is never dumped.
470
+
471
+ - The hook applies to one database config, `config.trellis.dump_database`
472
+ (default `"primary"`): the one `config.trellis.connect` and
473
+ `config.trellis.instances` point at. Other databases dump as Rails dumps
474
+ them. The dump raises `Trellis::ValidationError`, writing nothing, when
475
+ `config.trellis.connect` or an instance points at a different database than
476
+ that config. They are compared by host, port and database name: the Rails
477
+ config's as libpq resolves them (`PGHOST`, `PGPORT` and `PGDATABASE`
478
+ included), Trellis's `url` from the url alone (port 5432, database the
479
+ user's name). A config with no host uses libpq's local socket, which
480
+ matches a local Trellis host (`localhost`, `127.0.0.1`, `::1` or a socket
481
+ directory). Other host spellings are not equated, so `localhost` is not
482
+ `127.0.0.1`. `db:prepare` in development also dumps the test database
483
+ after migrating it; that dump is checked against development's config of
484
+ the same name, which `config.trellis.connect` points at.
485
+ - `pg_dump` and `dump_definitions_sql` read separate snapshots. The hook reads
486
+ the definitions before and after the `pg_dump` and redoes the dump when they
487
+ differ (three attempts, then `Trellis::ConflictError`), so a define or drop
488
+ that races a dump can't leave tables and definitions disagreeing.
489
+ - A definition that was paused or quarantined where the dump was taken loads
490
+ as `waiting_to_backfill`: the pause is runtime state.
491
+ - `pg_dump` 17 writes `CREATE SCHEMA public`, which a database made by
492
+ `db:create` already has; the hook writes `CREATE SCHEMA IF NOT EXISTS
493
+ public` instead.
494
+ - The dump connects through `Trellis::Railtie.with_handle`, so it migrates
495
+ Trellis's tables first if the database lacks them, and starts no worker.
496
+ - It hooks `ActiveRecord::Tasks::DatabaseTasks.dump_schema` and
497
+ `PostgreSQLDatabaseTasks#structure_dump`, which Rails marks `:nodoc:` and
498
+ offers no extension point for. A Rails upgrade that renames them fails the
499
+ test suite's round trip (`test/schema_dump_test.rb`).
500
+
501
+ ## Layout
502
+
503
+ - `lib/`: the `Trellis` module and `Trellis::Instance`, its error classes and its `Data` values,
504
+ the Rails integration (`trellis/railtie.rb`, `trellis/migration.rb`),
505
+ loaded only in an app that has Rails, and the test helpers
506
+ (`trellis/test_support.rb`), loaded only when required.
507
+ - `ext/trellis_ruby/`: the extension crate, a member of the repository's
508
+ Cargo workspace, and the `extconf.rb` that builds it with rb-sys.
509
+ Plain-data conversion and error codes come from `clients/embed`
510
+ (`trellis-embed`), shared with the Elixir binding.
511
+ - `trellis-pg.gemspec`: the source gem. The `Rakefile` derives the platform
512
+ gems from it.
513
+
514
+ The crate's code sits behind its `ruby` feature: rb-sys needs a Ruby install
515
+ and libclang to build, and the Rust-only CI job and `verify` have neither, so
516
+ without the feature (`cargo build --workspace`) it compiles to an empty
517
+ library. The root manifest leaves it out of `default-members` too.
518
+
519
+ ## Development
520
+
521
+ Ruby is pinned in `.tool-versions`. rb-sys generates its bindings with
522
+ libclang, so building needs it (`libclang-dev` on Debian/Ubuntu,
523
+ `clang-devel` on Fedora). From this directory:
524
+
525
+ ```sh
526
+ cargo build -p testkit --bin trellis-testkit # the suite's throwaway Postgres
527
+ bundle install
528
+ bundle exec rake test
529
+ ```
530
+
531
+ `rake compile:dev` (which `rake test` runs first) builds the extension the
532
+ way installing the source gem does, through `ext/trellis_ruby/extconf.rb`,
533
+ but with Cargo's dev profile and into the workspace's `target/`, and copies
534
+ it to `lib/trellis/`. `rake compile` builds the release profile. The suite spawns
535
+ `trellis-testkit` (from `PATH`, else the workspace's debug build) for a
536
+ Postgres cluster that is torn down when the run exits. It needs the Postgres
537
+ server binaries on `PATH`, like the Rust tests.
538
+
539
+ The tests use Minitest: it ships with Ruby, and its plain `assert_*` style
540
+ reads like the Elixir binding's ExUnit suite, which this one mirrors.
541
+ `test/parity_test.rb` runs `clients/parity/cases.json`, the fixture the
542
+ Elixir suite runs too, so the two bindings can't drift apart unnoticed (see
543
+ `clients/parity/README.md`).
544
+
545
+ ## Releasing
546
+
547
+ `trellis-pg` is released with the rest of Trellis, from one `v<version>` tag
548
+ ([docs/releasing.md](../../docs/releasing.md)); `Trellis::VERSION`
549
+ (`lib/trellis/version.rb`) is the release's. `.github/workflows/release.yml`
550
+ builds the six platform gems and the source gem, installs and loads each one
551
+ (every Linux gem on each Ruby it supports), and attaches them to the tag's
552
+ GitHub release. They are then pushed to RubyGems by hand, byte for byte, with
553
+ `.github/scripts/publish-packages.sh`, platform gems first.
554
+
555
+ A platform gem builds in rb-sys's cross-compiling container for its
556
+ platform, through `rb-sys-dock`, and takes one build of the extension per
557
+ Ruby minor version. `.github/scripts/build-ruby-gem.sh <platform> <out-dir>`
558
+ does that, locally too (it needs Docker or Podman; see the script). Adding a
559
+ Ruby version means adding it to that script's `RUBY_VERSIONS`, the
560
+ workflow's check matrix, the table above and, for a new oldest version,
561
+ `required_ruby_version` in the gemspec.
562
+
563
+ The source gem's extension crate has to build outside this repository, so
564
+ `.github/scripts/ruby-prepare-source-gem.sh` rewrites its path dependencies
565
+ as git dependencies on the released commit and gives it a lockfile, before
566
+ `gem build`. A `gem build` of an unprepared checkout makes a gem that only
567
+ installs from inside the checkout.
Binary file
Binary file
Binary file