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 +7 -0
- data/README.md +567 -0
- data/lib/trellis/3.3/trellis_ruby.bundle +0 -0
- data/lib/trellis/3.4/trellis_ruby.bundle +0 -0
- data/lib/trellis/4.0/trellis_ruby.bundle +0 -0
- data/lib/trellis/error.rb +103 -0
- data/lib/trellis/instance.rb +540 -0
- data/lib/trellis/migration.rb +186 -0
- data/lib/trellis/pg.rb +140 -0
- data/lib/trellis/railtie.rb +293 -0
- data/lib/trellis/schema_dump.rb +278 -0
- data/lib/trellis/test_support.rb +161 -0
- data/lib/trellis/values.rb +386 -0
- data/lib/trellis/version.rb +7 -0
- metadata +66 -0
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
|