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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trellis
4
+ # An error from Trellis: the engine's message and a stable #code, and
5
+ # nothing else (ADR-0010 decision 4). Every error the binding raises is one
6
+ # of the subclasses below, so `rescue Trellis::Error` catches them all, and
7
+ # `rescue Trellis::NotFoundError` just one kind.
8
+ #
9
+ # The engine's error codes are an open set, so a code this version of the
10
+ # binding doesn't know arrives as an UnknownError, with the engine's code
11
+ # named in the message, rather than crashing.
12
+ class Error < StandardError
13
+ # The code this class stands for, as a symbol; see #code.
14
+ CODE = nil
15
+
16
+ # One of :parse, :validation, :connectivity, :conflict, :not_found,
17
+ # :internal, :timeout, or :unknown for a code newer than this binding.
18
+ def code
19
+ self.class::CODE
20
+ end
21
+
22
+ # The exception for the native extension's `(code, message)` pair.
23
+ # Not part of the public API.
24
+ def self.from_native(code, message)
25
+ klass = BY_CODE[code]
26
+ return klass.new(message) if klass
27
+
28
+ UnknownError.new("(error code #{code.inspect}) #{message}")
29
+ end
30
+ end
31
+
32
+ # The statement text isn't valid grammar.
33
+ class ParseError < Error
34
+ CODE = :parse
35
+ end
36
+
37
+ # Well-formed but rejected: a bad option, an unknown column, a type
38
+ # mismatch, a call on a handle that has been shut down.
39
+ class ValidationError < Error
40
+ CODE = :validation
41
+ end
42
+
43
+ # The database couldn't be reached, or the connection dropped.
44
+ class ConnectivityError < Error
45
+ CODE = :connectivity
46
+ end
47
+
48
+ # Clashes with something that already exists.
49
+ class ConflictError < Error
50
+ CODE = :conflict
51
+ end
52
+
53
+ # Names something that doesn't exist.
54
+ class NotFoundError < Error
55
+ CODE = :not_found
56
+ end
57
+
58
+ # A Trellis bug or an unexpected database failure.
59
+ class InternalError < Error
60
+ CODE = :internal
61
+ end
62
+
63
+ # A bounded wait ran out of time, such as Trellis.await_converged's
64
+ # timeout_ms, or Trellis.release_key's wait for the pages in flight on its
65
+ # table, or a call ran out of its 30-second deadline (the server stopped
66
+ # what the call started and rolled it back). Expected, not a bug: retry it.
67
+ class TimeoutError < Error
68
+ CODE = :timeout
69
+ end
70
+
71
+ # An error code newer than this binding. The message names the code.
72
+ class UnknownError < Error
73
+ CODE = :unknown
74
+ end
75
+
76
+ # A call on a handle this process inherited through `fork` rather than
77
+ # connected itself. Rust threads don't cross `fork`, so the handle has
78
+ # nothing left to answer it: every call raises this instead of hanging.
79
+ # Call Trellis.connect in the forked process (Puma's before_worker_boot,
80
+ # Passenger's starting_worker_process: "Forking servers" in the README).
81
+ # A ValidationError, like any other call on a handle that can't serve it.
82
+ #
83
+ # Also Trellis.connect's error in a process forked while its parent had a
84
+ # handle running, which may have inherited a lock that handle's threads
85
+ # held (issue #600): shut down before forking (Puma's before_fork).
86
+ class ForkedHandleError < ValidationError
87
+ end
88
+
89
+ class Error
90
+ # The engine's codes, written out rather than derived, so adding one is a
91
+ # deliberate change here. test/error_test.rb fails when the engine
92
+ # reports a code this map lacks.
93
+ BY_CODE = {
94
+ "parse" => ParseError,
95
+ "validation" => ValidationError,
96
+ "connectivity" => ConnectivityError,
97
+ "conflict" => ConflictError,
98
+ "not_found" => NotFoundError,
99
+ "internal" => InternalError,
100
+ "timeout" => TimeoutError
101
+ }.freeze
102
+ end
103
+ end
@@ -0,0 +1,540 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "error"
4
+ require_relative "values"
5
+
6
+ module Trellis
7
+ # The largest `limit:` and `timeout_ms:` the engine takes (an i64 and a
8
+ # u64 millisecond count); anything larger is refused as a ValidationError
9
+ # rather than failing to convert in the extension.
10
+ MAX_LIMIT = (2**63) - 1
11
+ MAX_TIMEOUT_MS = (2**64) - 1
12
+ SELF_CHECK_MODES = %i[standard strict].freeze
13
+
14
+ # The argument checks every call makes before it reaches the extension.
15
+ module Checks # :nodoc:
16
+ private
17
+
18
+ def string_option!(name, value)
19
+ return if value.is_a?(String)
20
+
21
+ raise ValidationError, "#{name} must be a String, got: #{value.inspect}"
22
+ end
23
+
24
+ def string!(what, value)
25
+ raise ValidationError, "#{what} must be a String, got: #{value.inspect}" unless value.is_a?(String)
26
+ end
27
+
28
+ def limit!(limit)
29
+ return if limit.is_a?(Integer) && limit.between?(1, MAX_LIMIT)
30
+
31
+ raise ValidationError, "limit must be a positive Integer, got: #{limit.inspect}"
32
+ end
33
+
34
+ def timeout_ms!(timeout_ms)
35
+ return if timeout_ms.is_a?(Integer) && timeout_ms.between?(0, MAX_TIMEOUT_MS)
36
+
37
+ raise ValidationError, "timeout_ms must be a non-negative Integer, got: #{timeout_ms.inspect}"
38
+ end
39
+
40
+ def cursor!(cursor, what)
41
+ return if cursor.nil? || cursor.is_a?(String)
42
+
43
+ raise ValidationError, "after must be #{what}, got: #{cursor.inspect}"
44
+ end
45
+ end
46
+
47
+ # One Trellis instance: a catalog schema in a database, and the native
48
+ # handle that runs it from this process. A process holds as many as it has
49
+ # catalog schemas (one per database, or several in one database), each with
50
+ # its own connections and threads and no shared budget
51
+ # (docs/decisions/0010-embeddable-clients.md, decision 3).
52
+ #
53
+ # analytics = Trellis::Instance.connect(url: "host=localhost dbname=app", schema: "analytics")
54
+ # billing = Trellis::Instance.connect(url: "host=localhost dbname=app", schema: "billing")
55
+ # analytics.define("TRANSFORM widget_prices FROM widgets SELECT price AS price")
56
+ # billing.shutdown # analytics keeps running
57
+ #
58
+ # Every call the `Trellis` module makes is a method here: the module
59
+ # delegates to its default instance (see Trellis.connect). The surface
60
+ # mirrors the Rust crate's BlockingTrellis:
61
+ #
62
+ # - Lifecycle: connect, connected?, migrate, config, shutdown.
63
+ # - Every statement form: apply runs one statement of Trellis's grammar
64
+ # (TRANSFORM, PAUSE TRANSFORM, RESUME TRANSFORM, DROP TRANSFORM, ALTER
65
+ # TRANSFORM) and reports what it did
66
+ # as an Applied. define is the one guarded convenience: an apply that
67
+ # refuses anything but a TRANSFORM statement before applying it.
68
+ # - Reads: status, definitions, dump_definitions_sql.
69
+ # - Quarantine: quarantined, quarantine_status, sample_quarantined,
70
+ # poisoned_since, release_key. Pausing and resuming are statements:
71
+ # instance.apply("RESUME TRANSFORM order_totals.total").
72
+ # - Operations: request_backfill, detect_capture_disruptions, has_live_drain_workers?,
73
+ # has_live_staging_worker?, the read-your-writes pair watermark_token and
74
+ # await_converged, and the audit self_check, a background job that
75
+ # self_check_job reads back.
76
+ #
77
+ # Every call blocks on the database with the GVL released, so other threads
78
+ # run meanwhile, and Thread#kill, Thread#raise, Timeout.timeout and Ctrl-C
79
+ # interrupt it, leaving no thread behind. An interrupt abandons the wait, not
80
+ # the work: the call itself ends within its 30-second deadline, where the
81
+ # server stops it. Every failure raises a Trellis::Error subclass, a call that
82
+ # runs out of time a Trellis::TimeoutError.
83
+ #
84
+ # Conventions: times are Times in UTC, at microsecond precision; a
85
+ # transform is named by its target table, bare ("order_totals") or qualified as
86
+ # #define returns it ("public.order_totals"), wherever a call takes one (#status,
87
+ # #quarantine_status, #sample_quarantined, #release_key, #self_check and the
88
+ # PAUSE, RESUME, DROP and ALTER statements); a quarantine target is an
89
+ # address string, a transform's name or "transform.column"
90
+ # ("order_totals.total", always the bare transform); the
91
+ # cursor sample_quarantined returns, and watermark_token's token, are opaque
92
+ # strings to pass back unchanged; and every symbol in a result comes from a
93
+ # closed set, never from a string the database returned.
94
+ #
95
+ # An instance doesn't survive `fork`. Shut every instance down before
96
+ # forking (Instance.shutdown_all; Puma's before_fork) and connect after
97
+ # (Puma's before_worker_boot, Passenger's starting_worker_process). A
98
+ # forked child's calls on an instance it inherited raise
99
+ # Trellis::ForkedHandleError, and its shutdown leaves the parent's handle
100
+ # alone and does nothing. So does Instance.connect in a child forked while
101
+ # any instance of its parent was running (issue #600).
102
+ class Instance
103
+ extend Checks
104
+ include Checks
105
+
106
+ @instances = []
107
+ @registry = Mutex.new
108
+ @at_exit_installed = false
109
+
110
+ class << self
111
+ # Connects a new instance and returns it. Raises ForkedHandleError in
112
+ # a process forked while its parent had an instance running
113
+ # (connected, connecting, or not yet fully shut down): it may have
114
+ # inherited a lock one of that instance's threads held (issue #600).
115
+ #
116
+ # - url: where the database is, as a libpq connection string
117
+ # ("host=... dbname=...") or URL. Nothing is read from the environment.
118
+ # - schema: the schema Trellis keeps this instance's own tables in. Not
119
+ # "public", not target_schema, and not another instance's schema or
120
+ # target_schema; migrate refuses those.
121
+ # - target_schema: the schema a bare target table name is created in.
122
+ # - staging: whether this process runs the instance's staging worker,
123
+ # which installs change capture on the source tables and starts each
124
+ # new transform's backfill. Exactly one process in a fleet should,
125
+ # per instance.
126
+ # - drain_threads: how many threads apply staged changes to the targets.
127
+ # Some process must run at least one per instance, or no definition
128
+ # reaches :live.
129
+ # - worker_threads: the worker threads of each Rust runtime this
130
+ # instance owns (its calls', and its background client's), invisible
131
+ # to Ruby's own thread sizing, so kept small and explicit. Each
132
+ # instance has its own; nothing is shared between instances.
133
+ #
134
+ # The defaults (staging: false, drain_threads: 0) run nothing in the
135
+ # background, which is what a migration step or a console wants. The
136
+ # staging worker reads Trellis's tables as it starts, so a staging: true
137
+ # connect to an unmigrated schema fails: migrate first.
138
+ def connect(url:, schema: "trellis", target_schema: "public", staging: false,
139
+ drain_threads: 0, worker_threads: 2)
140
+ string_option!(:url, url)
141
+ string_option!(:schema, schema)
142
+ string_option!(:target_schema, target_schema)
143
+ unless [true, false].include?(staging)
144
+ raise ValidationError, "staging must be true or false, got: #{staging.inspect}"
145
+ end
146
+ unless drain_threads.is_a?(Integer) && drain_threads >= 0
147
+ raise ValidationError,
148
+ "drain_threads must be a non-negative Integer, got: #{drain_threads.inspect}"
149
+ end
150
+ unless worker_threads.is_a?(Integer) && worker_threads >= 1
151
+ raise ValidationError,
152
+ "worker_threads must be a positive Integer, got: #{worker_threads.inspect}"
153
+ end
154
+
155
+ handle = Native.connect(url, schema, target_schema, staging, drain_threads, worker_threads)
156
+ instance = new(handle, schema)
157
+ register(instance)
158
+ instance
159
+ end
160
+
161
+ # The instances this process holds, the default one included: every
162
+ # one it connected and hasn't shut down, minus any a forked child only
163
+ # inherited.
164
+ def connected
165
+ snapshot.select(&:connected?)
166
+ end
167
+
168
+ # Shuts down every instance this process holds, the `Trellis` module's
169
+ # default instance included, and waits for each. Does nothing for an
170
+ # instance a forked child only inherited. Every instance is attempted;
171
+ # if some fail, the first error is raised once the rest are done and
172
+ # the others are warned about. What a forking server calls before it
173
+ # forks (Puma's before_fork), and the at_exit hook's backstop.
174
+ def shutdown_all
175
+ errors = snapshot.filter_map do |instance|
176
+ instance.shutdown
177
+ nil
178
+ rescue Error => e
179
+ e
180
+ end
181
+ return nil if errors.empty?
182
+
183
+ errors.drop(1).each { |e| warn "trellis: shutting down an instance failed: #{e.class}: #{e.message}" }
184
+ raise errors.first
185
+ end
186
+
187
+ private
188
+
189
+ # Removes a shut-down instance from the registry.
190
+ def deregister(instance)
191
+ @registry.synchronize { @instances.delete(instance) }
192
+ end
193
+
194
+ def register(instance)
195
+ @registry.synchronize do
196
+ @instances << instance
197
+ install_at_exit
198
+ end
199
+ end
200
+
201
+ def snapshot
202
+ @registry.synchronize { @instances.dup }
203
+ end
204
+
205
+ def install_at_exit
206
+ return if @at_exit_installed
207
+
208
+ @at_exit_installed = true
209
+ at_exit { shutdown_all_at_exit }
210
+ end
211
+
212
+ # The backstop for a process that exits without shutting its instances
213
+ # down. A forked child that inherited them leaves them alone (see
214
+ # #shutdown).
215
+ def shutdown_all_at_exit
216
+ shutdown_all
217
+ rescue Error => e
218
+ warn "trellis: shutting down at exit failed: #{e.class}: #{e.message}"
219
+ end
220
+ end
221
+
222
+ def initialize(handle, schema)
223
+ @handle = handle
224
+ @schema = schema
225
+ @lock = Mutex.new
226
+ end
227
+ private_class_method :new
228
+
229
+ # Whether this instance holds a live handle. False after shutdown, and
230
+ # in a forked child that hasn't connected its own.
231
+ def connected?
232
+ handle = @handle
233
+ !handle.nil? && handle.owner_pid == Process.pid
234
+ end
235
+
236
+ # Creates or upgrades Trellis's own tables in the configured schema. Safe
237
+ # to run on every deploy.
238
+ def migrate
239
+ handle.migrate
240
+ nil
241
+ end
242
+
243
+ # The Config this instance connected with.
244
+ def config
245
+ Config.new(**handle.config)
246
+ end
247
+
248
+ # Registers a transform from a `TRANSFORM` statement, creates its target
249
+ # table, and returns the target table (fully qualified, "schema.table"),
250
+ # at :waiting_to_backfill. Its backfill runs in the background: poll
251
+ # #status with the returned name for :live.
252
+ #
253
+ # Only a `TRANSFORM` statement: any other form (DROP, PAUSE, ...) raises
254
+ # ValidationError without being applied, and a statement that doesn't
255
+ # parse raises ParseError. #apply takes every form.
256
+ #
257
+ # Trellis runs this on its own connections, not in the caller's
258
+ # transaction: if an enclosing migration rolls back, the definition stays.
259
+ def define(statement)
260
+ string!("the statement", statement)
261
+ handle.define(statement)
262
+ end
263
+
264
+ # Runs one statement of Trellis's grammar, whatever its form, and returns
265
+ # an Applied saying what it did.
266
+ #
267
+ # trellis.apply("TRANSFORM order_totals FROM orders SELECT price + tax AS total")
268
+ # # => #<data Trellis::Applied kind=:transform_defined, target_table="public.order_totals", ...>
269
+ # trellis.apply("RESUME TRANSFORM order_totals.total").columns
270
+ # # => ["order_totals.total"]
271
+ #
272
+ # A statement that doesn't parse raises ParseError, and nothing is
273
+ # applied. Like #define, this runs on Trellis's own connections, not in
274
+ # the caller's transaction.
275
+ #
276
+ # An Applied of kind :unknown is a success like any other: the statement
277
+ # took effect, and only its outcome is newer than this binding can
278
+ # describe. Don't retry it; read the result back with #status or
279
+ # #definitions if you need it.
280
+ def apply(statement)
281
+ string!("the statement", statement)
282
+ Applied.from_native(handle.apply(statement))
283
+ end
284
+
285
+ # The Status of the definition writing target_table, or nil when none
286
+ # does. target_table is either the fully qualified name #define and #apply
287
+ # return ("public.order_totals") or the bare table name ("order_totals");
288
+ # a qualified name with another schema matches nothing.
289
+ def status(target_table)
290
+ string!("the target table", target_table)
291
+ hash = handle.status(target_table)
292
+ hash && Status.from_native(hash)
293
+ end
294
+
295
+ # Every registered transform definition, oldest first, as
296
+ # DefinitionSummary values.
297
+ def definitions
298
+ handle.definitions.map { |hash| DefinitionSummary.from_native(hash) }
299
+ end
300
+
301
+ # The SQL that carries this instance's definitions in a schema-only dump
302
+ # of its database: Trellis's migration history, instance marker,
303
+ # transform definitions, and the seed rows capture
304
+ # needs. Append it to the output of `pg_dump --schema-only` (with the
305
+ # instance's schema and target schema in scope) and a database loaded
306
+ # from the file has every definition, each at :waiting_to_backfill, as
307
+ # `define` leaves it; the worker builds them. Runtime state (the ledger,
308
+ # ring, chunks, quarantine, claims and statuses) is never in it.
309
+ #
310
+ # Read from one snapshot, but a different one from the pg_dump's: a
311
+ # define or drop that commits between the two leaves them disagreeing.
312
+ # The Rails hook (Trellis::SchemaDump) reads it on both sides of the
313
+ # pg_dump and retries when the two differ.
314
+ def dump_definitions_sql
315
+ handle.dump_definitions_sql
316
+ end
317
+
318
+ # Rebuilds every definition that reads source_table (a bare table name,
319
+ # resolved like one in a statement), and returns once that rebuild is
320
+ # queued.
321
+ #
322
+ # Each :live reader reports :backfilling (rebuilt by the call itself)
323
+ # until the re-read has re-derived every row the table still has and
324
+ # deleted any target row it no longer backs. A newly defined transform
325
+ # doesn't need this: its backfill is queued for it. Only a table Trellis
326
+ # already captures can be re-read; any other raises NotFoundError.
327
+ def request_backfill(source_table)
328
+ string!("the source table", source_table)
329
+ handle.request_backfill(source_table)
330
+ nil
331
+ end
332
+
333
+ # Releases one key `transform` holds in quarantine, once its cause is
334
+ # fixed. source_table ("public.orders" or "orders") and key are as
335
+ # sample_quarantined and poisoned_since report them.
336
+ #
337
+ # It stages a recompute of the key, which every transform reading the
338
+ # table applies from the key's current row, and discards the changes held
339
+ # for `transform` meanwhile. Another transform holding the same key keeps
340
+ # holding it. If the cause is still there, the key is poisoned again.
341
+ # Resuming the transform releases every key it holds.
342
+ #
343
+ # An unknown transform raises NotFoundError, and so does a key it doesn't
344
+ # hold, which changes nothing. The release first waits for the drain
345
+ # pages in flight on the table to commit; a wait past the lock timeout
346
+ # (30 seconds) raises TimeoutError and changes nothing: call it again.
347
+ #
348
+ # trellis.release_key("order_totals", "public.orders", "42")
349
+ def release_key(transform, source_table, key)
350
+ string!("the transform", transform)
351
+ string!("the source table", source_table)
352
+ string!("the key", key)
353
+ handle.release_key(transform, source_table, key)
354
+ nil
355
+ end
356
+
357
+ # Every source row the apply path poisoned (gave up on) after `since` (a
358
+ # Time), oldest first, as PoisonEntry values. Poll it with the last
359
+ # entry's poisoned_at so a whole-table failure doesn't sit unnoticed.
360
+ def poisoned_since(since)
361
+ raise ValidationError, "since must be a Time, got: #{since.inspect}" unless since.is_a?(::Time)
362
+
363
+ micros = EpochMicros.from_time(since) or
364
+ raise ValidationError, "since is out of range: #{since.inspect}"
365
+ handle.poisoned_since(micros).map { |hash| PoisonEntry.from_native(hash) }
366
+ end
367
+
368
+ # Every quarantined transform and paused column, across every transform,
369
+ # as QuarantineEntry values. Cheap enough for a dashboard or health
370
+ # check to poll.
371
+ def quarantined
372
+ handle.quarantined.map { |hash| QuarantineEntry.from_native(hash) }
373
+ end
374
+
375
+ # The QuarantineEntry of one target: a transform ("order_totals") or one
376
+ # of its columns ("order_totals.total"). A column that isn't paused is
377
+ # :live.
378
+ def quarantine_status(target)
379
+ string!("the target", target)
380
+ QuarantineEntry.from_native(handle.quarantine_status(target))
381
+ end
382
+
383
+ # One SamplePage of the rows quarantined under target, to diagnose a
384
+ # quarantine's cause: for a column ("order_totals.total"), the rows that
385
+ # failed evaluating it; for a whole transform ("order_totals"), the keys
386
+ # poisoned from its source table.
387
+ #
388
+ # - limit: the most rows to return.
389
+ # - after: the previous page's next_cursor; nil for the first page.
390
+ #
391
+ # page = trellis.sample_quarantined("order_totals.total", limit: 50)
392
+ # page = trellis.sample_quarantined("order_totals.total", limit: 50, after: page.next_cursor)
393
+ def sample_quarantined(target, limit: 100, after: nil)
394
+ string!("the target", target)
395
+ limit!(limit)
396
+ cursor!(after, "a cursor from a previous page")
397
+ SamplePage.from_native(handle.sample_quarantined(target, after, limit))
398
+ end
399
+
400
+ # Whether at least one drain worker is alive anywhere in the fleet. With
401
+ # none, nothing reaches a target table: poll this from a health check.
402
+ def has_live_drain_workers?
403
+ handle.has_live_drain_workers
404
+ end
405
+
406
+ # Whether the staging worker (which installs change capture and starts
407
+ # backfills) is alive anywhere in the fleet. The other half of the health check.
408
+ def has_live_staging_worker?
409
+ handle.has_live_staging_worker
410
+ end
411
+
412
+ # Pauses, now, the definitions that read a table whose Trellis capture
413
+ # was changed outside Trellis (a trigger disabled, dropped or recreated, a
414
+ # capture function replaced, the table gone), and returns the qualified
415
+ # names of those tables. The staging worker's reconcile pass does the same
416
+ # on its own cadence, with a CaptureFailure of kind :disruption on each
417
+ # paused definition's status; this is for a caller that has just done
418
+ # something that disrupts capture, like a fixture load, and needs the
419
+ # readers paused before it goes on. Installs, repairs and rebuilds
420
+ # nothing, and resuming is still the caller's (Trellis::TestSupport does
421
+ # it for a test suite). Safe beside a running staging worker.
422
+ def detect_capture_disruptions
423
+ handle.detect_capture_disruptions
424
+ end
425
+
426
+ # A read-your-writes token (an opaque String) covering every write
427
+ # committed before this call. Take it after a source-table write commits,
428
+ # then pass it to #await_converged to wait for that write to reach its
429
+ # targets.
430
+ def watermark_token
431
+ handle.watermark_token
432
+ end
433
+
434
+ # Waits until every change committed at or before `token` has reached its
435
+ # target tables, or timeout_ms passes (a TimeoutError; retry it). Returns
436
+ # nil.
437
+ #
438
+ # It waits for captured changes only: a transform that isn't :live yet
439
+ # may still be missing rows when this returns (see #status).
440
+ #
441
+ # Like every call, it returns within 30 seconds: a longer timeout_ms is
442
+ # cut to that, and a caller that wants to wait longer calls again. Calls
443
+ # from other threads don't wait behind it.
444
+ def await_converged(token, timeout_ms:)
445
+ string!("the token", token)
446
+ timeout_ms!(timeout_ms)
447
+ handle.await_converged(token, timeout_ms)
448
+ nil
449
+ end
450
+
451
+ # Starts a background check of target_table against a fresh recompute of
452
+ # its definition from the source tables, and returns a SelfCheckJob at
453
+ # once. The comparison is a drain worker's, a page of keys at a time
454
+ # (with no drain worker anywhere in the fleet the job stays :queued, as
455
+ # a define does); poll self_check_job with the job's id until it is
456
+ # finished, and its report is the verdict.
457
+ #
458
+ # - timeout_ms: how long a page of the job waits for the target to catch
459
+ # up, per wait. A :standard check waits up to twice per page, a
460
+ # :strict one once.
461
+ # - mode: :standard re-checks anything that differs after a fresh wait,
462
+ # so a change still in flight isn't reported; it is safe while the
463
+ # source is being written. :strict skips the re-check, and is only
464
+ # sound once writes to the audited tables have stopped.
465
+ #
466
+ # A target whose job is still :queued or :running gets that job back,
467
+ # whatever mode and timeout_ms this call passed; a finished job stays
468
+ # until the next self_check of its target replaces it. Only a
469
+ # one-row-per-source-key transform can be audited: an aggregate target
470
+ # raises ValidationError, and an unknown one NotFoundError. A column that is
471
+ # paused is left out of the comparison.
472
+ #
473
+ # job = trellis.self_check("order_totals", timeout_ms: 30_000)
474
+ # sleep 1 until (job = trellis.self_check_job(job.id)).finished?
475
+ # job.report.outcome # => :converged
476
+ #
477
+ # The call reads nothing of the target, so it returns well inside the
478
+ # 30-second call limit, holding no connection afterwards.
479
+ def self_check(target_table, timeout_ms:, mode: :standard)
480
+ string!("the target table", target_table)
481
+ timeout_ms!(timeout_ms)
482
+ unless SELF_CHECK_MODES.include?(mode)
483
+ raise ValidationError, "mode must be :standard or :strict, got: #{mode.inspect}"
484
+ end
485
+
486
+ SelfCheckJob.from_native(handle.self_check(target_table, mode.to_s, timeout_ms))
487
+ end
488
+
489
+ # The SelfCheckJob that self_check returned with this id, as it stands
490
+ # now, or nil when there is none: a newer self_check of its target
491
+ # replaced it, or its transform was dropped.
492
+ def self_check_job(id)
493
+ unless id.is_a?(Integer) && id.between?(-(2**63), MAX_LIMIT)
494
+ raise ValidationError, "the job id must be an Integer, got: #{id.inspect}"
495
+ end
496
+
497
+ hash = handle.self_check_job(id)
498
+ hash && SelfCheckJob.from_native(hash)
499
+ end
500
+
501
+ # Stops this instance: its background work, its connections and its
502
+ # runtime thread, and waits for them. The other instances of the process
503
+ # are untouched. Does nothing if not connected, which includes a forked
504
+ # child holding only the instance it inherited: that one is the parent's,
505
+ # so it's left in place, untouched, and the child's other calls on it
506
+ # still raise ForkedHandleError. A child's cleanup code (Puma's
507
+ # on_worker_shutdown, say) can call this whether or not the child
508
+ # connected. A shut-down instance is spent: connect a new one.
509
+ def shutdown
510
+ @lock.synchronize do
511
+ return nil unless connected?
512
+
513
+ handle = @handle
514
+ begin
515
+ handle.shutdown
516
+ ensure
517
+ # Also when the shutdown raised or was interrupted: its call still
518
+ # takes the engine out of the handle, so the handle is spent.
519
+ @handle = nil
520
+ self.class.send(:deregister, self)
521
+ end
522
+ end
523
+ nil
524
+ end
525
+
526
+ # Shows the schema, never the connection string.
527
+ def inspect
528
+ "#<#{self.class.name} schema=#{@schema.inspect} #{@handle.nil? ? 'shut down' : 'connected'}>"
529
+ end
530
+ alias to_s inspect
531
+
532
+ private
533
+
534
+ def handle
535
+ @handle or raise ValidationError,
536
+ "this Trellis instance (schema #{@schema.inspect}) is not connected: " \
537
+ "it has been shut down"
538
+ end
539
+ end
540
+ end