trellis-pg 0.1.1 → 0.2.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 410251832893ecd2d20f86866c37a19d6759daea75a7f921725d0e943533ed39
4
- data.tar.gz: 36360b89efadf99fbf39801c2b35d12525966cca924a2e00a86ef134e05016b4
3
+ metadata.gz: 989a2bb8e00efa189fc95684dca175ecbdf17e0e6227ea26462c837276418567
4
+ data.tar.gz: 70d484a63fece2c1e4c61821f2565b3431638e65b74b6ad724fd22396c5df6bb
5
5
  SHA512:
6
- metadata.gz: 37f46e475451bb8e861c578418fe9b98eb031bbb03bb04cdd357af15e5c3655a60e0fcee81de6bc8f952b72215bc16fee8cecc72b301ee9b5cbf7b3722f9cec1
7
- data.tar.gz: 5b0062cd93400242b4dc7a5c20408a28853adf2d49549085471e53df12e2122e4e09ba96668bf72271c39757637ddb1786d7c6aaeba332d79d27fc738a907d70
6
+ metadata.gz: a0dde1a7bc9e39fdf24a27fe6130591d0423ae1f89ff059b61d0d06b65f186686486b18552e448ba46c4bd8427a6043e9365022feef9e637cd54166904a4996c
7
+ data.tar.gz: 05b66f0c6d95a08173f2805d1b75cc2bbb00b53c484e62d3d3ec4f2a16a4b572c7f2320b5a2d04addd4903aa0d8708dfdbdf9da7325edae1959eb39f7553d06f
data/README.md CHANGED
@@ -18,7 +18,7 @@ as `trellis/pg`. The module is still `Trellis`.
18
18
 
19
19
  ```ruby
20
20
  # Gemfile
21
- gem "trellis-pg", "~> 0.1.0"
21
+ gem "trellis-pg", "~> 0.2.0"
22
22
  ```
23
23
 
24
24
  `Bundler.require`, which a Rails app's `config/application.rb` runs,
@@ -78,10 +78,38 @@ Nothing in the binding uses it.
78
78
  `table`, `columns`, `role` (`:group_by`, `:join_from` or `:join_joined`: the part of
79
79
  the definition that needs it), `opted_out` and a `create index` statement as `fix`);
80
80
  `self_check` refuses such a target unless it was defined `WITHOUT SOURCE
81
- INDEX`. `status` lists the partitions of a partitioned source a definition
81
+ INDEX`. `status` lists the source indexes a `MIN`/`MAX` aggregate's probes
82
+ read and the source lacks (`extreme_index_gaps`, each a
83
+ `Trellis::ExtremeIndexGap` with `table`, `columns`, `argument`, `opted_out`
84
+ and `fix`); nothing pauses, and a deleted extreme is re-read by scanning
85
+ its group. `status` lists the partitions of a partitioned source a definition
82
86
  is being corrected for while it reads `:backfilling` for them
83
87
  (`correcting`, each a `Trellis::CorrectingNode` with `node`, `since` and
84
- `keys_remaining`).
88
+ `keys_remaining`). `status` names what a Re-derive build waits on instead
89
+ of its own work (`build_wait_xid`, the transaction id of the fence it
90
+ waits out, `nil` when it waits on nothing). While a process of another
91
+ Trellis version is live, `status` reports that this process stands by
92
+ (`version_handover`, a `Trellis::VersionHandover` with `this_version`,
93
+ `processes`, each a `Trellis::OtherProcess` with `worker_id` and
94
+ `version` (`nil` for a release before 0.2.0),
95
+ `unregistered_staging_worker` and `migrations_pending`), and
96
+ state-changing calls raise a `version_handover` error until it is gone.
97
+ While this binary is below the catalog floor, `status` reports that this
98
+ process stands by (`below_catalog_floor`, a `Trellis::BelowCatalogFloor`
99
+ with `floor` and `this_version`), and state-changing calls raise a
100
+ `version_floor` error.
101
+ For a `JOIN` definition `status` reports its hidden tables (`join`, a
102
+ `Trellis::JoinStatus`): `hidden_tables`, each a `Trellis::HiddenTable` with
103
+ `table`, `name`, `estimated_rows` and `bytes`, the `P`-size warning
104
+ (`partials_warning`, a `Trellis::PartialsWarning`), a 1-1 `JOIN`'s fan-out
105
+ warnings (`fan_out_warnings`, one `Trellis::FanOutWarning` per joined
106
+ position past the threshold, naming its `position`) and the step its build
107
+ runs (`building`, a `Trellis::JoinBuildStep` with `step` and the `side`,
108
+ `part`, `leaf`, `remaining` or `position` its kind of step carries). `join` is
109
+ `nil` for any other definition. `status` reports the oldest Trellis release
110
+ that can run a definition (`floor`, `"major.minor.patch"`), which an
111
+ application reads before rolling back to an earlier Trellis release
112
+ ([upgrading](../../docs/upgrading.md#rolling-back)).
85
113
  - `Trellis.request_backfill(source_table)`,
86
114
  `Trellis.detect_capture_disruptions` (pauses now the readers of every table
87
115
  whose Trellis capture was changed outside Trellis, and returns the tables),
@@ -161,7 +189,7 @@ one stand by; that is harmless, and each polls the lock every 5 seconds (see
161
189
  At the defaults, a handle holds up to 20 Postgres connections (its call pool,
162
190
  opened as calls need them and closed after 60 seconds idle) and 3 threads. A
163
191
  worker handle (`staging: true, drain_threads: N`) also owns a background pool
164
- of up to `2N + 5` connections (`2N + 4` without staging, `1` with staging
192
+ of up to `2N + 6` connections (`2N + 5` without staging, `2` with staging
165
193
  alone), closed after 60 seconds idle, and two connections that stay open: the
166
194
  staging session and one `LISTEN` shared by its drain threads. It holds 6
167
195
  threads: drain threads are tasks on its runtime, not OS threads. Its memory is
@@ -262,7 +290,9 @@ default one included.
262
290
  ```
263
291
  - **Errors** are raised as a subclass of `Trellis::Error` per error code
264
292
  (`ParseError`, `ValidationError`, `ConnectivityError`, `ConflictError`,
265
- `NotFoundError`, `InternalError`, `TimeoutError`), each with `#code` (a
293
+ `NotFoundError`, `InternalError`, `TimeoutError`, `VersionHandoverError`,
294
+ `VersionFloorError`),
295
+ each with `#code` (a
266
296
  symbol) and `#message`. A code newer than the binding arrives as
267
297
  `UnknownError`, naming the code in its message. `ForkedHandleError` is a
268
298
  `ValidationError`. Raising is Ruby's idiom, so where the Elixir binding
@@ -493,9 +523,8 @@ true, drain_threads: 1`), because the resume rebuilds through them.
493
523
  A Rails app that uses Trellis dumps its schema as `structure.sql`
494
524
  (`config.active_record.schema_format = :sql`), and the dump carries Trellis's
495
525
  definitions, the way it carries `schema_migrations`. `schema.rb` can't carry
496
- another schema's tables or triggers, so with `schema_format = :ruby` the dump
497
- (`db:migrate`'s and `db:schema:dump`) raises `Trellis::ValidationError` and
498
- writes nothing.
526
+ them, so with `schema_format = :ruby` the dump (`db:migrate`'s and
527
+ `db:schema:dump`) raises `Trellis::ValidationError` and writes nothing.
499
528
 
500
529
  ```ruby
501
530
  # config/application.rb
@@ -503,49 +532,65 @@ config.active_record.schema_format = :sql
503
532
  config.trellis.connect = { url: ENV.fetch("DATABASE_URL") }
504
533
  ```
505
534
 
506
- Nothing else is set up. After `pg_dump`, `db:migrate`'s dump and
507
- `db:schema:dump` append `Trellis::Instance#dump_definitions_sql` for
508
- `config.trellis.connect` and each of `config.trellis.instances`: Trellis's
509
- migration history, the instance marker, the transform
510
- definitions and the seed rows capture needs. The Railtie also adds each
511
- instance's `schema` and `target_schema` to the `--schema` flags Rails passes
512
- `pg_dump` (nothing is added when Rails dumps every schema), so the Trellis
513
- tables, the target tables and the capture triggers are in the file.
514
- `db:schema:load`, `db:prepare`, `db:setup` and the test database
515
- `maintain_test_schema!` loads then build a database with every definition in
516
- place. Each loads as `waiting_to_backfill`, and the worker builds it from the
517
- source rows; a test database has none, so that is immediate. Runtime state
518
- (ledger, ring, build chunks, quarantine, claims, statuses) is never dumped.
519
-
520
- - The hook applies to one database config, `config.trellis.dump_database`
535
+ Nothing else is set up. The file has the application's tables, the target
536
+ tables (with their indexes and grants), and one row per definition: its
537
+ source, target and `JOIN` table, its text as written, its grammar version
538
+ and its floor. Trellis's own schema, the capture triggers on source tables
539
+ and the 1-1 targets' ledgers are not in it, so regenerating the dump changes
540
+ it only where a definition changed. `db:schema:load`, `db:prepare`,
541
+ `db:setup` and the test database `maintain_test_schema!` loads then run
542
+ `migrate` for `config.trellis.connect` and each of `config.trellis.instances`,
543
+ which creates Trellis's tables and restores each definition into its dumped
544
+ target. Each is restored unbuilt, and the worker builds it from the source
545
+ rows; a test database has none, so that is immediate. A file loaded another
546
+ way (`psql -f structure.sql`) needs `bin/rails trellis:migrate` after it.
547
+ How the restore works, and what it refuses, is in
548
+ [`docs/embedding.md`](../../docs/embedding.md#schema-dumps).
549
+
550
+ - The hooks apply to one database config, `config.trellis.dump_database`
521
551
  (default `"primary"`): the one `config.trellis.connect` and
522
- `config.trellis.instances` point at. Other databases dump as Rails dumps
523
- them. The dump raises `Trellis::ValidationError`, writing nothing, when
524
- `config.trellis.connect` or an instance points at a different database than
525
- that config. They are compared by host, port and database name: the Rails
526
- config's as libpq resolves them (`PGHOST`, `PGPORT` and `PGDATABASE`
527
- included), Trellis's `url` from the url alone (port 5432, database the
528
- user's name). A config with no host uses libpq's local socket, which
529
- matches a local Trellis host (`localhost`, `127.0.0.1`, `::1` or a socket
530
- directory). Other host spellings are not equated, so `localhost` is not
531
- `127.0.0.1`. `db:prepare` in development also dumps the test database
532
- after migrating it; that dump is checked against development's config of
533
- the same name, which `config.trellis.connect` points at.
534
- - `pg_dump` and `dump_definitions_sql` read separate snapshots. The hook reads
535
- the definitions before and after the `pg_dump` and redoes the dump when they
536
- differ (three attempts, then `Trellis::ConflictError`), so a define or drop
537
- that races a dump can't leave tables and definitions disagreeing.
552
+ `config.trellis.instances` point at. Other databases dump and load as Rails
553
+ does it. The dump and the load raise `Trellis::ValidationError`, the dump
554
+ writing nothing, when `config.trellis.connect` or an instance points at a
555
+ different database than that config. They are compared by host, port and
556
+ database name: the Rails config's as libpq resolves them (`PGHOST`,
557
+ `PGPORT` and `PGDATABASE` included), Trellis's `url` from the url alone
558
+ (port 5432, database the user's name). A config with no host uses libpq's
559
+ local socket, which matches a local Trellis host (`localhost`,
560
+ `127.0.0.1`, `::1` or a socket directory). Other host spellings are not
561
+ equated, so `localhost` is not `127.0.0.1`.
562
+ - `db:prepare` in development also dumps and loads the test database, and
563
+ `db:test:prepare` loads it. That dump is checked against development's
564
+ config of the same name, which `config.trellis.connect` points at, and the
565
+ load's restore connects to the test database: the configured options with
566
+ the test config's database, and its host, port, user, password and other
567
+ libpq keywords (`sslmode`, `sslrootcert`, ...) where it names a non-blank
568
+ value (its `url` when it has one).
569
+ - The hook adds each instance's exclusions (its Trellis schema, its ledgers)
570
+ to the flags Rails passes `pg_dump`, and its `target_schema` to the
571
+ `--schema` flags when Rails passes any. `pg_dump` can't leave out one
572
+ trigger, so the hook takes the capture triggers out of its output
573
+ (`Trellis::Instance#filter_schema_dump`).
574
+ - `pg_dump` and `Trellis::Instance#schema_dump` read separate snapshots. The
575
+ hook reads the definitions before and after the `pg_dump` and redoes the
576
+ dump when they differ (three attempts, then `Trellis::ConflictError`), so a
577
+ define or drop that races a dump can't leave tables and definitions
578
+ disagreeing. A dump that fails once `pg_dump` has written `structure.sql`
579
+ removes it, since what Rails wrote has no definitions and would load
580
+ without a word; a file no `pg_dump` wrote to is left as it was.
538
581
  - A definition that was paused or quarantined where the dump was taken loads
539
- as `waiting_to_backfill`: the pause is runtime state.
582
+ unpaused: the pause is runtime state.
583
+ - A `structure.sql` written by an earlier release, which carries Trellis's
584
+ schema and its definitions' catalog rows, still loads as it did.
540
585
  - `pg_dump` 17 writes `CREATE SCHEMA public`, which a database made by
541
586
  `db:create` already has; the hook writes `CREATE SCHEMA IF NOT EXISTS
542
587
  public` instead.
543
588
  - The dump connects through `Trellis::Railtie.with_handle`, so it migrates
544
589
  Trellis's tables first if the database lacks them, and starts no worker.
545
590
  - It hooks `ActiveRecord::Tasks::DatabaseTasks.dump_schema` and
546
- `PostgreSQLDatabaseTasks#structure_dump`, which Rails marks `:nodoc:` and
547
- offers no extension point for. A Rails upgrade that renames them fails the
548
- test suite's round trip (`test/schema_dump_test.rb`).
591
+ `.load_schema`, and `PostgreSQLDatabaseTasks#structure_dump`, which Rails
592
+ marks `:nodoc:` and offers no extension point for. A Rails upgrade that
593
+ renames them fails the test suite's round trip (`test/schema_dump_test.rb`).
549
594
 
550
595
  ## Layout
551
596
 
@@ -1729,8 +1729,8 @@ dependencies = [
1729
1729
 
1730
1730
  [[package]]
1731
1731
  name = "trellis"
1732
- version = "0.1.1"
1733
- source = "git+https://github.com/salesforce-misc/trellis?rev=0e13fef8a731b23ae4650fd040eb1d0f93c01c44#0e13fef8a731b23ae4650fd040eb1d0f93c01c44"
1732
+ version = "0.2.0"
1733
+ source = "git+https://github.com/salesforce-misc/trellis?rev=f2afc74f5ee0a6ac309051da8678e0b2d862892e#f2afc74f5ee0a6ac309051da8678e0b2d862892e"
1734
1734
  dependencies = [
1735
1735
  "deadpool",
1736
1736
  "deadpool-postgres",
@@ -1739,7 +1739,6 @@ dependencies = [
1739
1739
  "metrics-exporter-prometheus",
1740
1740
  "metrics-util",
1741
1741
  "refinery",
1742
- "regex",
1743
1742
  "rustls",
1744
1743
  "serde",
1745
1744
  "serde_json",
@@ -1752,8 +1751,8 @@ dependencies = [
1752
1751
 
1753
1752
  [[package]]
1754
1753
  name = "trellis-embed"
1755
- version = "0.1.1"
1756
- source = "git+https://github.com/salesforce-misc/trellis?rev=0e13fef8a731b23ae4650fd040eb1d0f93c01c44#0e13fef8a731b23ae4650fd040eb1d0f93c01c44"
1754
+ version = "0.2.0"
1755
+ source = "git+https://github.com/salesforce-misc/trellis?rev=f2afc74f5ee0a6ac309051da8678e0b2d862892e#f2afc74f5ee0a6ac309051da8678e0b2d862892e"
1757
1756
  dependencies = [
1758
1757
  "tracing",
1759
1758
  "trellis",
@@ -1761,7 +1760,7 @@ dependencies = [
1761
1760
 
1762
1761
  [[package]]
1763
1762
  name = "trellis_ruby"
1764
- version = "0.1.1"
1763
+ version = "0.2.0"
1765
1764
  dependencies = [
1766
1765
  "magnus",
1767
1766
  "rb-sys",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "trellis_ruby"
3
- version = "0.1.1"
3
+ version = "0.2.0"
4
4
  edition = "2024"
5
5
  publish = false
6
6
 
@@ -28,8 +28,8 @@ doctest = false
28
28
  ruby = ["dep:magnus", "dep:rb-sys"]
29
29
 
30
30
  [dependencies]
31
- trellis = { git = "https://github.com/salesforce-misc/trellis", rev = "0e13fef8a731b23ae4650fd040eb1d0f93c01c44" }
32
- trellis-embed = { git = "https://github.com/salesforce-misc/trellis", rev = "0e13fef8a731b23ae4650fd040eb1d0f93c01c44" }
31
+ trellis = { git = "https://github.com/salesforce-misc/trellis", rev = "f2afc74f5ee0a6ac309051da8678e0b2d862892e" }
32
+ trellis-embed = { git = "https://github.com/salesforce-misc/trellis", rev = "f2afc74f5ee0a6ac309051da8678e0b2d862892e" }
33
33
  magnus = { version = "0.9", optional = true, default-features = false, features = ["rb-sys"] }
34
34
  rb-sys = { version = "0.9", optional = true }
35
35
 
@@ -61,14 +61,17 @@ use magnus::{
61
61
  };
62
62
  use trellis::{BlockingTrellis, Config, ErrorCode, PendingCall, TrellisOptions};
63
63
  use trellis_embed::{
64
- DIVERGENCE_KINDS, ERROR_CODES, PlainApplied, PlainBackfillFailure, PlainCaptureFailure,
65
- PlainCaptureWait, PlainConfig, PlainCorrectingNode, PlainDefinitionStatus,
66
- PlainDefinitionSummary, PlainDivergence, PlainDrainFailure, PlainError, PlainHeldKeys,
67
- PlainPoisonEntry, PlainQuarantineEntry, PlainSamplePage, PlainSelfCheckJob,
68
- PlainSelfCheckReport, PlainSourceIndexGap, SELF_CHECK_MODES, SELF_CHECK_OUTCOMES,
69
- SELF_CHECK_STATES, capture_failure_kind_names, decode_cursor, decode_watermark,
70
- encode_watermark, index_role_names, quarantine_state_names, require_transform_statement,
71
- self_check_mode, system_time_from_epoch_micros, transform_status_names,
64
+ DIVERGENCE_KINDS, ERROR_CODES, JOIN_BUILD_STEPS, JOIN_SIDE_PARTS, JOIN_SIDES, PlainApplied,
65
+ PlainBackfillFailure, PlainBelowCatalogFloor, PlainCaptureFailure, PlainCaptureWait,
66
+ PlainConfig, PlainCorrectingNode, PlainDefinitionStatus, PlainDefinitionSummary,
67
+ PlainDivergence, PlainDrainFailure, PlainError, PlainExtremeIndexGap, PlainFanOutWarning,
68
+ PlainHeldKeys, PlainHiddenTable, PlainJoinBuildStep, PlainJoinStatus, PlainOtherProcess,
69
+ PlainPartialsWarning, PlainPoisonEntry, PlainQuarantineEntry, PlainSamplePage,
70
+ PlainSelfCheckJob, PlainSelfCheckReport, PlainSourceIndexGap, PlainVersionHandover,
71
+ SELF_CHECK_MODES, SELF_CHECK_OUTCOMES, SELF_CHECK_STATES, capture_failure_kind_names,
72
+ decode_cursor, decode_watermark, encode_watermark, index_role_names, quarantine_state_names,
73
+ require_transform_statement, self_check_mode, system_time_from_epoch_micros,
74
+ transform_status_names,
72
75
  };
73
76
 
74
77
  /// What a blocking call produces: its value, or the engine's error as plain
@@ -234,6 +237,29 @@ impl Handle {
234
237
  rb_self.call(ruby, BlockingTrellis::start_dump_definitions_sql)
235
238
  }
236
239
 
240
+ /// What a schema dump of the database needs from this instance
241
+ /// ([`BlockingTrellis::schema_dump`]): `[pg_dump_args, appendix]`, the
242
+ /// arguments that leave Trellis's own tables out of `pg_dump` and the
243
+ /// SQL to append after its filtered output.
244
+ fn schema_dump(ruby: &Ruby, rb_self: &Self) -> Result<RArray, Error> {
245
+ let dump = rb_self.call(ruby, BlockingTrellis::start_schema_dump)?;
246
+ let args = ruby.ary_from_iter(dump.pg_dump_args);
247
+ Ok(ruby.ary_new_from_values(&[args.as_value(), ruby.str_new(&dump.appendix).as_value()]))
248
+ }
249
+
250
+ /// `pg_dump_output` without this instance's capture triggers
251
+ /// ([`BlockingTrellis::filter_schema_dump`]). Reads nothing from the
252
+ /// database.
253
+ fn filter_schema_dump(
254
+ ruby: &Ruby,
255
+ rb_self: &Self,
256
+ pg_dump_output: String,
257
+ ) -> Result<String, Error> {
258
+ rb_self
259
+ .with_engine(ruby, |trellis| trellis.filter_schema_dump(&pg_dump_output))?
260
+ .map_err(|err| raise(ruby, err.into()))
261
+ }
262
+
237
263
  /// Rebuilds every applying definition that reads `source_table`
238
264
  /// (`live -> backfilling`).
239
265
  fn request_backfill(ruby: &Ruby, rb_self: &Self, source_table: String) -> Result<(), Error> {
@@ -835,6 +861,9 @@ fn symbol_words() -> impl Iterator<Item = &'static str> {
835
861
  .chain(DIVERGENCE_KINDS)
836
862
  .chain(capture_failure_kind_names())
837
863
  .chain(index_role_names())
864
+ .chain(JOIN_BUILD_STEPS)
865
+ .chain(JOIN_SIDES)
866
+ .chain(JOIN_SIDE_PARTS)
838
867
  }
839
868
 
840
869
  /// A hash from symbol keys to `fields`' values: the shape every record
@@ -906,8 +935,21 @@ fn status_hash(ruby: &Ruby, status: PlainDefinitionStatus) -> Result<RHash, Erro
906
935
  .map(|failure| drain_failure_hash(ruby, failure))
907
936
  .transpose()?;
908
937
  let source_index_gaps = array(ruby, status.source_index_gaps, source_index_gap_hash)?;
938
+ let extreme_index_gaps = array(ruby, status.extreme_index_gaps, extreme_index_gap_hash)?;
909
939
  let correcting = array(ruby, status.correcting, correcting_hash)?;
910
940
  let paused_upstream_columns = ruby.ary_from_iter(status.paused_upstream_columns);
941
+ let version_handover = status
942
+ .version_handover
943
+ .map(|seen| version_handover_hash(ruby, seen))
944
+ .transpose()?;
945
+ let below_catalog_floor = status
946
+ .below_catalog_floor
947
+ .map(|seen| below_catalog_floor_hash(ruby, seen))
948
+ .transpose()?;
949
+ let join = status
950
+ .join
951
+ .map(|join| join_status_hash(ruby, join))
952
+ .transpose()?;
911
953
  record(
912
954
  ruby,
913
955
  [
@@ -918,11 +960,142 @@ fn status_hash(ruby: &Ruby, status: PlainDefinitionStatus) -> Result<RHash, Erro
918
960
  ("held_keys", ruby.into_value(held_keys)),
919
961
  ("drain_failure", ruby.into_value(drain_failure)),
920
962
  ("source_index_gaps", source_index_gaps.as_value()),
963
+ ("extreme_index_gaps", extreme_index_gaps.as_value()),
921
964
  ("correcting", correcting.as_value()),
922
965
  (
923
966
  "paused_upstream_columns",
924
967
  paused_upstream_columns.as_value(),
925
968
  ),
969
+ ("build_wait_xid", ruby.into_value(status.build_wait_xid)),
970
+ ("version_handover", ruby.into_value(version_handover)),
971
+ ("below_catalog_floor", ruby.into_value(below_catalog_floor)),
972
+ ("join", ruby.into_value(join)),
973
+ ("floor", ruby.into_value(status.floor)),
974
+ ],
975
+ )
976
+ }
977
+
978
+ /// A `JOIN` transform's hidden tables, warnings and build step.
979
+ fn join_status_hash(ruby: &Ruby, join: PlainJoinStatus) -> Result<RHash, Error> {
980
+ let hidden_tables = array(ruby, join.hidden_tables, hidden_table_hash)?;
981
+ let partials_warning = join
982
+ .partials_warning
983
+ .map(|warning| partials_warning_hash(ruby, warning))
984
+ .transpose()?;
985
+ let fan_out_warnings = array(ruby, join.fan_out_warnings, fan_out_warning_hash)?;
986
+ let building = join
987
+ .building
988
+ .map(|step| join_build_step_hash(ruby, step))
989
+ .transpose()?;
990
+ record(
991
+ ruby,
992
+ [
993
+ ("hidden_tables", hidden_tables.as_value()),
994
+ ("partials_warning", ruby.into_value(partials_warning)),
995
+ ("fan_out_warnings", fan_out_warnings.as_value()),
996
+ ("building", ruby.into_value(building)),
997
+ ],
998
+ )
999
+ }
1000
+
1001
+ /// One hidden table of a `JOIN`.
1002
+ fn hidden_table_hash(ruby: &Ruby, table: PlainHiddenTable) -> Result<RHash, Error> {
1003
+ record(
1004
+ ruby,
1005
+ [
1006
+ ("table", ruby.into_value(table.table)),
1007
+ ("name", ruby.into_value(table.name)),
1008
+ ("estimated_rows", ruby.into_value(table.estimated_rows)),
1009
+ ("bytes", ruby.into_value(table.bytes)),
1010
+ ],
1011
+ )
1012
+ }
1013
+
1014
+ /// `P` has grown close to the fact table's size.
1015
+ fn partials_warning_hash(ruby: &Ruby, warning: PlainPartialsWarning) -> Result<RHash, Error> {
1016
+ record(
1017
+ ruby,
1018
+ [
1019
+ ("partials_rows", ruby.into_value(warning.partials_rows)),
1020
+ ("fact_rows", ruby.into_value(warning.fact_rows)),
1021
+ ],
1022
+ )
1023
+ }
1024
+
1025
+ /// A 1-1 `JOIN`'s very large group on one join key at one joined position.
1026
+ fn fan_out_warning_hash(ruby: &Ruby, warning: PlainFanOutWarning) -> Result<RHash, Error> {
1027
+ record(
1028
+ ruby,
1029
+ [
1030
+ ("position", ruby.into_value(warning.position)),
1031
+ ("key", ruby.into_value(warning.key)),
1032
+ ("rows", ruby.into_value(warning.rows)),
1033
+ ("threshold", ruby.into_value(warning.threshold)),
1034
+ ],
1035
+ )
1036
+ }
1037
+
1038
+ /// The step a `JOIN` build runs. `step`, `side` and `part` are symbols from
1039
+ /// [`JOIN_BUILD_STEPS`], [`JOIN_SIDES`] and [`JOIN_SIDE_PARTS`]; each of the
1040
+ /// others is set only for the step that carries it.
1041
+ fn join_build_step_hash(ruby: &Ruby, step: PlainJoinBuildStep) -> Result<RHash, Error> {
1042
+ record(
1043
+ ruby,
1044
+ [
1045
+ ("step", word_symbol(ruby, step.step).as_value()),
1046
+ (
1047
+ "side",
1048
+ ruby.into_value(step.side.map(|word| word_symbol(ruby, word))),
1049
+ ),
1050
+ (
1051
+ "part",
1052
+ ruby.into_value(step.part.map(|word| word_symbol(ruby, word))),
1053
+ ),
1054
+ ("leaf", ruby.into_value(step.leaf)),
1055
+ ("remaining", ruby.into_value(step.remaining)),
1056
+ ("position", ruby.into_value(step.position)),
1057
+ ],
1058
+ )
1059
+ }
1060
+
1061
+ /// Another Trellis version is live, so this binary stands by.
1062
+ fn version_handover_hash(ruby: &Ruby, seen: PlainVersionHandover) -> Result<RHash, Error> {
1063
+ let processes = array(ruby, seen.processes, other_process_hash)?;
1064
+ record(
1065
+ ruby,
1066
+ [
1067
+ ("this_version", ruby.into_value(seen.this_version)),
1068
+ ("processes", processes.as_value()),
1069
+ (
1070
+ "unregistered_staging_worker",
1071
+ ruby.into_value(seen.unregistered_staging_worker),
1072
+ ),
1073
+ (
1074
+ "migrations_pending",
1075
+ ruby.into_value(seen.migrations_pending),
1076
+ ),
1077
+ ],
1078
+ )
1079
+ }
1080
+
1081
+ /// This binary is below the catalog floor, so it stands by.
1082
+ fn below_catalog_floor_hash(ruby: &Ruby, seen: PlainBelowCatalogFloor) -> Result<RHash, Error> {
1083
+ record(
1084
+ ruby,
1085
+ [
1086
+ ("floor", ruby.into_value(seen.floor)),
1087
+ ("this_version", ruby.into_value(seen.this_version)),
1088
+ ],
1089
+ )
1090
+ }
1091
+
1092
+ /// A live process of another version.
1093
+ fn other_process_hash(ruby: &Ruby, process: PlainOtherProcess) -> Result<RHash, Error> {
1094
+ record(
1095
+ ruby,
1096
+ [
1097
+ ("worker_id", ruby.into_value(process.worker_id)),
1098
+ ("version", ruby.into_value(process.version)),
926
1099
  ],
927
1100
  )
928
1101
  }
@@ -953,6 +1126,20 @@ fn source_index_gap_hash(ruby: &Ruby, gap: PlainSourceIndexGap) -> Result<RHash,
953
1126
  )
954
1127
  }
955
1128
 
1129
+ /// A source index a `MIN`/`MAX` field's probe reads and the source lacks.
1130
+ fn extreme_index_gap_hash(ruby: &Ruby, gap: PlainExtremeIndexGap) -> Result<RHash, Error> {
1131
+ record(
1132
+ ruby,
1133
+ [
1134
+ ("table", ruby.into_value(gap.table)),
1135
+ ("columns", ruby.into_value(gap.columns)),
1136
+ ("argument", ruby.into_value(gap.argument)),
1137
+ ("opted_out", ruby.into_value(gap.opted_out)),
1138
+ ("fix", ruby.into_value(gap.fix)),
1139
+ ],
1140
+ )
1141
+ }
1142
+
956
1143
  /// A drain page that keeps failing with nothing charged or paused.
957
1144
  fn drain_failure_hash(ruby: &Ruby, failure: PlainDrainFailure) -> Result<RHash, Error> {
958
1145
  record(
@@ -1222,6 +1409,21 @@ fn index_roles(ruby: &Ruby) -> RArray {
1222
1409
  word_symbols(ruby, index_role_names())
1223
1410
  }
1224
1411
 
1412
+ /// Every step symbol a `JOIN` build's status can carry.
1413
+ fn join_build_steps(ruby: &Ruby) -> RArray {
1414
+ word_symbols(ruby, JOIN_BUILD_STEPS)
1415
+ }
1416
+
1417
+ /// Every side symbol a `JOIN` build step can carry.
1418
+ fn join_sides(ruby: &Ruby) -> RArray {
1419
+ word_symbols(ruby, JOIN_SIDES)
1420
+ }
1421
+
1422
+ /// Every part symbol a `JOIN` build's side step can carry.
1423
+ fn join_side_parts(ruby: &Ruby) -> RArray {
1424
+ word_symbols(ruby, JOIN_SIDE_PARTS)
1425
+ }
1426
+
1225
1427
  /// Every statement kind `trellis`'s grammar has, for the test asserting
1226
1428
  /// `apply`'s round trip covers each one.
1227
1429
  fn statement_kinds() -> Vec<&'static str> {
@@ -1265,6 +1467,9 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
1265
1467
  native.define_module_function("divergence_kinds", function!(divergence_kinds, 0))?;
1266
1468
  native.define_module_function("capture_failure_kinds", function!(capture_failure_kinds, 0))?;
1267
1469
  native.define_module_function("index_roles", function!(index_roles, 0))?;
1470
+ native.define_module_function("join_build_steps", function!(join_build_steps, 0))?;
1471
+ native.define_module_function("join_sides", function!(join_sides, 0))?;
1472
+ native.define_module_function("join_side_parts", function!(join_side_parts, 0))?;
1268
1473
  native.define_module_function("statement_kinds", function!(statement_kinds, 0))?;
1269
1474
  native.define_module_function("render_prometheus", function!(render_prometheus, 0))?;
1270
1475
 
@@ -1279,6 +1484,8 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
1279
1484
  "dump_definitions_sql",
1280
1485
  method!(Handle::dump_definitions_sql, 0),
1281
1486
  )?;
1487
+ handle.define_method("schema_dump", method!(Handle::schema_dump, 0))?;
1488
+ handle.define_method("filter_schema_dump", method!(Handle::filter_schema_dump, 1))?;
1282
1489
  handle.define_method("request_backfill", method!(Handle::request_backfill, 1))?;
1283
1490
  handle.define_method("release_key", method!(Handle::release_key, 3))?;
1284
1491
  handle.define_method("poisoned_since", method!(Handle::poisoned_since, 1))?;
data/lib/trellis/error.rb CHANGED
@@ -14,7 +14,8 @@ module Trellis
14
14
  CODE = nil
15
15
 
16
16
  # One of :parse, :validation, :connectivity, :conflict, :not_found,
17
- # :internal, :timeout, or :unknown for a code newer than this binding.
17
+ # :internal, :timeout, :version_handover, :version_floor, or :unknown for
18
+ # a code newer than this binding.
18
19
  def code
19
20
  self.class::CODE
20
21
  end
@@ -62,12 +63,33 @@ module Trellis
62
63
 
63
64
  # A bounded wait ran out of time, such as Trellis.await_converged's
64
65
  # 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
+ # table, or a statement waited out the 30-second lock timeout behind another
67
+ # transaction's lock (a define, ALTER or DROP behind a drain page, say), or a
68
+ # call ran out of its 30-second deadline (the server stopped
66
69
  # what the call started and rolled it back). Expected, not a bug: retry it.
67
70
  class TimeoutError < Error
68
71
  CODE = :timeout
69
72
  end
70
73
 
74
+ # A Trellis process of another version is live on the instance, so this
75
+ # call changed nothing: a state-changing call, or Trellis.migrate with
76
+ # migrations pending, during a deploy that crosses a version boundary.
77
+ # Read-only calls keep working. Retry once the other version's processes
78
+ # have stopped; the message names the versions and their worker ids.
79
+ class VersionHandoverError < Error
80
+ CODE = :version_handover
81
+ end
82
+
83
+ # This Trellis version is below a compatibility floor the database
84
+ # records: the catalog floor, the oldest version that can read the
85
+ # instance's catalog, so Trellis.migrate or a state-changing call refused
86
+ # it; or a definition's floor, the oldest version that can run it, so DROP
87
+ # TRANSFORM refused it and changed nothing. Not retryable with this
88
+ # version: run the version the message names, or a later one.
89
+ class VersionFloorError < Error
90
+ CODE = :version_floor
91
+ end
92
+
71
93
  # An error code newer than this binding. The message names the code.
72
94
  class UnknownError < Error
73
95
  CODE = :unknown
@@ -97,7 +119,9 @@ module Trellis
97
119
  "conflict" => ConflictError,
98
120
  "not_found" => NotFoundError,
99
121
  "internal" => InternalError,
100
- "timeout" => TimeoutError
122
+ "timeout" => TimeoutError,
123
+ "version_handover" => VersionHandoverError,
124
+ "version_floor" => VersionFloorError
101
125
  }.freeze
102
126
  end
103
127
  end
@@ -65,7 +65,8 @@ module Trellis
65
65
  # TRANSFORM) and reports what it did
66
66
  # as an Applied. define is the one guarded convenience: an apply that
67
67
  # refuses anything but a TRANSFORM statement before applying it.
68
- # - Reads: status, definitions, dump_definitions_sql.
68
+ # - Reads: status, definitions, schema_dump, filter_schema_dump,
69
+ # dump_definitions_sql.
69
70
  # - Quarantine: quarantined, quarantine_status, sample_quarantined,
70
71
  # poisoned_since, release_key. Pausing and resuming are statements:
71
72
  # instance.apply("RESUME TRANSFORM order_totals.total").
@@ -336,6 +337,35 @@ module Trellis
336
337
  handle.dump_definitions_sql
337
338
  end
338
339
 
340
+ # What a schema dump of this instance's database needs, as a frozen Hash:
341
+ # :pg_dump_args, the arguments (each one argument, not shell words) that
342
+ # leave Trellis's own schema and the 1-1 targets' ledgers out of
343
+ # `pg_dump`, and :appendix, the SQL to append after the dump. Run
344
+ # `pg_dump --schema-only` with pg_dump_args (and the target schema among
345
+ # the dump's `--schema`s, if it names any), pass the output through
346
+ # filter_schema_dump, and append the appendix. Both are the same for the
347
+ # same definitions, whatever their history. A database loaded from the file has the application's
348
+ # schema and the target tables; `migrate` restores every definition into
349
+ # its target, each unbuilt, and the worker builds them. Trellis's own
350
+ # schema, the capture triggers and the ledgers are not in the dump.
351
+ #
352
+ # Read from one snapshot, a different one from the pg_dump's: the Rails
353
+ # hook (Trellis::SchemaDump) reads it on both sides of the pg_dump and
354
+ # retries when the two differ.
355
+ def schema_dump
356
+ pg_dump_args, appendix = handle.schema_dump
357
+ { pg_dump_args: pg_dump_args.freeze, appendix: appendix.freeze }.freeze
358
+ end
359
+
360
+ # pg_dump_output, the output of a `pg_dump` run with schema_dump's
361
+ # pg_dump_args, without this instance's capture triggers on its source
362
+ # tables, which `pg_dump` can't leave out by itself. Reads nothing from
363
+ # the database. Raises Trellis::InternalError if a line still runs one of
364
+ # Trellis's functions afterwards: the dump wouldn't load.
365
+ def filter_schema_dump(pg_dump_output)
366
+ handle.filter_schema_dump(pg_dump_output)
367
+ end
368
+
339
369
  # Rebuilds every definition that reads source_table (a bare table name,
340
370
  # resolved like one in a statement), and returns once that rebuild is
341
371
  # queued.
data/lib/trellis/pg.rb CHANGED
@@ -43,7 +43,8 @@ require_relative "metrics"
43
43
  #
44
44
  # - Lifecycle: connect, connected?, migrate, config, shutdown.
45
45
  # - Every statement form: apply, and define for a TRANSFORM statement alone.
46
- # - Reads: status, definitions, dump_definitions_sql.
46
+ # - Reads: status, definitions, schema_dump, filter_schema_dump,
47
+ # dump_definitions_sql.
47
48
  # - Quarantine: quarantined, quarantine_status, sample_quarantined,
48
49
  # poisoned_since, release_key.
49
50
  # - Operations: request_backfill, detect_capture_disruptions, has_live_drain_workers?,
@@ -2,46 +2,62 @@
2
2
 
3
3
  require "active_record"
4
4
  require "etc"
5
+ require "fileutils"
5
6
  require "trellis/pg"
6
7
 
7
8
  module Trellis
8
9
  # Makes `structure.sql` carry Trellis's definitions, the way Rails makes it
9
10
  # 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).
11
+ # appends the rows that describe how the schema came to be. The dump has
12
+ # the application's schema and the target tables, and one row per
13
+ # definition (Trellis::Instance#schema_dump). Trellis's own schema, the
14
+ # capture triggers on source tables and the 1-1 targets' ledgers are left
15
+ # out: a database built from the file gets them from the loading release,
16
+ # whose `migrate` restores each definition into its dumped target (ADR-0025).
14
17
  #
15
18
  # 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
19
+ # carry the definitions. With `:ruby`, the dump raises
17
20
  # Trellis::ValidationError and writes nothing: a `schema.rb` would load
18
21
  # into a database with target tables and no definitions.
19
22
  #
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):
23
+ # It hooks the places Rails offers no extension point for:
22
24
  #
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`.)
25
+ # - ActiveRecord::Tasks::DatabaseTasks.dump_schema (the `db:schema:dump`
26
+ # task and the dump after `db:migrate` both call it), whose `:sql`
27
+ # branch is `pg_dump` followed by the `schema_migrations` rows. This
28
+ # hook takes the capture triggers out of the `pg_dump` output
29
+ # (Trellis::Instance#filter_schema_dump) and adds the definitions'
30
+ # rows after the `schema_migrations` ones, for the default instance and
31
+ # each of config.trellis.instances. Those rows are read before and after
32
+ # the `pg_dump`, which reads its own snapshot, and the dump is redone (up
33
+ # to three times) when a define or drop committed in between, so the DDL
34
+ # and the rows agree.
35
+ # - ActiveRecord::Tasks::PostgreSQLDatabaseTasks#structure_dump. This hook
36
+ # adds each instance's `pg_dump_args` (which exclude the Trellis schema
37
+ # and the ledgers) and, when Rails passes `--schema` for the search
38
+ # path's schemas, each instance's `target_schema`.
39
+ # - ActiveRecord::Tasks::DatabaseTasks.load_schema, which
40
+ # `db:schema:load`, `db:prepare` and the test database's
41
+ # `maintain_test_schema!` load the file with. After the load, this hook
42
+ # runs `migrate` for each configured instance, which restores the
43
+ # definitions (a file in the older format, which carries Trellis's
44
+ # schema, needs nothing more and loads as before). A load of another
45
+ # environment's database (`db:prepare` in development prepares the test
46
+ # database too, and `db:test:prepare` the test one) runs it against
47
+ # that database: the instances' options with the database's host, port,
48
+ # name, user and password from its config.
36
49
  #
37
- # Both apply to one database: the config named by
50
+ # A load that doesn't go through Rails (`psql -f structure.sql`) is followed
51
+ # by `rails trellis:migrate`.
52
+ #
53
+ # All three apply to one database: the config named by
38
54
  # `config.trellis.dump_database` (default "primary"), the one
39
55
  # config.trellis.connect and config.trellis.instances point at. The other
40
- # databases of a multi-database app dump as Rails dumps them.
56
+ # databases of a multi-database app dump and load as Rails does it.
41
57
  #
42
58
  # 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.
59
+ # definition loads unbuilt, as a `define` leaves it, including one that
60
+ # was paused or quarantined where the dump was taken.
45
61
  #
46
62
  # `pg_dump` 17 writes `CREATE SCHEMA public` when `public` is dumped,
47
63
  # which fails to load into a database that has one (every database
@@ -54,12 +70,9 @@ module Trellis
54
70
  # socket) matches, besides a socket directory.
55
71
  LOCAL_HOSTS = %w[localhost 127.0.0.1 ::1].freeze
56
72
 
57
- HEADER = <<~SQL
58
- --
59
- -- Trellis definitions (Trellis::Instance#dump_definitions_sql)
60
- --
61
-
62
- SQL
73
+ # The thread-local the dump hook leaves the parts it read before the
74
+ # `pg_dump` in, for the structure-dump hook's arguments.
75
+ PARTS_KEY = :trellis_schema_dump_parts
63
76
 
64
77
  class << self
65
78
  # Installs the hooks. Idempotent.
@@ -68,6 +81,12 @@ module Trellis
68
81
  ::ActiveRecord::Tasks::PostgreSQLDatabaseTasks.prepend(StructureDumpHook)
69
82
  end
70
83
 
84
+ # The schema format of a dump or load of db_config: the format
85
+ # argument args carries, or Rails' default for db_config.
86
+ def format_of(db_config, args)
87
+ args.first || (db_config.respond_to?(:schema_format) ? db_config.schema_format : ::ActiveRecord.schema_format)
88
+ end
89
+
71
90
  # Whether Trellis's definitions belong in the dump of db_config: Trellis
72
91
  # is configured, and db_config is the database config.trellis.dump_database
73
92
  # names.
@@ -122,38 +141,136 @@ module Trellis
122
141
  (app&.config&.trellis&.dump_database || "primary").to_s
123
142
  end
124
143
 
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}" }
144
+ # The flags to add to the pg_dump of db_config, whose connection
145
+ # settings are configuration_hash: each instance's pg_dump_args, and its
146
+ # target schema when Rails names the schemas to dump. parts are the
147
+ # instances' schema_dump Hashes (Trellis::Instance#schema_dump), read now when the dump hook
148
+ # hasn't.
149
+ def schema_flags(db_config, configuration_hash, parts = nil)
150
+ return [] unless applies?(db_config)
151
+
152
+ parts ||= Thread.current[PARTS_KEY] || self.parts
153
+ flags = []
154
+ if restricts_schemas?(configuration_hash)
155
+ flags.concat(configured_options.map { |options| "--schema=#{exact_pattern(options.fetch(:target_schema, "public"))}" })
156
+ end
157
+ flags.concat(parts.flat_map { |part| part.fetch(:pg_dump_args) })
158
+ flags.uniq
159
+ end
160
+
161
+ # schema as a pg_dump pattern that matches exactly it: as is when it is
162
+ # lower case letters, digits and underscores, which pg_dump reads
163
+ # literally, and double-quoted otherwise, so its case and pattern
164
+ # characters (* ? .) are taken literally.
165
+ def exact_pattern(schema)
166
+ schema = schema.to_s
167
+ schema.match?(/\A[a-z_][a-z0-9_]*\z/) ? schema : "\"#{schema.gsub('"', '""')}\""
136
168
  end
137
169
 
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")
170
+ # Each configured instance's schema_dump Hashes (Trellis::Instance#schema_dump): the default
171
+ # instance's first, then config.trellis.instances' in order.
172
+ def parts
173
+ each_instance.map { |name| Railtie.with_handle(name, &:schema_dump) }
145
174
  end
146
175
 
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)
176
+ # The dump at filename without each instance's capture triggers, and
177
+ # its `CREATE SCHEMA public` loadable into a database that has one,
178
+ # followed by each of parts' appendix.
179
+ def finish(filename, parts)
150
180
  sql = File.read(filename, encoding: "utf-8")
151
181
  sql = sql.gsub(/^CREATE SCHEMA public;$/, "CREATE SCHEMA IF NOT EXISTS public;")
152
- File.write(filename, "#{sql}#{HEADER}#{appendix}\n", encoding: "utf-8")
182
+ each_instance.each do |name|
183
+ sql = Railtie.with_handle(name) { |instance| instance.filter_schema_dump(sql) }
184
+ end
185
+ appendix = parts.map { |part| part.fetch(:appendix) }.join("\n")
186
+ File.write(filename, "#{sql}\n#{appendix}", encoding: "utf-8")
187
+ end
188
+
189
+ # Removes what a dump that failed left at filename, unless it is
190
+ # previous (the file's bytes before the dump, nil when there was none):
191
+ # a failure before `pg_dump` wrote to the file leaves it alone.
192
+ def discard_unfinished(filename, previous)
193
+ return unless File.exist?(filename)
194
+ return if previous && File.binread(filename) == previous
195
+
196
+ FileUtils.rm_f(filename)
197
+ end
198
+
199
+ # Restores the definitions a load of db_config's dump queued: `migrate`
200
+ # on each configured instance, in the database db_config names.
201
+ def restore(db_config)
202
+ verify_same_database!(db_config)
203
+ if db_config.env_name.to_s == ::ActiveRecord::Tasks::DatabaseTasks.env.to_s
204
+ Railtie.migrate_all
205
+ return
206
+ end
207
+
208
+ configured = []
209
+ configured << [nil, Railtie.connect_options] if Railtie.connect_options
210
+ Railtie.instance_options.each { |name, options| configured << [name, options] }
211
+ configured.each do |name, options|
212
+ what = name ? "config.trellis.instances[#{name.inspect}]" : "config.trellis.connect"
213
+ url = environment_url(db_config, options[:url], what)
214
+ instance = Trellis::Instance.connect(**options, url:, staging: false, drain_threads: 0)
215
+ begin
216
+ instance.migrate
217
+ ensure
218
+ instance.shutdown
219
+ end
220
+ end
153
221
  end
154
222
 
155
223
  private
156
224
 
225
+ # nil for the default instance (when config.trellis.connect is set),
226
+ # then the name of each of config.trellis.instances.
227
+ def each_instance
228
+ names = []
229
+ names << nil if Railtie.connect_options
230
+ names.concat(Railtie.instance_options.keys)
231
+ end
232
+
233
+ def configured_options
234
+ options = []
235
+ options << Railtie.connect_options if Railtie.connect_options
236
+ options.concat(Railtie.instance_options.values)
237
+ end
238
+
239
+ # A libpq connection string for the database db_config names, in
240
+ # another environment than configured_url's: configured_url's
241
+ # settings with db_config's database, and its host, port, user,
242
+ # password and other libpq keywords (sslmode, sslrootcert, ...) where
243
+ # db_config names them (a host left to libpq's default keeps
244
+ # configured_url's, a local one by verify_same_database!). A db_config
245
+ # with a url is that url.
246
+ def environment_url(db_config, configured_url, what)
247
+ hash = db_config.configuration_hash.transform_keys(&:to_sym)
248
+ return hash[:url].to_s if hash[:url]
249
+
250
+ keywords = conninfo(configured_url, what).reject { |_, value| value.to_s.empty? }
251
+ host, port, dbname = rails_database(hash)
252
+ keywords["dbname"] = dbname
253
+ keywords["port"] = port
254
+ if host
255
+ keywords["host"] = host
256
+ # A hostaddr would win over the host for the connection itself.
257
+ keywords.delete("hostaddr")
258
+ end
259
+ user = hash[:username] || hash[:user]
260
+ keywords["user"] = user.to_s if user
261
+ # Every other libpq keyword the config names (sslmode, sslrootcert,
262
+ # connect_timeout, ...) is the connection Rails makes: the adapter
263
+ # hands them all to libpq. A blank one names nothing, a blank password
264
+ # included: it doesn't blank the url's.
265
+ PG::Connection.conndefaults_hash.each_key do |key|
266
+ next if %i[host port dbname user].include?(key)
267
+
268
+ value = hash[key]
269
+ keywords[key.to_s] = value.to_s unless value.nil? || value.to_s.empty?
270
+ end
271
+ keywords.map { |key, value| "#{key}='#{value.to_s.gsub(/[\\']/) { |c| "\\#{c}" }}'" }.join(" ")
272
+ end
273
+
157
274
  # The keywords of a libpq connection string or URI. A failure names
158
275
  # what was being read and never the string or libpq's message, which
159
276
  # can quote part of a password (as Trellis's own parse does).
@@ -183,7 +300,8 @@ module Trellis
183
300
  hash = hash.transform_keys(&:to_sym)
184
301
  url = hash[:url] ? conninfo(hash[:url], "config.trellis.dump_database's url") : {}
185
302
  given = { host: hash[:host] || url["host"], port: hash[:port] || url["port"],
186
- dbname: hash[:database] || url["dbname"], user: hash[:username] || url["user"] }
303
+ dbname: hash[:database] || hash[:dbname] || url["dbname"],
304
+ user: hash[:username] || hash[:user] || url["user"] }
187
305
  defaults = PG::Connection.conndefaults_hash
188
306
  resolved = given.to_h do |key, value|
189
307
  value = defaults[key] if value.nil? || value.to_s.empty?
@@ -237,13 +355,11 @@ module Trellis
237
355
  def dump_schema(db_config, *args)
238
356
  return super unless SchemaDump.applies?(db_config) && db_config.schema_dump
239
357
 
240
- format = args.first ||
241
- (db_config.respond_to?(:schema_format) ? db_config.schema_format : ::ActiveRecord.schema_format)
242
-
358
+ format = SchemaDump.format_of(db_config, args)
243
359
  unless format.to_sym == :sql
244
360
  raise Trellis::ValidationError,
245
361
  "#{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"
362
+ "definitions: set config.active_record.schema_format = :sql"
247
363
  end
248
364
 
249
365
  filename = schema_dump_path(db_config, format)
@@ -251,17 +367,45 @@ module Trellis
251
367
 
252
368
  SchemaDump.verify_same_database!(db_config)
253
369
 
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
370
+ previous = File.exist?(filename) ? File.binread(filename) : nil
371
+ finished = false
372
+ begin
373
+ SchemaDump::ATTEMPTS.times do
374
+ before = SchemaDump.parts
375
+ begin
376
+ Thread.current[SchemaDump::PARTS_KEY] = before
377
+ super(db_config, format)
378
+ ensure
379
+ Thread.current[SchemaDump::PARTS_KEY] = nil
380
+ end
381
+ if SchemaDump.parts == before
382
+ SchemaDump.finish(filename, before)
383
+ finished = true
384
+ return nil
385
+ end
260
386
  end
387
+ raise Trellis::ConflictError,
388
+ "Trellis's definitions changed during each of #{SchemaDump::ATTEMPTS} dumps of " \
389
+ "#{db_config.name}: run the dump when no define or drop is running"
390
+ ensure
391
+ # Whatever Rails wrote is raw `pg_dump` output with the capture
392
+ # triggers in and no definitions: left in place, a later
393
+ # `db:schema:load` would load it without a word. A file no
394
+ # `pg_dump` wrote to (a failure before the first, or a `pg_dump`
395
+ # that failed before opening it) is the previous dump: left alone.
396
+ SchemaDump.discard_unfinished(filename, previous) unless finished
397
+ end
398
+ end
399
+
400
+ # After Rails loads the dump database's `structure.sql`, restores the
401
+ # definitions it queued (SchemaDump.restore).
402
+ def load_schema(db_config, *args)
403
+ result = super
404
+ if SchemaDump.applies?(db_config) && db_config.schema_dump &&
405
+ SchemaDump.format_of(db_config, args).to_sym == :sql
406
+ SchemaDump.restore(db_config)
261
407
  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"
408
+ result
265
409
  end
266
410
  end
267
411
 
@@ -78,6 +78,13 @@ module Trellis
78
78
  # Trellis.self_check refuses to walk the target unless it opted out, when
79
79
  # the walk scans the source once per page.
80
80
  #
81
+ # extreme_index_gaps is a warning too: the ExtremeIndexGaps for the source
82
+ # indexes a MIN/MAX aggregate's probes read and the source lacks ([] when
83
+ # it has no MIN or MAX, or they exist). Define and a resume refuse a
84
+ # MIN/MAX definition without them, so a gap is an index dropped since.
85
+ # Nothing is paused: when a group's extreme is deleted, the next one is
86
+ # re-read by scanning the group instead, exact but slower.
87
+ #
81
88
  # correcting lists the CorrectingNodes of its partitioned source it is being
82
89
  # corrected for, oldest first ([] when none): a partition was attached,
83
90
  # detached, dropped or truncated, and the definition re-derives the keys
@@ -92,15 +99,56 @@ module Trellis
92
99
  # over its frozen values, so it is stale against the source. Nothing is
93
100
  # paused or rebuilt: resuming the column writes through to the reader and
94
101
  # it reads :live again.
102
+ #
103
+ # build_wait_xid is set while its build waits on something other than its
104
+ # own work: the transaction id of a fence. The build's source is another
105
+ # definition's target, and no chunk runs until every transaction that was
106
+ # open when the fence was taken has ended (a writer of that target could
107
+ # otherwise commit after a chunk's snapshot without the build seeing its
108
+ # rows). Look in pg_stat_activity for a transaction with a backend_xid at
109
+ # or below it; the wait clears on its own when that transaction ends. nil
110
+ # when the build waits on nothing.
111
+ #
112
+ # version_handover is set while a process of another Trellis version is
113
+ # live on the instance (a VersionHandover): this process stands by (it
114
+ # stages, drains and builds nothing) and its state-changing calls raise
115
+ # VersionHandoverError until the other version is gone. A process killed
116
+ # without a clean shutdown keeps its row live, and so delays the takeover,
117
+ # for at most 30 seconds. nil when none is.
118
+ #
119
+ # below_catalog_floor is set while this binary's version is below the
120
+ # instance's catalog floor, the oldest Trellis version that can read its
121
+ # catalog (a BelowCatalogFloor): this process stands by (it stages, drains
122
+ # and builds nothing, and checks again every standby poll) and its
123
+ # state-changing calls raise VersionFloorError. Run a version at or above
124
+ # the floor. nil when this binary meets it.
125
+ #
126
+ # join is set for a JOIN definition, aggregate or 1-1, and nil for any
127
+ # other (a JoinStatus). It never holds anything up: it changes nothing
128
+ # about the definition's status. It reports the estimated size of the
129
+ # definition's hidden tables, the P-size warning, the fan-out warning of a
130
+ # 1-1 JOIN and the step its build runs.
131
+ #
132
+ # floor is the oldest Trellis release that can run the definition, as
133
+ # "major.minor.patch": the latest release any construct, key space, option
134
+ # or hidden-table layout it uses arrived in. Trellis records it when the
135
+ # definition is defined, altered or rebuilt by a resume. Before rolling the
136
+ # application back to an earlier Trellis release, drop each definition
137
+ # whose floor is above that release, under the newer one.
95
138
  Status = Data.define(:status, :backfill_failure, :capture_wait, :capture_failure,
96
139
  :held_keys, :drain_failure, :source_index_gaps,
97
- :correcting, :paused_upstream_columns) do
140
+ :extreme_index_gaps, :correcting, :paused_upstream_columns,
141
+ :build_wait_xid, :version_handover, :below_catalog_floor,
142
+ :join, :floor) do
98
143
  def self.from_native(hash)
99
144
  failure = hash[:backfill_failure]
100
145
  wait = hash[:capture_wait]
101
146
  capture_failure = hash[:capture_failure]
102
147
  held_keys = hash[:held_keys]
103
148
  drain_failure = hash[:drain_failure]
149
+ version_handover = hash[:version_handover]
150
+ below_catalog_floor = hash[:below_catalog_floor]
151
+ join = hash[:join]
104
152
  new(status: hash[:status],
105
153
  backfill_failure: failure && BackfillFailure.from_native(failure),
106
154
  capture_wait: wait && CaptureWait.from_native(wait),
@@ -108,11 +156,116 @@ module Trellis
108
156
  held_keys: held_keys && HeldKeys.from_native(held_keys),
109
157
  drain_failure: drain_failure && DrainFailure.from_native(drain_failure),
110
158
  source_index_gaps: hash.fetch(:source_index_gaps).map { |g| SourceIndexGap.new(**g) },
159
+ extreme_index_gaps: hash.fetch(:extreme_index_gaps).map { |g| ExtremeIndexGap.new(**g) },
111
160
  correcting: hash.fetch(:correcting).map { |c| CorrectingNode.from_native(c) },
112
- paused_upstream_columns: hash.fetch(:paused_upstream_columns))
161
+ paused_upstream_columns: hash.fetch(:paused_upstream_columns),
162
+ build_wait_xid: hash.fetch(:build_wait_xid),
163
+ version_handover: version_handover && VersionHandover.from_native(version_handover),
164
+ below_catalog_floor: below_catalog_floor && BelowCatalogFloor.new(**below_catalog_floor),
165
+ join: join && JoinStatus.from_native(join),
166
+ floor: hash.fetch(:floor))
167
+ end
168
+ end
169
+
170
+ # A JOIN definition's hidden tables, as Trellis.status reports them: the
171
+ # estimated size of each (hidden_tables, HiddenTables: P, M, J and T, or R, M
172
+ # and J for a 1-1 JOIN, a table that doesn't exist left out), a warning when
173
+ # P has grown close to the fact table's size (partials_warning, a
174
+ # PartialsWarning), a 1-1 JOIN's warnings that one join key holds a very
175
+ # large group of rows (fan_out_warnings, FanOutWarnings, one per joined
176
+ # position past the threshold, in position order) and the step its build
177
+ # runs now, or runs next while no worker holds its job (building, a
178
+ # JoinBuildStep). partials_warning and building are nil, and
179
+ # fan_out_warnings is empty, when there is none.
180
+ # Sizes are the planner's estimates, as fresh as the table's last ANALYZE or
181
+ # autovacuum.
182
+ JoinStatus = Data.define(:hidden_tables, :partials_warning, :fan_out_warnings, :building) do
183
+ def self.from_native(hash)
184
+ partials_warning = hash.fetch(:partials_warning)
185
+ building = hash.fetch(:building)
186
+ new(hidden_tables: hash.fetch(:hidden_tables).map { |t| HiddenTable.new(**t) },
187
+ partials_warning: partials_warning && PartialsWarning.new(**partials_warning),
188
+ fan_out_warnings: hash.fetch(:fan_out_warnings).map { |w| FanOutWarning.new(**w) },
189
+ building: building && JoinBuildStep.new(**building))
190
+ end
191
+ end
192
+
193
+ # One hidden table of a JOIN, in a JoinStatus: which table it is (table,
194
+ # "P", "M", "J", "T" or a 1-1 JOIN's "R"), its name in the catalog schema
195
+ # (name), its estimated live rows (estimated_rows, 0 before the first
196
+ # analyze) and its total size on disk with its indexes, in bytes (bytes).
197
+ HiddenTable = Data.define(:table, :name, :estimated_rows, :bytes)
198
+
199
+ # P, the FROM table grouped by the definition's GROUP BY columns and join
200
+ # key, has grown close to the fact table's size, so the JOIN costs about as
201
+ # much as the table it summarises: P's estimated rows (partials_rows) reach
202
+ # half of the fact table's (fact_rows).
203
+ PartialsWarning = Data.define(:partials_rows, :fact_rows)
204
+
205
+ # A 1-1 JOIN has a join key with a very large group of a rows at one joined
206
+ # position: a change of that position's row on the key rewrites that many
207
+ # target rows, in chunks, before a watermark token past it converges. It
208
+ # names the joined position (position, 1 for the first table joined), the
209
+ # join key's text (key, its columns'
210
+ # values joined by ","), the group's live rows (rows, exact up to ten times
211
+ # the threshold) and the threshold it passed (threshold). The group is found
212
+ # by sampling, so a group just past the threshold can go unreported until it
213
+ # grows.
214
+ FanOutWarning = Data.define(:position, :key, :rows, :threshold)
215
+
216
+ # The step a JOIN build runs, in a JoinStatus. step is the kind:
217
+ #
218
+ # * :whole, the whole build in one transaction, because neither side is a
219
+ # partitioned table with rows.
220
+ # * :side, a step of a build with a partitioned side. side is :from or
221
+ # :joined, and part says what it builds of that side: :whole, one :leaf
222
+ # (named by leaf, a schema.table; leaves build in oid order), the
223
+ # :remainder (the cells that route to none of its leaves) or :unroutable
224
+ # (the side's partition tree isn't recorded, or a leaf has no usable
225
+ # constraint text, so the build fails on this side and backfill_failure
226
+ # says why).
227
+ # * :final, the step that writes T and the target and completes the build.
228
+ # * A 1-1 JOIN's steps: :joined_rows, :joined_null_keys and :joined_gone for
229
+ # the pass over the M of the joined position position (1 for the first
230
+ # table joined), :planning_chunks while the plan job walks a's primary
231
+ # key, :chunks while the chunks of that key run (remaining of them are
232
+ # still to run), and :sweep_stale and :sweep_orphans for the two passes of
233
+ # the sweep that ends a rebuild in place.
234
+ # * :unknown, a step added since this binding was built; side and part read
235
+ # :unknown likewise for an unknown side or part.
236
+ #
237
+ # side, part, leaf, remaining and position are nil for the steps that don't
238
+ # carry them.
239
+ JoinBuildStep = Data.define(:step, :side, :part, :leaf, :remaining, :position)
240
+
241
+ # Another Trellis version is live on the instance, so this process changes
242
+ # nothing until it is gone, as Trellis.status reports it: this binary's
243
+ # version (this_version), the live processes holding it back (processes,
244
+ # OtherProcesses), whether the staging-worker lock is held by a session no
245
+ # registered process names, which is a staging worker of a release before
246
+ # 0.2.0 (unregistered_staging_worker), and whether the migrations this
247
+ # binary carries are pending (migrations_pending), in which case every
248
+ # live process holds it back, whatever its version.
249
+ VersionHandover = Data.define(:this_version, :processes, :unregistered_staging_worker,
250
+ :migrations_pending) do
251
+ def self.from_native(hash)
252
+ new(this_version: hash.fetch(:this_version),
253
+ processes: hash.fetch(:processes).map { |p| OtherProcess.new(**p) },
254
+ unregistered_staging_worker: hash.fetch(:unregistered_staging_worker),
255
+ migrations_pending: hash.fetch(:migrations_pending))
113
256
  end
114
257
  end
115
258
 
259
+ # A live process of another Trellis version, in a VersionHandover: its
260
+ # worker id (worker_id, trellis-<host>-<pid>-<random>) and its binary's
261
+ # version (version, nil for a release before 0.2.0, which records none).
262
+ OtherProcess = Data.define(:worker_id, :version)
263
+
264
+ # This binary's version is below the instance's catalog floor, so this
265
+ # process changes nothing, as Trellis.status reports it: the floor the
266
+ # catalog records (floor) and this binary's version (this_version).
267
+ BelowCatalogFloor = Data.define(:floor, :this_version)
268
+
116
269
  # A node of a partitioned source a definition is being corrected for, as
117
270
  # Trellis.status reports it: the schema-qualified node (node), when the
118
271
  # staging worker queued its correction (since, a Time), and the keys still
@@ -135,6 +288,14 @@ module Trellis
135
288
  # (fix). Trellis doesn't create indexes on your tables.
136
289
  SourceIndexGap = Data.define(:table, :columns, :role, :opted_out, :fix)
137
290
 
291
+ # A source index a MIN/MAX field's probe reads and the source lacks, as
292
+ # Trellis.status reports it: the qualified table (table), the columns the
293
+ # index should lead with, the GROUP BY columns then the argument (columns),
294
+ # the source column the MIN/MAX reads (argument), whether the definition
295
+ # opted out with WITHOUT SOURCE INDEX (opted_out, which define refuses for
296
+ # MIN/MAX), and a create index statement to run (fix).
297
+ ExtremeIndexGap = Data.define(:table, :columns, :argument, :opted_out, :fix)
298
+
138
299
  # A drain page that keeps failing with nothing charged or paused, as
139
300
  # Trellis.status and Trellis.self_check report it: the segment whose page
140
301
  # fails (seg_seq), the qualified source tables it holds changes to
@@ -175,7 +336,8 @@ module Trellis
175
336
  end
176
337
 
177
338
  # Why capture of source_table is broken, or why the drain (or a build's
178
- # merge) halted on the definition: kind is :capture or :halt, error is a
339
+ # merge) halted on the definition: kind is :capture, :halt, :disruption, or
340
+ # :unknown for a kind a newer Trellis release stored, error is a
179
341
  # sentence naming the cause, columns the columns it is about (empty when it
180
342
  # isn't about a column, and for a halt), and detected_at (a Time) when it
181
343
  # was first found.
@@ -3,5 +3,5 @@
3
3
  module Trellis
4
4
  # The trellis-pg gem's version: the Trellis release's, which a
5
5
  # `v<VERSION>` tag releases (docs/releasing.md).
6
- VERSION = "0.1.1"
6
+ VERSION = "0.2.0"
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: trellis-pg
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Salesforce