trellis-pg 0.1.0.pre.alpha
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/LICENSE.txt +206 -0
- data/README.md +567 -0
- data/ext/trellis_ruby/Cargo.lock +2218 -0
- data/ext/trellis_ruby/Cargo.toml +37 -0
- data/ext/trellis_ruby/extconf.rb +15 -0
- data/ext/trellis_ruby/src/engine.rs +222 -0
- data/ext/trellis_ruby/src/lib.rs +1281 -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 +77 -0
|
@@ -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
|