trellis-pg 0.1.0-aarch64-linux

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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: 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,561 @@
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.
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.
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. Any number of processes may set it: one
125
+ # takes the instance's producer lock and leads, the rest stand by and
126
+ # take over when it goes away, so a rolling deploy needs no
127
+ # operator step.
128
+ # - drain_threads: how many threads apply staged changes to the targets.
129
+ # Some process must run at least one per instance, or no definition
130
+ # reaches :live.
131
+ # - worker_threads: the worker threads of each Rust runtime this
132
+ # instance owns (its calls', and its background client's), invisible
133
+ # to Ruby's own thread sizing, so kept small and explicit. Each
134
+ # instance has its own; nothing is shared between instances.
135
+ # - max_call_pool_size: the most connections this instance's call pool
136
+ # (its API calls) opens at once. Connections open as calls need them
137
+ # and close after 60 seconds idle. The background client's pool is
138
+ # sized by staging and drain_threads, not by this.
139
+ # - call_pool_wait_timeout_ms: how long a call waits for a free call-pool
140
+ # connection before failing with a ConnectivityError.
141
+ #
142
+ # The defaults (staging: false, drain_threads: 0) run nothing in the
143
+ # background, which is what a migration step or a console wants. The
144
+ # staging worker reads Trellis's tables as it starts, so a staging: true
145
+ # connect to an unmigrated schema fails: migrate first.
146
+ def connect(url:, schema: "trellis", target_schema: "public", staging: false,
147
+ drain_threads: 0, worker_threads: 2, max_call_pool_size: 20,
148
+ call_pool_wait_timeout_ms: 30_000)
149
+ string_option!(:url, url)
150
+ string_option!(:schema, schema)
151
+ string_option!(:target_schema, target_schema)
152
+ unless [true, false].include?(staging)
153
+ raise ValidationError, "staging must be true or false, got: #{staging.inspect}"
154
+ end
155
+ unless drain_threads.is_a?(Integer) && drain_threads >= 0
156
+ raise ValidationError,
157
+ "drain_threads must be a non-negative Integer, got: #{drain_threads.inspect}"
158
+ end
159
+ unless worker_threads.is_a?(Integer) && worker_threads >= 1
160
+ raise ValidationError,
161
+ "worker_threads must be a positive Integer, got: #{worker_threads.inspect}"
162
+ end
163
+
164
+ unless max_call_pool_size.is_a?(Integer) && max_call_pool_size >= 1
165
+ raise ValidationError,
166
+ "max_call_pool_size must be a positive Integer, got: #{max_call_pool_size.inspect}"
167
+ end
168
+ unless call_pool_wait_timeout_ms.is_a?(Integer) &&
169
+ call_pool_wait_timeout_ms.between?(0, MAX_TIMEOUT_MS)
170
+ raise ValidationError,
171
+ "call_pool_wait_timeout_ms must be a non-negative Integer, got: " \
172
+ "#{call_pool_wait_timeout_ms.inspect}"
173
+ end
174
+
175
+ handle = Native.connect(url, schema, target_schema, staging, drain_threads, worker_threads,
176
+ max_call_pool_size, call_pool_wait_timeout_ms)
177
+ instance = new(handle, schema)
178
+ register(instance)
179
+ instance
180
+ end
181
+
182
+ # The instances this process holds, the default one included: every
183
+ # one it connected and hasn't shut down, minus any a forked child only
184
+ # inherited.
185
+ def connected
186
+ snapshot.select(&:connected?)
187
+ end
188
+
189
+ # Shuts down every instance this process holds, the `Trellis` module's
190
+ # default instance included, and waits for each. Does nothing for an
191
+ # instance a forked child only inherited. Every instance is attempted;
192
+ # if some fail, the first error is raised once the rest are done and
193
+ # the others are warned about. What a forking server calls before it
194
+ # forks (Puma's before_fork), and the at_exit hook's backstop.
195
+ def shutdown_all
196
+ errors = snapshot.filter_map do |instance|
197
+ instance.shutdown
198
+ nil
199
+ rescue Error => e
200
+ e
201
+ end
202
+ return nil if errors.empty?
203
+
204
+ errors.drop(1).each { |e| warn "trellis: shutting down an instance failed: #{e.class}: #{e.message}" }
205
+ raise errors.first
206
+ end
207
+
208
+ private
209
+
210
+ # Removes a shut-down instance from the registry.
211
+ def deregister(instance)
212
+ @registry.synchronize { @instances.delete(instance) }
213
+ end
214
+
215
+ def register(instance)
216
+ @registry.synchronize do
217
+ @instances << instance
218
+ install_at_exit
219
+ end
220
+ end
221
+
222
+ def snapshot
223
+ @registry.synchronize { @instances.dup }
224
+ end
225
+
226
+ def install_at_exit
227
+ return if @at_exit_installed
228
+
229
+ @at_exit_installed = true
230
+ at_exit { shutdown_all_at_exit }
231
+ end
232
+
233
+ # The backstop for a process that exits without shutting its instances
234
+ # down. A forked child that inherited them leaves them alone (see
235
+ # #shutdown).
236
+ def shutdown_all_at_exit
237
+ shutdown_all
238
+ rescue Error => e
239
+ warn "trellis: shutting down at exit failed: #{e.class}: #{e.message}"
240
+ end
241
+ end
242
+
243
+ def initialize(handle, schema)
244
+ @handle = handle
245
+ @schema = schema
246
+ @lock = Mutex.new
247
+ end
248
+ private_class_method :new
249
+
250
+ # Whether this instance holds a live handle. False after shutdown, and
251
+ # in a forked child that hasn't connected its own.
252
+ def connected?
253
+ handle = @handle
254
+ !handle.nil? && handle.owner_pid == Process.pid
255
+ end
256
+
257
+ # Creates or upgrades Trellis's own tables in the configured schema. Safe
258
+ # to run on every deploy.
259
+ def migrate
260
+ handle.migrate
261
+ nil
262
+ end
263
+
264
+ # The Config this instance connected with.
265
+ def config
266
+ Config.new(**handle.config)
267
+ end
268
+
269
+ # Registers a transform from a `TRANSFORM` statement, creates its target
270
+ # table, and returns the target table (fully qualified, "schema.table"),
271
+ # at :waiting_to_backfill. Its backfill runs in the background: poll
272
+ # #status with the returned name for :live.
273
+ #
274
+ # Only a `TRANSFORM` statement: any other form (DROP, PAUSE, ...) raises
275
+ # ValidationError without being applied, and a statement that doesn't
276
+ # parse raises ParseError. #apply takes every form.
277
+ #
278
+ # Trellis runs this on its own connections, not in the caller's
279
+ # transaction: if an enclosing migration rolls back, the definition stays.
280
+ def define(statement)
281
+ string!("the statement", statement)
282
+ handle.define(statement)
283
+ end
284
+
285
+ # Runs one statement of Trellis's grammar, whatever its form, and returns
286
+ # an Applied saying what it did.
287
+ #
288
+ # trellis.apply("TRANSFORM order_totals FROM orders SELECT price + tax AS total")
289
+ # # => #<data Trellis::Applied kind=:transform_defined, target_table="public.order_totals", ...>
290
+ # trellis.apply("RESUME TRANSFORM order_totals.total").columns
291
+ # # => ["order_totals.total"]
292
+ #
293
+ # A statement that doesn't parse raises ParseError, and nothing is
294
+ # applied. Like #define, this runs on Trellis's own connections, not in
295
+ # the caller's transaction.
296
+ #
297
+ # An Applied of kind :unknown is a success like any other: the statement
298
+ # took effect, and only its outcome is newer than this binding can
299
+ # describe. Don't retry it; read the result back with #status or
300
+ # #definitions if you need it.
301
+ def apply(statement)
302
+ string!("the statement", statement)
303
+ Applied.from_native(handle.apply(statement))
304
+ end
305
+
306
+ # The Status of the definition writing target_table, or nil when none
307
+ # does. target_table is either the fully qualified name #define and #apply
308
+ # return ("public.order_totals") or the bare table name ("order_totals");
309
+ # a qualified name with another schema matches nothing.
310
+ def status(target_table)
311
+ string!("the target table", target_table)
312
+ hash = handle.status(target_table)
313
+ hash && Status.from_native(hash)
314
+ end
315
+
316
+ # Every registered transform definition, oldest first, as
317
+ # DefinitionSummary values.
318
+ def definitions
319
+ handle.definitions.map { |hash| DefinitionSummary.from_native(hash) }
320
+ end
321
+
322
+ # The SQL that carries this instance's definitions in a schema-only dump
323
+ # of its database: Trellis's migration history, instance marker,
324
+ # transform definitions, and the seed rows capture
325
+ # needs. Append it to the output of `pg_dump --schema-only` (with the
326
+ # instance's schema and target schema in scope) and a database loaded
327
+ # from the file has every definition, each at :waiting_to_backfill, as
328
+ # `define` leaves it; the worker builds them. Runtime state (the ledger,
329
+ # ring, chunks, quarantine, claims and statuses) is never in it.
330
+ #
331
+ # Read from one snapshot, but a different one from the pg_dump's: a
332
+ # define or drop that commits between the two leaves them disagreeing.
333
+ # The Rails hook (Trellis::SchemaDump) reads it on both sides of the
334
+ # pg_dump and retries when the two differ.
335
+ def dump_definitions_sql
336
+ handle.dump_definitions_sql
337
+ end
338
+
339
+ # Rebuilds every definition that reads source_table (a bare table name,
340
+ # resolved like one in a statement), and returns once that rebuild is
341
+ # queued.
342
+ #
343
+ # Each :live reader reports :backfilling (rebuilt by the call itself)
344
+ # until the re-read has re-derived every row the table still has and
345
+ # deleted any target row it no longer backs. A newly defined transform
346
+ # doesn't need this: its backfill is queued for it. Only a table Trellis
347
+ # already captures can be re-read; any other raises NotFoundError.
348
+ def request_backfill(source_table)
349
+ string!("the source table", source_table)
350
+ handle.request_backfill(source_table)
351
+ nil
352
+ end
353
+
354
+ # Releases one key `transform` holds in quarantine, once its cause is
355
+ # fixed. source_table ("public.orders" or "orders") and key are as
356
+ # sample_quarantined and poisoned_since report them.
357
+ #
358
+ # It stages a recompute of the key, which every transform reading the
359
+ # table applies from the key's current row, and discards the changes held
360
+ # for `transform` meanwhile. Another transform holding the same key keeps
361
+ # holding it. If the cause is still there, the key is poisoned again.
362
+ # Resuming the transform releases every key it holds.
363
+ #
364
+ # An unknown transform raises NotFoundError, and so does a key it doesn't
365
+ # hold, which changes nothing. The release first waits for the drain
366
+ # pages in flight on the table to commit; a wait past the lock timeout
367
+ # (30 seconds) raises TimeoutError and changes nothing: call it again.
368
+ #
369
+ # trellis.release_key("order_totals", "public.orders", "42")
370
+ def release_key(transform, source_table, key)
371
+ string!("the transform", transform)
372
+ string!("the source table", source_table)
373
+ string!("the key", key)
374
+ handle.release_key(transform, source_table, key)
375
+ nil
376
+ end
377
+
378
+ # Every source row the apply path poisoned (gave up on) after `since` (a
379
+ # Time), oldest first, as PoisonEntry values. Poll it with the last
380
+ # entry's poisoned_at so a whole-table failure doesn't sit unnoticed.
381
+ def poisoned_since(since)
382
+ raise ValidationError, "since must be a Time, got: #{since.inspect}" unless since.is_a?(::Time)
383
+
384
+ micros = EpochMicros.from_time(since) or
385
+ raise ValidationError, "since is out of range: #{since.inspect}"
386
+ handle.poisoned_since(micros).map { |hash| PoisonEntry.from_native(hash) }
387
+ end
388
+
389
+ # Every quarantined transform and paused column, across every transform,
390
+ # as QuarantineEntry values. Cheap enough for a dashboard or health
391
+ # check to poll.
392
+ def quarantined
393
+ handle.quarantined.map { |hash| QuarantineEntry.from_native(hash) }
394
+ end
395
+
396
+ # The QuarantineEntry of one target: a transform ("order_totals") or one
397
+ # of its columns ("order_totals.total"). A column that isn't paused is
398
+ # :live.
399
+ def quarantine_status(target)
400
+ string!("the target", target)
401
+ QuarantineEntry.from_native(handle.quarantine_status(target))
402
+ end
403
+
404
+ # One SamplePage of the rows quarantined under target, to diagnose a
405
+ # quarantine's cause: for a column ("order_totals.total"), the rows that
406
+ # failed evaluating it; for a whole transform ("order_totals"), the keys
407
+ # poisoned from its source table.
408
+ #
409
+ # - limit: the most rows to return.
410
+ # - after: the previous page's next_cursor; nil for the first page.
411
+ #
412
+ # page = trellis.sample_quarantined("order_totals.total", limit: 50)
413
+ # page = trellis.sample_quarantined("order_totals.total", limit: 50, after: page.next_cursor)
414
+ def sample_quarantined(target, limit: 100, after: nil)
415
+ string!("the target", target)
416
+ limit!(limit)
417
+ cursor!(after, "a cursor from a previous page")
418
+ SamplePage.from_native(handle.sample_quarantined(target, after, limit))
419
+ end
420
+
421
+ # Whether at least one drain worker is alive anywhere in the fleet. With
422
+ # none, nothing reaches a target table: poll this from a health check.
423
+ def has_live_drain_workers?
424
+ handle.has_live_drain_workers
425
+ end
426
+
427
+ # Whether the staging worker (which installs change capture and starts
428
+ # backfills) is alive anywhere in the fleet. The other half of the health check.
429
+ def has_live_staging_worker?
430
+ handle.has_live_staging_worker
431
+ end
432
+
433
+ # Pauses, now, the definitions that read a table whose Trellis capture
434
+ # was changed outside Trellis (a trigger disabled, dropped or recreated, a
435
+ # capture function replaced, the table gone), and returns the qualified
436
+ # names of those tables. The staging worker's reconcile pass does the same
437
+ # on its own cadence, with a CaptureFailure of kind :disruption on each
438
+ # paused definition's status; this is for a caller that has just done
439
+ # something that disrupts capture, like a fixture load, and needs the
440
+ # readers paused before it goes on. Installs, repairs and rebuilds
441
+ # nothing, and resuming is still the caller's (Trellis::TestSupport does
442
+ # it for a test suite). Safe beside a running staging worker.
443
+ def detect_capture_disruptions
444
+ handle.detect_capture_disruptions
445
+ end
446
+
447
+ # A read-your-writes token (an opaque String) covering every write
448
+ # committed before this call. Take it after a source-table write commits,
449
+ # then pass it to #await_converged to wait for that write to reach its
450
+ # targets.
451
+ def watermark_token
452
+ handle.watermark_token
453
+ end
454
+
455
+ # Waits until every change committed at or before `token` has reached its
456
+ # target tables, or timeout_ms passes (a TimeoutError; retry it). Returns
457
+ # nil.
458
+ #
459
+ # It waits for captured changes only: a transform that isn't :live yet
460
+ # may still be missing rows when this returns (see #status).
461
+ #
462
+ # Like every call, it returns within 30 seconds: a longer timeout_ms is
463
+ # cut to that, and a caller that wants to wait longer calls again. Calls
464
+ # from other threads don't wait behind it.
465
+ def await_converged(token, timeout_ms:)
466
+ string!("the token", token)
467
+ timeout_ms!(timeout_ms)
468
+ handle.await_converged(token, timeout_ms)
469
+ nil
470
+ end
471
+
472
+ # Starts a background check of target_table against a fresh recompute of
473
+ # its definition from the source tables, and returns a SelfCheckJob at
474
+ # once. The comparison is a drain worker's, a page of keys at a time
475
+ # (with no drain worker anywhere in the fleet the job stays :queued, as
476
+ # a define does); poll self_check_job with the job's id until it is
477
+ # finished, and its report is the verdict.
478
+ #
479
+ # - timeout_ms: how long a page of the job waits for the target to catch
480
+ # up, per wait. A :standard check waits up to twice per page, a
481
+ # :strict one once.
482
+ # - mode: :standard re-checks anything that differs after a fresh wait,
483
+ # so a change still in flight isn't reported; it is safe while the
484
+ # source is being written. :strict skips the re-check, and is only
485
+ # sound once writes to the audited tables have stopped.
486
+ #
487
+ # A target whose job is still :queued or :running gets that job back,
488
+ # whatever mode and timeout_ms this call passed; a finished job stays
489
+ # until the next self_check of its target replaces it. Only a
490
+ # one-row-per-source-key transform can be audited: an aggregate target
491
+ # raises ValidationError, and an unknown one NotFoundError. A column that is
492
+ # paused is left out of the comparison.
493
+ #
494
+ # job = trellis.self_check("order_totals", timeout_ms: 30_000)
495
+ # sleep 1 until (job = trellis.self_check_job(job.id)).finished?
496
+ # job.report.outcome # => :converged
497
+ #
498
+ # The call reads nothing of the target, so it returns well inside the
499
+ # 30-second call limit, holding no connection afterwards.
500
+ def self_check(target_table, timeout_ms:, mode: :standard)
501
+ string!("the target table", target_table)
502
+ timeout_ms!(timeout_ms)
503
+ unless SELF_CHECK_MODES.include?(mode)
504
+ raise ValidationError, "mode must be :standard or :strict, got: #{mode.inspect}"
505
+ end
506
+
507
+ SelfCheckJob.from_native(handle.self_check(target_table, mode.to_s, timeout_ms))
508
+ end
509
+
510
+ # The SelfCheckJob that self_check returned with this id, as it stands
511
+ # now, or nil when there is none: a newer self_check of its target
512
+ # replaced it, or its transform was dropped.
513
+ def self_check_job(id)
514
+ unless id.is_a?(Integer) && id.between?(-(2**63), MAX_LIMIT)
515
+ raise ValidationError, "the job id must be an Integer, got: #{id.inspect}"
516
+ end
517
+
518
+ hash = handle.self_check_job(id)
519
+ hash && SelfCheckJob.from_native(hash)
520
+ end
521
+
522
+ # Stops this instance: its background work, its connections and its
523
+ # runtime thread, and waits for them. The other instances of the process
524
+ # are untouched. Does nothing if not connected, which includes a forked
525
+ # child holding only the instance it inherited: that one is the parent's,
526
+ # so it's left in place, untouched, and the child's other calls on it
527
+ # still raise ForkedHandleError. A child's cleanup code (Puma's
528
+ # on_worker_shutdown, say) can call this whether or not the child
529
+ # connected. A shut-down instance is spent: connect a new one.
530
+ def shutdown
531
+ @lock.synchronize do
532
+ return nil unless connected?
533
+
534
+ handle = @handle
535
+ begin
536
+ handle.shutdown
537
+ ensure
538
+ # Also when the shutdown raised or was interrupted: its call still
539
+ # takes the engine out of the handle, so the handle is spent.
540
+ @handle = nil
541
+ self.class.send(:deregister, self)
542
+ end
543
+ end
544
+ nil
545
+ end
546
+
547
+ # Shows the schema, never the connection string.
548
+ def inspect
549
+ "#<#{self.class.name} schema=#{@schema.inspect} #{@handle.nil? ? 'shut down' : 'connected'}>"
550
+ end
551
+ alias to_s inspect
552
+
553
+ private
554
+
555
+ def handle
556
+ @handle or raise ValidationError,
557
+ "this Trellis instance (schema #{@schema.inspect}) is not connected: " \
558
+ "it has been shut down"
559
+ end
560
+ end
561
+ end