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,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