trellis-pg 0.1.0-aarch64-linux-musl

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: 8cfefa4d5e0d26498ce019c4c36b856aba3318440a2853610e9a2cd437fc98a8
4
+ data.tar.gz: ebfb4931d6321739a3deb964f2952062ad50b075d7194de37bea9bd85f86c301
5
+ SHA512:
6
+ metadata.gz: 6ae7d33662d01772f64e83fc436792c467d79cfa5848abfded5f0c839bdf0b4d1c1048950ae3a94113678cc32578d967d025f295c5e27a06fb1607c4a899861c
7
+ data.tar.gz: 6b489ce476c174abd206c78ad606adf54e3e416ad15661915fc81d8ca31721c3e31302b0abbeb18f9a041b36816557b00921189d13fd6093238371efde671c16
data/README.md ADDED
@@ -0,0 +1,620 @@
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
+ - `Trellis::Metrics.render_prometheus`: the process-wide metrics registry as
107
+ Prometheus text, for a `/metrics` route the app already serves (the
108
+ binding opens no port; see [Metrics](#metrics)). It needs no handle.
109
+
110
+ ```ruby
111
+ require "trellis/pg"
112
+
113
+ # A deploy's migration step: the defaults run nothing in the background.
114
+ Trellis.connect(url: "host=localhost dbname=app")
115
+ # TLS options go in the URL (docs/embedding.md, "TLS to Postgres"):
116
+ # url: "postgres://db.example.com/app?sslmode=verify-full&sslrootcert=/etc/ssl/ca.pem"
117
+ Trellis.migrate
118
+ Trellis.shutdown
119
+
120
+ # The app, once migrated. The staging worker reads the catalog as it starts,
121
+ # so a `staging: true` handle can't connect before `migrate` has run.
122
+ Trellis.connect(url: "host=localhost dbname=app", staging: true, drain_threads: 2)
123
+ Trellis.define("TRANSFORM widget_prices FROM widgets SELECT price AS price")
124
+ # => "public.widget_prices"
125
+ Trellis.status("widget_prices")
126
+ # => #<data Trellis::Status status=:live, backfill_failure=nil, capture_wait=nil,
127
+ # capture_failure=nil>, once backfilled
128
+
129
+ # A column that keeps failing is paused, not fatal: find it, see why, fix
130
+ # the data, resume it.
131
+ Trellis.quarantined
132
+ # => [#<data Trellis::QuarantineEntry target="widget_prices.price", state=:paused, ...>]
133
+ page = Trellis.sample_quarantined("widget_prices.price", limit: 50)
134
+ page.samples # => [#<data Trellis::PoisonSample src_table="public.widgets", key="7", ...>]
135
+ Trellis.apply("RESUME TRANSFORM widget_prices.price").columns
136
+ # => ["widget_prices.price"]
137
+ Trellis.shutdown
138
+ ```
139
+
140
+ `connect`'s options, all explicit (nothing is read from the environment):
141
+
142
+ | Option | Default | |
143
+ |---|---|---|
144
+ | `url:` | required | a libpq connection string or URL |
145
+ | `schema:` | `"trellis"` | the schema Trellis keeps its own tables in; not `"public"`, not `target_schema:`, and not another instance's schema or target schema |
146
+ | `target_schema:` | `"public"` | where a bare target table name is created |
147
+ | `staging:` | `false` | run the staging worker (installs change capture, starts backfills) here |
148
+ | `drain_threads:` | `0` | threads applying staged changes to the targets |
149
+ | `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 |
150
+ | `max_call_pool_size:` | `20` | the most connections the handle's call pool opens; the background pool is sized by `staging:` and `drain_threads:` |
151
+ | `call_pool_wait_timeout_ms:` | `30_000` | how long a call waits for a free call-pool connection before a `ConnectivityError` |
152
+
153
+ The defaults run nothing in the background. Some process in a fleet must set
154
+ `staging: true` and some process must run drain threads, or no definition ever
155
+ reaches `:live`. Any number of processes may set `staging: true`: one takes the
156
+ producer lock and leads, the rest stand by and take over when it goes away, so a
157
+ rolling deploy needs no operator step. Under a preforking Puma with
158
+ `TRELLIS_WORKER=1`, every Puma worker connects with `staging: true`, so all but
159
+ one stand by; that is harmless, and each polls the lock every 5 seconds (see
160
+ [Rolling deploys](../../docs/embedding.md#rolling-deploys)).
161
+
162
+ ### Resources
163
+
164
+ At the defaults, a handle holds up to 20 Postgres connections (its call pool,
165
+ opened as calls need them and closed after 60 seconds idle) and 3 threads. A
166
+ worker handle (`staging: true, drain_threads: N`) also owns a background pool
167
+ of up to `2N + 5` connections (`2N + 4` without staging, `1` with staging
168
+ alone), closed after 60 seconds idle, and two connections that stay open: the
169
+ staging session and one `LISTEN` shared by its drain threads. It holds 6
170
+ threads: drain threads are tasks on its runtime, not OS threads. Its memory is
171
+ bounded by row counts, not bytes: about `N` × 100,000 changed rows' images at
172
+ its peak. `max_call_pool_size:` lowers the 20 (the background pool is sized by
173
+ the handle's roles, not by an option), and `worker_threads:` sets the threads.
174
+ Every handle (each `Trellis::Instance` too) costs this again. The full
175
+ accounting is in
176
+ [`docs/embedding.md`](../../docs/embedding.md#what-a-handle-uses).
177
+
178
+ ### Metrics
179
+
180
+ The engine's metrics live in one registry per process, holding the series of
181
+ every handle in it; `Trellis::Metrics.render_prometheus` renders them in
182
+ Prometheus's text format. Every series carries a `trellis_instance` label
183
+ (`<database>/<catalog schema>`), so instances in one process stay apart, and
184
+ `trellis_instance_up` is 1 while an instance's handle runs and 0 after it
185
+ shuts down. The metrics themselves are listed in
186
+ [`docs/observability.md`](../../docs/observability.md).
187
+
188
+ The binding opens no port, so serve the text from a route of the app's own:
189
+
190
+ ```ruby
191
+ # config/routes.rb
192
+ get "/metrics", to: proc {
193
+ [200, { "content-type" => "text/plain; version=0.0.4" }, [Trellis::Metrics.render_prometheus]]
194
+ }
195
+ ```
196
+
197
+ Under a forking server each worker process has its own registry, holding
198
+ only its own handles' series, so Prometheus scrapes each worker (or the
199
+ worker handle, `staging: true`, runs in a process of its own that it
200
+ scrapes). A process forked while its parent had a handle running can't
201
+ render the registry, as it can't connect: it raises
202
+ `Trellis::ForkedHandleError` (see [Forking servers](#forking-servers)).
203
+
204
+ ### Several instances
205
+
206
+ A process can hold several Trellis instances, for different catalog schemas
207
+ in one database or for different databases. `Trellis::Instance.connect`
208
+ takes `Trellis.connect`'s options and returns an instance with the same
209
+ methods:
210
+
211
+ ```ruby
212
+ reports = Trellis::Instance.connect(url: "host=localhost dbname=app", schema: "reports")
213
+ reports.migrate
214
+ reports.define("TRANSFORM monthly_totals FROM orders SELECT total AS total")
215
+ reports.status("monthly_totals").status # => :live, once it has backfilled
216
+ reports.shutdown # every other instance keeps running
217
+ ```
218
+
219
+ Each instance has its own connections, threads and `worker_threads` cap;
220
+ nothing is shared between them. `Trellis.connect` opens the default
221
+ instance, `Trellis.default_instance` returns it, and a second
222
+ `Trellis.connect` raises while it is connected. `Trellis::Instance.connect`
223
+ has no such limit. `Trellis::Instance.connected` lists the instances the
224
+ process holds, and `Trellis::Instance.shutdown_all` shuts them all down, the
225
+ default one included.
226
+
227
+ ### Conventions
228
+
229
+ - **One default instance, held by the `Trellis` module** (ADR-0010
230
+ decision 3). `Trellis.connect` raises if it is already connected;
231
+ `Trellis.shutdown` it first. An `at_exit` hook shuts down every instance
232
+ the process holds if the app didn't.
233
+ - **Every call releases the GVL** while it waits on the database, so other
234
+ threads (Puma's request threads, say) keep running, and `Thread#kill`,
235
+ `Thread#raise`, `Timeout.timeout` and Ctrl-C interrupt a thread waiting in
236
+ one. The thread waits for the reply itself, so an interrupt leaves no thread
237
+ behind. It abandons the wait, not the work: a `define` that was interrupted
238
+ may still register its transform (`Trellis.status` tells you whether it
239
+ did). Every call ends within 30 seconds on its own, and
240
+ the server stops what it started at that deadline, so an abandoned call
241
+ holds a pooled connection for at most that long. A call that runs out of
242
+ time raises `Trellis::TimeoutError`. A call stuck on a lock doesn't hold up the others:
243
+ calls from different threads run in parallel, in no particular order.
244
+ `shutdown` doesn't wait for a call stuck on a lock either: it cancels the
245
+ calls in flight, and they raise `Trellis::ValidationError`.
246
+ - **A handle doesn't survive `fork`.** Connect after forking: Puma's
247
+ `before_worker_boot` (`on_worker_boot` before Puma 7), Passenger's
248
+ `starting_worker_process`. A forked child's
249
+ calls on a handle it inherited raise `Trellis::ForkedHandleError` rather
250
+ than hang. `Trellis.shutdown` in a child that hasn't connected does
251
+ nothing. If the parent connects too (to migrate, say), shut it down before
252
+ forking. A child forked while the parent's handle was running (connected,
253
+ connecting on another thread, or not yet fully shut down) may have
254
+ inherited a lock one of that handle's threads held, which nothing in the
255
+ child can release, so its `connect` raises `Trellis::ForkedHandleError`
256
+ too, rather than risk hanging on it. Forks whose children
257
+ never connect, and `system`/`spawn`, are unaffected. [Forking
258
+ servers](#forking-servers) has the hooks for Puma (with `preload_app!`
259
+ and `fork_worker`), Unicorn and Passenger.
260
+
261
+ ```ruby
262
+ # config/puma.rb, in a Rails app (see "In a Rails app" below)
263
+ before_fork { Trellis::Instance.shutdown_all }
264
+ before_worker_boot { Trellis::Railtie.connect }
265
+ ```
266
+ - **Errors** are raised as a subclass of `Trellis::Error` per error code
267
+ (`ParseError`, `ValidationError`, `ConnectivityError`, `ConflictError`,
268
+ `NotFoundError`, `InternalError`, `TimeoutError`), each with `#code` (a
269
+ symbol) and `#message`. A code newer than the binding arrives as
270
+ `UnknownError`, naming the code in its message. `ForkedHandleError` is a
271
+ `ValidationError`. Raising is Ruby's idiom, so where the Elixir binding
272
+ has `{:ok, value}` / `{:error, error}` and bang variants, every Ruby
273
+ method returns its value or raises.
274
+ - **Words are symbols** (statuses, quarantine states, cardinalities,
275
+ `Applied` kinds, `self_check` modes, states and outcomes, and divergence kinds), each from a
276
+ closed set: none is ever made from a string the database returned.
277
+ - **Times are `Time`s** in UTC, at microsecond precision. They cross the
278
+ extension as epoch microseconds.
279
+ - **A quarantine target is an address string**, `"transform"` or
280
+ `"transform.column"`, exactly as `Trellis.quarantined` reports it.
281
+ - **Cursors and tokens are opaque strings**: `sample_quarantined`'s
282
+ `next_cursor` and `watermark_token`'s token. Pass back what the previous call
283
+ returned.
284
+ - **Durations are milliseconds**, as `timeout_ms:`, matching the Elixir
285
+ binding.
286
+ - **Results are `Data` values** (`Trellis::DefinitionSummary`,
287
+ `Trellis::QuarantineEntry`, ...), so they compare by value and
288
+ pattern-match: `case Trellis.apply(stmt) in { kind: :resumed, columns: }`.
289
+
290
+ ## In a Rails app
291
+
292
+ `require "trellis/pg"` after Rails (which `Bundler.require` in
293
+ `config/application.rb` does) loads `Trellis::Railtie`. Without Rails, the
294
+ gem loads no Rails code and requires no gem at all; `activerecord` and
295
+ `railties` are only ever the app's own dependencies.
296
+
297
+ ### Configuration and boot
298
+
299
+ The Rails integration reads `Trellis.connect`'s options from one place,
300
+ `config.trellis.connect`:
301
+
302
+ ```ruby
303
+ # config/application.rb (or config/environments/*.rb)
304
+ config.trellis.connect = {
305
+ url: ENV.fetch("DATABASE_URL"),
306
+ # The one dedicated worker process sets TRELLIS_WORKER=1.
307
+ staging: ENV["TRELLIS_WORKER"] == "1",
308
+ drain_threads: ENV["TRELLIS_WORKER"] == "1" ? 2 : 0
309
+ }
310
+ ```
311
+
312
+ With it set, the app's default instance connects as the app boots (an
313
+ `after_initialize` hook), and the `at_exit` hook shuts it down. Left `nil`,
314
+ the default, the Railtie connects no default instance.
315
+
316
+ An app with more instances names them in `config.trellis.instances`, each
317
+ with `Trellis::Instance.connect`'s options. They connect with the default
318
+ instance (which is optional when they are set), and
319
+ `Trellis::Railtie.named_instance(:reports)` returns one:
320
+
321
+ ```ruby
322
+ config.trellis.instances = {
323
+ reports: { url: ENV.fetch("REPORTS_DATABASE_URL"), schema: "reports" }
324
+ }
325
+ ```
326
+
327
+ If any instance fails to connect, the ones that did are shut down again,
328
+ and `Trellis::Railtie.connect` raises while one of them is already
329
+ connected, as `Trellis.connect` does.
330
+ With neither set, the Railtie does nothing. Two exceptions:
331
+
332
+ - **An app booted by a rake task gets no boot handle.** `rails db:create`
333
+ may run before the database exists, and `rails db:migrate` must not start
334
+ a worker's background work, so the process stays disconnected, as Ecto
335
+ migrates without starting the app. The migration helpers connect a handle
336
+ of their own. A rake task of your own that calls Trellis calls
337
+ `Trellis::Railtie.connect` first. It's the running task that counts:
338
+ `rails test` boots the app outside any task and connects, but tests run
339
+ by a rake task (`bin/rails db:test:prepare test`, or plain `rake`) don't,
340
+ so a test helper that needs the handles can call
341
+ `Trellis::Railtie.connect if Trellis::Instance.connected.empty?`.
342
+ - **`config.trellis.connect_on_boot = false`** skips the boot connect, for a
343
+ server that forks without a hook to shut the handle down first (a
344
+ preloading Passenger, say). Connect in each child with
345
+ `Trellis::Railtie.connect`.
346
+
347
+ ### Forking servers
348
+
349
+ A handle doesn't survive `fork`. A server that loads the app
350
+ before it forks has to shut the boot handle down in the parent and connect
351
+ one in each child. Puma preloads by default whenever it runs more than one
352
+ worker:
353
+
354
+ ```ruby
355
+ # config/puma.rb
356
+ before_fork { Trellis::Instance.shutdown_all } # the parent's boot handles
357
+ before_worker_boot { Trellis::Railtie.connect } # on_worker_boot before Puma 7
358
+
359
+ # Only with fork_worker, where worker 0 forks the others (at boot, on a
360
+ # respawn and on a refork): shut its handle down around each fork. The
361
+ # master, already disconnected by before_fork, stays that way.
362
+ trellis_was_connected = false
363
+ before_worker_fork { trellis_was_connected = !Trellis::Instance.connected.empty?; Trellis::Instance.shutdown_all }
364
+ after_worker_fork { Trellis::Railtie.connect if trellis_was_connected }
365
+ ```
366
+
367
+ (`before_refork`/`after_refork` aren't enough for `fork_worker`: worker 0
368
+ forks the first workers at boot, and respawns a dead one, without running
369
+ them.) Without `preload_app!`, each worker boots the app itself and the boot
370
+ connect is all it needs: leave these hooks out, since `Trellis` isn't loaded
371
+ yet when they run.
372
+
373
+ Unicorn's `before_fork` and `after_fork`, and Passenger's
374
+ `starting_worker_process` (with `connect_on_boot = false`), work the same
375
+ way. If the hooks are missing, the child's calls raise
376
+ `Trellis::ForkedHandleError` rather than hang. (Puma logs a hook's
377
+ exception as a warning and boots the worker anyway, so a failed connect
378
+ shows up as that warning and then as errors from the worker's calls.)
379
+
380
+ ### Migrations
381
+
382
+ `Trellis::Migration` makes Trellis statements read like the migration's
383
+ own:
384
+
385
+ ```ruby
386
+ class DefineOrderTotals < ActiveRecord::Migration[8.1]
387
+ include Trellis::Migration
388
+
389
+ # Required: the helpers raise before applying anything if the migration
390
+ # has a transaction open.
391
+ disable_ddl_transaction!
392
+
393
+ def up
394
+ define "TRANSFORM order_totals FROM orders SELECT price + tax AS total"
395
+ end
396
+
397
+ def down
398
+ apply "PAUSE TRANSFORM order_totals"
399
+ apply "DROP TRANSFORM order_totals"
400
+ end
401
+ end
402
+ ```
403
+
404
+ - `define`, `apply` and `status` are `Trellis.define`, `Trellis.apply` and
405
+ `Trellis.status`, run as the migration reaches them, so a transform can
406
+ read a table created earlier in the same migration.
407
+ - **No transaction.** Trellis never joins the migration's transaction: each
408
+ call commits on Trellis's own connections. `define` and `apply` raise
409
+ `ActiveRecord::MigrationError` before applying anything if the
410
+ migration's connection has a transaction open, so a migration missing
411
+ `disable_ddl_transaction!` rolls back with nothing applied on either
412
+ side.
413
+ - **`up` and `down`, not `change`.** ActiveRecord can't reverse a Trellis
414
+ statement: undoing a define is a `PAUSE TRANSFORM` and a `DROP
415
+ TRANSFORM` of a target only the statement names. A migration that
416
+ includes `Trellis::Migration` and defines a public `change` raises
417
+ before anything runs.
418
+ - **Which handle.** The helpers run on the default instance. If the process
419
+ is connected, they use its handle. Otherwise (`rails db:migrate` is never connected), each call
420
+ connects one from `config.trellis.connect`, always with `staging: false`
421
+ and `drain_threads: 0`, runs `Trellis.migrate`, makes its call and shuts
422
+ the handle down.
423
+ - **Once per database.** A define isn't idempotent, and the helpers add no
424
+ guard: `schema_migrations` already records which migrations have run.
425
+ A migration that has to tolerate a transform defined some other way can
426
+ guard its define itself, if you choose to:
427
+
428
+ ```ruby
429
+ def up
430
+ define "TRANSFORM order_totals FROM orders SELECT price + tax AS total" if status("order_totals").nil?
431
+ end
432
+ ```
433
+
434
+ The guard checks the target's name, not its definition. A changed
435
+ statement for a target that exists is an `ALTER TRANSFORM`, in a
436
+ migration of its own.
437
+ - **Schema dumps carry the definitions** (see "Schema dumps" below), when the
438
+ app uses `structure.sql`.
439
+
440
+ `rails trellis:migrate` creates or upgrades Trellis's own tables the same
441
+ way. The helpers run it whenever a migration defines something; run it in
442
+ the deploy step as well (`bin/rails trellis:migrate db:migrate`), so a
443
+ deploy that upgrades the gem without a new define still upgrades the
444
+ tables before the worker restarts.
445
+
446
+ `Trellis::Railtie.with_handle { ... }` is what `trellis:migrate` and the
447
+ helpers use, for a script or rake task of your own that needs Trellis the
448
+ way a migration does. It yields the instance, and `with_handle(:reports)`
449
+ runs on an instance of `config.trellis.instances`, which is how a migration
450
+ reaches one (`Trellis::Railtie.with_handle(:reports) { |reports|
451
+ reports.define "TRANSFORM ..." }`). `trellis:migrate` migrates the default
452
+ instance and every instance of `config.trellis.instances`. Neither it nor
453
+ `trellis:migrate` needs ActiveRecord.
454
+ Outside Rails, `require "trellis/migration"` and `Trellis.connect` before
455
+ migrating.
456
+
457
+ ## Fixtures and bulk loads in tests
458
+
459
+ Rails fixtures load with `disable_referential_integrity`, which disables
460
+ every trigger on the loaded tables, Trellis's capture triggers included, and
461
+ ends with `ENABLE TRIGGER ALL`. The rows it wrote never reached a target, so
462
+ Trellis pauses every definition that reads the table (`status.capture_failure`
463
+ has `kind: :disruption`) instead of letting a test read silently wrong data,
464
+ and waits for a `RESUME`. `Trellis::TestSupport` does that `RESUME` for a
465
+ suite, then waits for the rebuilt targets to catch up:
466
+
467
+ ```ruby
468
+ # test/test_helper.rb
469
+ require "trellis/test_support"
470
+
471
+ class ActiveSupport::TestCase
472
+ include Trellis::TestSupport
473
+ fixtures :all
474
+ # Rails loads the fixtures in its own setup, before this block runs.
475
+ setup { resume_capture_disruptions }
476
+ end
477
+ ```
478
+
479
+ `Trellis::TestSupport.resume_capture_disruptions(instance = Trellis.default_instance, timeout_ms: 30_000)`
480
+ does the same without the mixin, after any bulk load. It first pauses the
481
+ readers of any disrupted table itself (`Trellis.detect_capture_disruptions`),
482
+ so it doesn't wait for the staging worker's next pass; resumes only the
483
+ definitions paused with `kind: :disruption`; waits until each is `:live` and
484
+ every write committed so far has reached its targets; and returns the targets
485
+ it resumed (`[]` when nothing was disrupted, which costs one catalog read per
486
+ captured table). A definition an operator paused, or one paused by a schema
487
+ change or another failure, stays paused. A `RESUME` the engine refuses (a
488
+ table that is gone, a table define would now refuse) leaves that definition
489
+ paused; the rest are still resumed, and the call then raises
490
+ `Trellis::TestSupport::ResumeRefused` with `refused`, a hash of target to
491
+ reason. The instance needs its staging worker and a drain worker (`staging:
492
+ true, drain_threads: 1`), because the resume rebuilds through them.
493
+
494
+ ## Schema dumps
495
+
496
+ A Rails app that uses Trellis dumps its schema as `structure.sql`
497
+ (`config.active_record.schema_format = :sql`), and the dump carries Trellis's
498
+ definitions, the way it carries `schema_migrations`. `schema.rb` can't carry
499
+ another schema's tables or triggers, so with `schema_format = :ruby` the dump
500
+ (`db:migrate`'s and `db:schema:dump`) raises `Trellis::ValidationError` and
501
+ writes nothing.
502
+
503
+ ```ruby
504
+ # config/application.rb
505
+ config.active_record.schema_format = :sql
506
+ config.trellis.connect = { url: ENV.fetch("DATABASE_URL") }
507
+ ```
508
+
509
+ Nothing else is set up. After `pg_dump`, `db:migrate`'s dump and
510
+ `db:schema:dump` append `Trellis::Instance#dump_definitions_sql` for
511
+ `config.trellis.connect` and each of `config.trellis.instances`: Trellis's
512
+ migration history, the instance marker, the transform
513
+ definitions and the seed rows capture needs. The Railtie also adds each
514
+ instance's `schema` and `target_schema` to the `--schema` flags Rails passes
515
+ `pg_dump` (nothing is added when Rails dumps every schema), so the Trellis
516
+ tables, the target tables and the capture triggers are in the file.
517
+ `db:schema:load`, `db:prepare`, `db:setup` and the test database
518
+ `maintain_test_schema!` loads then build a database with every definition in
519
+ place. Each loads as `waiting_to_backfill`, and the worker builds it from the
520
+ source rows; a test database has none, so that is immediate. Runtime state
521
+ (ledger, ring, build chunks, quarantine, claims, statuses) is never dumped.
522
+
523
+ - The hook applies to one database config, `config.trellis.dump_database`
524
+ (default `"primary"`): the one `config.trellis.connect` and
525
+ `config.trellis.instances` point at. Other databases dump as Rails dumps
526
+ them. The dump raises `Trellis::ValidationError`, writing nothing, when
527
+ `config.trellis.connect` or an instance points at a different database than
528
+ that config. They are compared by host, port and database name: the Rails
529
+ config's as libpq resolves them (`PGHOST`, `PGPORT` and `PGDATABASE`
530
+ included), Trellis's `url` from the url alone (port 5432, database the
531
+ user's name). A config with no host uses libpq's local socket, which
532
+ matches a local Trellis host (`localhost`, `127.0.0.1`, `::1` or a socket
533
+ directory). Other host spellings are not equated, so `localhost` is not
534
+ `127.0.0.1`. `db:prepare` in development also dumps the test database
535
+ after migrating it; that dump is checked against development's config of
536
+ the same name, which `config.trellis.connect` points at.
537
+ - `pg_dump` and `dump_definitions_sql` read separate snapshots. The hook reads
538
+ the definitions before and after the `pg_dump` and redoes the dump when they
539
+ differ (three attempts, then `Trellis::ConflictError`), so a define or drop
540
+ that races a dump can't leave tables and definitions disagreeing.
541
+ - A definition that was paused or quarantined where the dump was taken loads
542
+ as `waiting_to_backfill`: the pause is runtime state.
543
+ - `pg_dump` 17 writes `CREATE SCHEMA public`, which a database made by
544
+ `db:create` already has; the hook writes `CREATE SCHEMA IF NOT EXISTS
545
+ public` instead.
546
+ - The dump connects through `Trellis::Railtie.with_handle`, so it migrates
547
+ Trellis's tables first if the database lacks them, and starts no worker.
548
+ - It hooks `ActiveRecord::Tasks::DatabaseTasks.dump_schema` and
549
+ `PostgreSQLDatabaseTasks#structure_dump`, which Rails marks `:nodoc:` and
550
+ offers no extension point for. A Rails upgrade that renames them fails the
551
+ test suite's round trip (`test/schema_dump_test.rb`).
552
+
553
+ ## Layout
554
+
555
+ - `lib/`: the `Trellis` module and `Trellis::Instance`, its error classes and its `Data` values,
556
+ `Trellis::Metrics`,
557
+ the Rails integration (`trellis/railtie.rb`, `trellis/migration.rb`),
558
+ loaded only in an app that has Rails, and the test helpers
559
+ (`trellis/test_support.rb`), loaded only when required.
560
+ - `ext/trellis_ruby/`: the extension crate, a member of the repository's
561
+ Cargo workspace, and the `extconf.rb` that builds it with rb-sys.
562
+ Plain-data conversion and error codes come from `clients/embed`
563
+ (`trellis-embed`), shared with the Elixir binding.
564
+ - `trellis-pg.gemspec`: the source gem. The `Rakefile` derives the platform
565
+ gems from it.
566
+
567
+ The crate's code sits behind its `ruby` feature: rb-sys needs a Ruby install
568
+ and libclang to build, and the Rust-only CI job and `verify` have neither, so
569
+ without the feature (`cargo build --workspace`) it compiles to an empty
570
+ library. The root manifest leaves it out of `default-members` too.
571
+
572
+ ## Development
573
+
574
+ Ruby is pinned in `.tool-versions`. rb-sys generates its bindings with
575
+ libclang, so building needs it (`libclang-dev` on Debian/Ubuntu,
576
+ `clang-devel` on Fedora). From this directory:
577
+
578
+ ```sh
579
+ cargo build -p testkit --bin trellis-testkit # the suite's throwaway Postgres
580
+ bundle install
581
+ bundle exec rake test
582
+ ```
583
+
584
+ `rake compile:dev` (which `rake test` runs first) builds the extension the
585
+ way installing the source gem does, through `ext/trellis_ruby/extconf.rb`,
586
+ but with Cargo's dev profile and into the workspace's `target/`, and copies
587
+ it to `lib/trellis/`. `rake compile` builds the release profile. The suite spawns
588
+ `trellis-testkit` (from `PATH`, else the workspace's debug build) for a
589
+ Postgres cluster that is torn down when the run exits. It needs the Postgres
590
+ server binaries on `PATH`, like the Rust tests.
591
+
592
+ The tests use Minitest: it ships with Ruby, and its plain `assert_*` style
593
+ reads like the Elixir binding's ExUnit suite, which this one mirrors.
594
+ `test/parity_test.rb` runs `clients/parity/cases.json`, the fixture the
595
+ Elixir suite runs too, so the two bindings can't drift apart unnoticed (see
596
+ `clients/parity/README.md`).
597
+
598
+ ## Releasing
599
+
600
+ `trellis-pg` is released with the rest of Trellis, from one `v<version>` tag
601
+ ([docs/releasing.md](../../docs/releasing.md)); `Trellis::VERSION`
602
+ (`lib/trellis/version.rb`) is the release's. `.github/workflows/release.yml`
603
+ builds the six platform gems and the source gem, installs and loads each one
604
+ (every Linux gem on each Ruby it supports), and attaches them to the tag's
605
+ GitHub release. They are then pushed to RubyGems by hand, byte for byte, with
606
+ `.github/scripts/publish-packages.sh`, platform gems first.
607
+
608
+ A platform gem builds in rb-sys's cross-compiling container for its
609
+ platform, through `rb-sys-dock`, and takes one build of the extension per
610
+ Ruby minor version. `.github/scripts/build-ruby-gem.sh <platform> <out-dir>`
611
+ does that, locally too (it needs Docker or Podman; see the script). Adding a
612
+ Ruby version means adding it to that script's `RUBY_VERSIONS`, the
613
+ workflow's check matrix, the table above and, for a new oldest version,
614
+ `required_ruby_version` in the gemspec.
615
+
616
+ The source gem's extension crate has to build outside this repository, so
617
+ `.github/scripts/ruby-prepare-source-gem.sh` rewrites its path dependencies
618
+ as git dependencies on the released commit and gives it a lockfile, before
619
+ `gem build`. A `gem build` of an unprepared checkout makes a gem that only
620
+ installs from inside the checkout.
Binary file
Binary file
Binary file