trellis-pg 0.1.0-arm64-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 +620 -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 +561 -0
- data/lib/trellis/metrics.rb +38 -0
- data/lib/trellis/migration.rb +186 -0
- data/lib/trellis/pg.rb +142 -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 +67 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
require "etc"
|
|
5
|
+
require "trellis/pg"
|
|
6
|
+
|
|
7
|
+
module Trellis
|
|
8
|
+
# Makes `structure.sql` carry Trellis's definitions, the way Rails makes it
|
|
9
|
+
# carry `schema_migrations`: after `pg_dump` writes the schema, the dump
|
|
10
|
+
# appends the `INSERT`s that describe how the schema came to be. A database
|
|
11
|
+
# built from the file (`db:schema:load`, `db:prepare`, the test database
|
|
12
|
+
# `maintain_test_schema!` loads) then has every transform defined, with no
|
|
13
|
+
# Trellis step to run: Trellis's worker finds the definitions unbuilt and builds them (ADR-0010).
|
|
14
|
+
#
|
|
15
|
+
# Needs `config.active_record.schema_format = :sql`: a `schema.rb` can't
|
|
16
|
+
# carry another schema's tables or triggers. With `:ruby`, the dump raises
|
|
17
|
+
# Trellis::ValidationError and writes nothing: a `schema.rb` would load
|
|
18
|
+
# into a database with target tables and no definitions.
|
|
19
|
+
#
|
|
20
|
+
# It hooks the two places Rails offers no extension point for (both the
|
|
21
|
+
# `db:schema:dump` task and the dump after `db:migrate` call them):
|
|
22
|
+
#
|
|
23
|
+
# - ActiveRecord::Tasks::DatabaseTasks.dump_schema, whose `:sql` branch
|
|
24
|
+
# is `pg_dump` followed by the `schema_migrations` rows. This hook adds
|
|
25
|
+
# the Trellis rows after them: Trellis::Instance#dump_definitions_sql
|
|
26
|
+
# for the default instance and each of config.trellis.instances. Those
|
|
27
|
+
# rows are read before and after the `pg_dump`, which reads its own
|
|
28
|
+
# snapshot, and the dump is redone (up to three times) when a define or
|
|
29
|
+
# drop committed in between, so the DDL and the rows agree.
|
|
30
|
+
# - ActiveRecord::Tasks::PostgreSQLDatabaseTasks#structure_dump, which
|
|
31
|
+
# passes `--schema` only for the search path's schemas. This hook adds
|
|
32
|
+
# each instance's `schema` and `target_schema`, so the Trellis tables and
|
|
33
|
+
# capture triggers are in the dump. (Nothing is added when Rails dumps
|
|
34
|
+
# every schema anyway, `ActiveRecord.dump_schemas = :all` or no
|
|
35
|
+
# `schema_search_path`.)
|
|
36
|
+
#
|
|
37
|
+
# Both apply to one database: the config named by
|
|
38
|
+
# `config.trellis.dump_database` (default "primary"), the one
|
|
39
|
+
# config.trellis.connect and config.trellis.instances point at. The other
|
|
40
|
+
# databases of a multi-database app dump as Rails dumps them.
|
|
41
|
+
#
|
|
42
|
+
# A dump lists definitions and nothing else of Trellis's state. Every
|
|
43
|
+
# definition loads as `waiting_to_backfill`, as a `define` leaves it,
|
|
44
|
+
# including one that was paused or quarantined where the dump was taken.
|
|
45
|
+
#
|
|
46
|
+
# `pg_dump` 17 writes `CREATE SCHEMA public` when `public` is dumped,
|
|
47
|
+
# which fails to load into a database that has one (every database
|
|
48
|
+
# `db:create` makes); the hook writes `CREATE SCHEMA IF NOT EXISTS public`.
|
|
49
|
+
module SchemaDump
|
|
50
|
+
# How many times a dump is redone when a define or drop races it.
|
|
51
|
+
ATTEMPTS = 3
|
|
52
|
+
|
|
53
|
+
# The hosts a Rails config with no host (libpq's default, the local
|
|
54
|
+
# socket) matches, besides a socket directory.
|
|
55
|
+
LOCAL_HOSTS = %w[localhost 127.0.0.1 ::1].freeze
|
|
56
|
+
|
|
57
|
+
HEADER = <<~SQL
|
|
58
|
+
--
|
|
59
|
+
-- Trellis definitions (Trellis::Instance#dump_definitions_sql)
|
|
60
|
+
--
|
|
61
|
+
|
|
62
|
+
SQL
|
|
63
|
+
|
|
64
|
+
class << self
|
|
65
|
+
# Installs the hooks. Idempotent.
|
|
66
|
+
def install
|
|
67
|
+
::ActiveRecord::Tasks::DatabaseTasks.singleton_class.prepend(DatabaseTasksHook)
|
|
68
|
+
::ActiveRecord::Tasks::PostgreSQLDatabaseTasks.prepend(StructureDumpHook)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Whether Trellis's definitions belong in the dump of db_config: Trellis
|
|
72
|
+
# is configured, and db_config is the database config.trellis.dump_database
|
|
73
|
+
# names.
|
|
74
|
+
def applies?(db_config)
|
|
75
|
+
return false unless Railtie.connect_options || !Railtie.instance_options.empty?
|
|
76
|
+
|
|
77
|
+
db_config.name == dump_database
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Raises Trellis::ValidationError unless config.trellis.connect and every
|
|
81
|
+
# config.trellis.instances entry point at the database db_config
|
|
82
|
+
# describes: appending the definitions of one database to the dump of
|
|
83
|
+
# another produces a file that fails to load.
|
|
84
|
+
#
|
|
85
|
+
# Databases are compared by host, port and dbname, each resolved the way
|
|
86
|
+
# its client resolves it: Rails' config as libpq does (PGHOST, PGPORT,
|
|
87
|
+
# PGDATABASE, then the user name), a Trellis url as Trellis does, from
|
|
88
|
+
# the url alone (port 5432, dbname the user name). A Rails config with no
|
|
89
|
+
# host connects to libpq's default, the local socket, which matches a
|
|
90
|
+
# local Trellis host (`localhost`, `127.0.0.1`, `::1` or a socket
|
|
91
|
+
# directory). Other spellings are not equated: `localhost` and
|
|
92
|
+
# `127.0.0.1` differ, so give config.trellis.connect and database.yml the
|
|
93
|
+
# same host.
|
|
94
|
+
#
|
|
95
|
+
# `db:prepare` in development also migrates and dumps the test
|
|
96
|
+
# environment's databases, while config.trellis.connect is
|
|
97
|
+
# development's: a db_config of another environment is compared as the
|
|
98
|
+
# current environment's config of the same name.
|
|
99
|
+
def verify_same_database!(db_config)
|
|
100
|
+
require "pg"
|
|
101
|
+
target = rails_database(current_environment_config(db_config).configuration_hash)
|
|
102
|
+
configured = {}
|
|
103
|
+
configured["config.trellis.connect"] = Railtie.connect_options if Railtie.connect_options
|
|
104
|
+
Railtie.instance_options.each { |name, options| configured["config.trellis.instances[#{name.inspect}]"] = options }
|
|
105
|
+
configured.each do |what, options|
|
|
106
|
+
next unless options[:url]
|
|
107
|
+
|
|
108
|
+
found = trellis_database(options[:url], what)
|
|
109
|
+
next if same_database?(found, target)
|
|
110
|
+
|
|
111
|
+
raise Trellis::ValidationError,
|
|
112
|
+
"#{what} points at #{describe(found)}, but config.trellis.dump_database " \
|
|
113
|
+
"#{db_config.name.inspect} is #{describe(target)}: its definitions don't belong in " \
|
|
114
|
+
"that database's structure dump. Point #{what} at the same host, port and database " \
|
|
115
|
+
"(spelled identically, e.g. `localhost` is not `127.0.0.1`), or set " \
|
|
116
|
+
"config.trellis.dump_database to the config that names it"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def dump_database
|
|
121
|
+
app = ::Rails.application
|
|
122
|
+
(app&.config&.trellis&.dump_database || "primary").to_s
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The `--schema` flags to add to the pg_dump of db_config, whose
|
|
126
|
+
# connection settings are configuration_hash.
|
|
127
|
+
def schema_flags(db_config, configuration_hash)
|
|
128
|
+
return [] unless applies?(db_config) && restricts_schemas?(configuration_hash)
|
|
129
|
+
|
|
130
|
+
schemas = []
|
|
131
|
+
default = Railtie.connect_options
|
|
132
|
+
schemas << default if default
|
|
133
|
+
schemas.concat(Railtie.instance_options.values)
|
|
134
|
+
schemas.flat_map { |options| [options.fetch(:schema, "trellis"), options.fetch(:target_schema, "public")] }
|
|
135
|
+
.uniq.map { |schema| "--schema=#{schema}" }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# The definitions of every configured instance as SQL: each one's
|
|
139
|
+
# dump_definitions_sql.
|
|
140
|
+
def appendix
|
|
141
|
+
parts = []
|
|
142
|
+
parts << Railtie.with_handle(&:dump_definitions_sql) if Railtie.connect_options
|
|
143
|
+
Railtie.instance_options.each_key { |name| parts << Railtie.with_handle(name, &:dump_definitions_sql) }
|
|
144
|
+
parts.join("\n")
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Appends appendix to the dump at filename, and makes its
|
|
148
|
+
# `CREATE SCHEMA public` loadable into a database that has one.
|
|
149
|
+
def finish(filename, appendix)
|
|
150
|
+
sql = File.read(filename, encoding: "utf-8")
|
|
151
|
+
sql = sql.gsub(/^CREATE SCHEMA public;$/, "CREATE SCHEMA IF NOT EXISTS public;")
|
|
152
|
+
File.write(filename, "#{sql}#{HEADER}#{appendix}\n", encoding: "utf-8")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
# The keywords of a libpq connection string or URI. A failure names
|
|
158
|
+
# what was being read and never the string or libpq's message, which
|
|
159
|
+
# can quote part of a password (as Trellis's own parse does).
|
|
160
|
+
def conninfo(url, what)
|
|
161
|
+
PG::Connection.conninfo_parse(url.to_s).to_h { |entry| [entry[:keyword], entry[:val]] }
|
|
162
|
+
rescue PG::Error
|
|
163
|
+
raise Trellis::ValidationError,
|
|
164
|
+
"#{what}'s url doesn't parse as a libpq connection string or postgres:// URL"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# [host, port, dbname] Trellis connects to with url. Trellis reads
|
|
168
|
+
# nothing from the environment: an unset port is 5432 and an unset
|
|
169
|
+
# dbname the user's name (the server's default), the user being the
|
|
170
|
+
# OS user when the url names none. The host is nil only for a url
|
|
171
|
+
# naming neither host nor hostaddr, which Trellis can't connect with.
|
|
172
|
+
def trellis_database(url, what)
|
|
173
|
+
keywords = conninfo(url, what).transform_values { |value| value.to_s.empty? ? nil : value }
|
|
174
|
+
user = keywords["user"] || Etc.getpwuid(Process.euid)&.name
|
|
175
|
+
[keywords["host"] || keywords["hostaddr"], keywords["port"] || "5432", keywords["dbname"] || user.to_s]
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# [host, port, dbname] of a Rails database config hash, as libpq
|
|
179
|
+
# resolves it: Rails' keys (or a `url`, which a hand-built HashConfig
|
|
180
|
+
# can carry), then PGHOST/PGPORT/PGDATABASE and libpq's defaults. The
|
|
181
|
+
# host is nil when nothing names one: libpq's default, the local socket.
|
|
182
|
+
def rails_database(hash)
|
|
183
|
+
hash = hash.transform_keys(&:to_sym)
|
|
184
|
+
url = hash[:url] ? conninfo(hash[:url], "config.trellis.dump_database's url") : {}
|
|
185
|
+
given = { host: hash[:host] || url["host"], port: hash[:port] || url["port"],
|
|
186
|
+
dbname: hash[:database] || url["dbname"], user: hash[:username] || url["user"] }
|
|
187
|
+
defaults = PG::Connection.conndefaults_hash
|
|
188
|
+
resolved = given.to_h do |key, value|
|
|
189
|
+
value = defaults[key] if value.nil? || value.to_s.empty?
|
|
190
|
+
[key, value.nil? || value.to_s.empty? ? nil : value.to_s]
|
|
191
|
+
end
|
|
192
|
+
[resolved[:host], resolved[:port] || "5432", resolved[:dbname] || resolved[:user].to_s]
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def same_database?(trellis, rails)
|
|
196
|
+
host, port, dbname = trellis
|
|
197
|
+
rails_host, rails_port, rails_dbname = rails
|
|
198
|
+
return false unless port == rails_port && dbname == rails_dbname
|
|
199
|
+
|
|
200
|
+
host == rails_host || (rails_host.nil? && local_host?(host))
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def local_host?(host)
|
|
204
|
+
LOCAL_HOSTS.include?(host) || host.to_s.start_with?("/", "@")
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# The config named db_config.name in the environment being run, or
|
|
208
|
+
# db_config itself when it is that environment's (or there is none).
|
|
209
|
+
def current_environment_config(db_config)
|
|
210
|
+
env = ::ActiveRecord::Tasks::DatabaseTasks.env.to_s
|
|
211
|
+
return db_config if db_config.env_name.to_s == env
|
|
212
|
+
|
|
213
|
+
::ActiveRecord::Base.configurations.configs_for(env_name: env, name: db_config.name) || db_config
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def describe(database)
|
|
217
|
+
host, port, dbname = database
|
|
218
|
+
"#{dbname} on #{host || "the local socket"}:#{port}"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Whether the structure dump passes any `--schema` (so a schema not
|
|
222
|
+
# listed is left out), as PostgreSQLDatabaseTasks#structure_dump decides.
|
|
223
|
+
def restricts_schemas?(configuration_hash)
|
|
224
|
+
case ::ActiveRecord.dump_schemas
|
|
225
|
+
when :schema_search_path then !configuration_hash[:schema_search_path].to_s.strip.empty?
|
|
226
|
+
when :all then false
|
|
227
|
+
else !::ActiveRecord.dump_schemas.to_s.strip.empty?
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Prepended to ActiveRecord::Tasks::DatabaseTasks's singleton.
|
|
233
|
+
module DatabaseTasksHook
|
|
234
|
+
# Rails 8.1 defaults format to db_config.schema_format, earlier
|
|
235
|
+
# versions to ActiveRecord.schema_format: the default is left to Rails'
|
|
236
|
+
# own signature, which an unconfigured database goes straight to.
|
|
237
|
+
def dump_schema(db_config, *args)
|
|
238
|
+
return super unless SchemaDump.applies?(db_config) && db_config.schema_dump
|
|
239
|
+
|
|
240
|
+
format = args.first ||
|
|
241
|
+
(db_config.respond_to?(:schema_format) ? db_config.schema_format : ::ActiveRecord.schema_format)
|
|
242
|
+
|
|
243
|
+
unless format.to_sym == :sql
|
|
244
|
+
raise Trellis::ValidationError,
|
|
245
|
+
"#{db_config.name}'s schema dump is #{format}, which can't carry Trellis's " \
|
|
246
|
+
"tables, triggers or definitions: set config.active_record.schema_format = :sql"
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
filename = schema_dump_path(db_config, format)
|
|
250
|
+
return super unless filename
|
|
251
|
+
|
|
252
|
+
SchemaDump.verify_same_database!(db_config)
|
|
253
|
+
|
|
254
|
+
SchemaDump::ATTEMPTS.times do
|
|
255
|
+
before = SchemaDump.appendix
|
|
256
|
+
super(db_config, format)
|
|
257
|
+
if SchemaDump.appendix == before
|
|
258
|
+
SchemaDump.finish(filename, before)
|
|
259
|
+
return nil
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
raise Trellis::ConflictError,
|
|
263
|
+
"Trellis's definitions changed during each of #{SchemaDump::ATTEMPTS} dumps of " \
|
|
264
|
+
"#{db_config.name}: run the dump when no define or drop is running"
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Prepended to ActiveRecord::Tasks::PostgreSQLDatabaseTasks.
|
|
269
|
+
module StructureDumpHook
|
|
270
|
+
def structure_dump(filename, extra_flags)
|
|
271
|
+
flags = SchemaDump.schema_flags(db_config, configuration_hash)
|
|
272
|
+
super(filename, flags.empty? ? extra_flags : Array(extra_flags) + flags)
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
Trellis::SchemaDump.install
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "error"
|
|
4
|
+
require_relative "instance"
|
|
5
|
+
|
|
6
|
+
module Trellis
|
|
7
|
+
# Helpers for a test suite that loads fixtures or bulk data into tables
|
|
8
|
+
# Trellis captures. Not loaded by `require "trellis/pg"`:
|
|
9
|
+
#
|
|
10
|
+
# require "trellis/test_support"
|
|
11
|
+
#
|
|
12
|
+
# A fixture loader that disables triggers for the load (Rails'
|
|
13
|
+
# `disable_referential_integrity`, `pg_restore --disable-triggers`, a
|
|
14
|
+
# hand-written `ALTER TABLE ... DISABLE TRIGGER ALL`) also disables
|
|
15
|
+
# Trellis's capture triggers, so the rows it writes never reach a target,
|
|
16
|
+
# and `ENABLE TRIGGER ALL` leaves them in a mode the staging worker treats
|
|
17
|
+
# as a disruption of capture. Trellis then pauses every definition that
|
|
18
|
+
# reads the table (a CaptureFailure of kind :disruption on its status)
|
|
19
|
+
# rather than let a test read targets that are silently wrong. A test
|
|
20
|
+
# suite's answer is a RESUME after the load, which rebuilds the targets
|
|
21
|
+
# from the tables as they are now:
|
|
22
|
+
#
|
|
23
|
+
# Trellis::TestSupport.resume_capture_disruptions
|
|
24
|
+
#
|
|
25
|
+
# Include the module to call it as a method of the test or its class:
|
|
26
|
+
#
|
|
27
|
+
# # test/test_helper.rb of a Rails app
|
|
28
|
+
# class ActiveSupport::TestCase
|
|
29
|
+
# include Trellis::TestSupport
|
|
30
|
+
# fixtures :all
|
|
31
|
+
# # Rails loads the fixtures in its own setup, before this block runs.
|
|
32
|
+
# setup { resume_capture_disruptions }
|
|
33
|
+
# end
|
|
34
|
+
#
|
|
35
|
+
# The call is cheap when nothing was disrupted (one catalog read per
|
|
36
|
+
# captured table), so running it before every test is fine.
|
|
37
|
+
module TestSupport
|
|
38
|
+
# Raised when a RESUME is refused or a resumed definition pauses again.
|
|
39
|
+
# `refused` maps each such target (as "schema.table") to the reason: the
|
|
40
|
+
# engine's error for a refused RESUME, or the new CaptureFailure's
|
|
41
|
+
# sentence. Those definitions stay paused. Every other disrupted
|
|
42
|
+
# definition was resumed, and waited for, before this is raised.
|
|
43
|
+
class ResumeRefused < ValidationError
|
|
44
|
+
attr_reader :refused
|
|
45
|
+
|
|
46
|
+
def initialize(refused)
|
|
47
|
+
@refused = refused.freeze
|
|
48
|
+
super("could not resume #{refused.size} definition(s) paused by a capture disruption " \
|
|
49
|
+
"(they stay paused): " + refused.map { |target, why| "#{target}: #{why}" }.join("; "))
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
POLL_SECONDS = 0.05
|
|
54
|
+
# How a CaptureFailure's error starts while a resume is still under way.
|
|
55
|
+
RESUMING = "resuming:"
|
|
56
|
+
|
|
57
|
+
module_function
|
|
58
|
+
|
|
59
|
+
# Resumes every definition of `instance` (the default instance if none
|
|
60
|
+
# is given) that is paused because Trellis's capture of a table it reads
|
|
61
|
+
# was changed outside Trellis, waits until each is :live again, then
|
|
62
|
+
# waits until every write committed so far has reached its targets
|
|
63
|
+
# (await_converged), so a test that follows reads exact targets. Returns
|
|
64
|
+
# the resumed targets, as "schema.table" strings (empty when nothing was
|
|
65
|
+
# disrupted).
|
|
66
|
+
#
|
|
67
|
+
# It runs a disruption check of its own first (Instance#detect_capture_disruptions),
|
|
68
|
+
# so a disruption the staging worker has not seen yet is paused and
|
|
69
|
+
# resumed here rather than a pass later.
|
|
70
|
+
#
|
|
71
|
+
# It resumes only the CaptureFailure kind :disruption. A definition an
|
|
72
|
+
# operator paused (PAUSE TRANSFORM), one paused by a schema change, a
|
|
73
|
+
# drain halt or a failing build stays paused. A definition paused because
|
|
74
|
+
# an upstream one was resumes with its upstream.
|
|
75
|
+
#
|
|
76
|
+
# A RESUME the engine refuses (for example, the table a definition read
|
|
77
|
+
# is missing, or a table define would now refuse) leaves that definition
|
|
78
|
+
# paused. The others are still resumed and waited for; then
|
|
79
|
+
# ResumeRefused, listing every refusal, is raised. A definition that
|
|
80
|
+
# does not reach :live within `timeout_ms` raises Trellis::TimeoutError.
|
|
81
|
+
#
|
|
82
|
+
# The instance needs its staging worker and a drain worker running
|
|
83
|
+
# somewhere (the usual test setup connects with `staging: true,
|
|
84
|
+
# drain_threads: 1`): resuming rebuilds through them.
|
|
85
|
+
def resume_capture_disruptions(instance = Trellis.default_instance, timeout_ms: 30_000)
|
|
86
|
+
deadline = monotonic + (timeout_ms / 1000.0)
|
|
87
|
+
instance.detect_capture_disruptions
|
|
88
|
+
|
|
89
|
+
refused = {}
|
|
90
|
+
resumed = []
|
|
91
|
+
disrupted(instance).each do |target|
|
|
92
|
+
instance.apply("RESUME TRANSFORM #{target}")
|
|
93
|
+
resumed << target
|
|
94
|
+
rescue ValidationError, NotFoundError, ConflictError => e
|
|
95
|
+
refused[target] = e.message
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
resumed.each { |target| await_live(instance, target, deadline, refused) }
|
|
99
|
+
converge(instance, deadline)
|
|
100
|
+
raise ResumeRefused, refused unless refused.empty?
|
|
101
|
+
|
|
102
|
+
resumed - refused.keys
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The targets of `instance`'s definitions that are paused with a
|
|
106
|
+
# CaptureFailure of kind :disruption, oldest first.
|
|
107
|
+
def disrupted(instance)
|
|
108
|
+
instance.definitions.select { |definition| definition.status == :paused }.filter_map do |definition|
|
|
109
|
+
status = instance.status(definition.target_table)
|
|
110
|
+
definition.target_table if status&.capture_failure&.kind == :disruption
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Waits for `target` to be :live. A resumed definition that pauses
|
|
115
|
+
# again (its rebuild failed) is recorded in `refused` instead of waited
|
|
116
|
+
# for.
|
|
117
|
+
def await_live(instance, target, deadline, refused)
|
|
118
|
+
loop do
|
|
119
|
+
status = instance.status(target)
|
|
120
|
+
return if status&.status == :live
|
|
121
|
+
|
|
122
|
+
if status&.status == :paused
|
|
123
|
+
failure = status.capture_failure
|
|
124
|
+
# Paused again by something else: its rebuild failed. A failure
|
|
125
|
+
# whose error starts "resuming:" is the resume still under way (the
|
|
126
|
+
# staging worker re-types Trellis's columns first, or waits for an
|
|
127
|
+
# upstream definition), and is waited for. (Paused again by a
|
|
128
|
+
# disruption is waited out: the next resume is the caller's, and
|
|
129
|
+
# the timeout reports it.)
|
|
130
|
+
if failure && failure.kind != :disruption && !failure.error.start_with?(RESUMING)
|
|
131
|
+
refused[target] = failure.error
|
|
132
|
+
return
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
raise TimeoutError, "#{target} was not :live in time after its resume; last saw #{status.inspect}" if monotonic > deadline
|
|
136
|
+
|
|
137
|
+
sleep POLL_SECONDS
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Waits for everything committed so far to reach its targets, in as many
|
|
142
|
+
# waits as the 30-second call limit needs.
|
|
143
|
+
def converge(instance, deadline)
|
|
144
|
+
token = instance.watermark_token
|
|
145
|
+
loop do
|
|
146
|
+
remaining_ms = ((deadline - monotonic) * 1000).to_i
|
|
147
|
+
raise TimeoutError, "targets did not converge in time" if remaining_ms <= 0
|
|
148
|
+
|
|
149
|
+
begin
|
|
150
|
+
return instance.await_converged(token, timeout_ms: [remaining_ms, 30_000].min)
|
|
151
|
+
rescue TimeoutError
|
|
152
|
+
raise if monotonic >= deadline
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def monotonic
|
|
158
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|