exwiw 0.8.5 → 0.9.1
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 +4 -4
- data/CHANGELOG.md +13 -0
- data/README.md +34 -3
- data/lib/exwiw/adapter/mongodb_adapter.rb +71 -2
- data/lib/exwiw/adapter/mysql_adapter.rb +1 -1
- data/lib/exwiw/adapter/postgresql_adapter.rb +1 -1
- data/lib/exwiw/adapter/sqlite_adapter.rb +1 -1
- data/lib/exwiw/adapter.rb +14 -2
- data/lib/exwiw/cli.rb +32 -6
- data/lib/exwiw/explain_runner.rb +13 -4
- data/lib/exwiw/mongodb_parallel_dumper.rb +47 -4
- data/lib/exwiw/query_ast_builder.rb +72 -41
- data/lib/exwiw/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 577420a290ea24adc64ef6ba21914ac71b1c98f86163f9d1d8a88643cc89e15b
|
|
4
|
+
data.tar.gz: eaddb0073169543aa1bea944cb1b075fbd8258a034f4cffb9cf0011338ba260a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c21735b0dcda7b30b0519944c37f24610564e91801a29e2c0f7cc3632cc0f24c532d10c078127bccffd0a2f258a5695cdabe25094edf228b5e3203a629ddf175
|
|
7
|
+
data.tar.gz: 7e9ce9682b281c097485da409befc466dd06fc655c1612278b9e98f6bccdea575aa443ff4292777be1e4e68f88d826554702eecd6b94bddc31b1e4edc01c923b
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.9.1] - 2026-06-30
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **The `reverse_scope` satellite cascade now fires in single-target (`--target-table`) mode, not only scope-column mode.** A table that `belongs_to` a `reverse_scope`'d hub (a "satellite") is scoped by constraining it to the hub's in-scope ids — the multi-hop forward (`via_scoped_parent`) cascade. That cascade ran only in scope-column mode: in single-target / PK-anchor mode the hub itself was scoped via `reverse_scope`, but its `belongs_to` children fell through to a full dump — a silent cross-tenant export in a multi-tenant schema, despite the README promising the cascade works in "both single-target and scope-column mode." The single-target build now runs the same `build_belongs_to_scoped_clause` cascade for a non-target table that has no `belongs_to` path to the dump target, so satellites tighten to the kept ids (multi-hop, keeping the single-unambiguous-parent rule, polymorphic skip, and forward-path cycle guard); it can only narrow such a table's output, never widen it. Scope-column mode is unchanged — the cascade logic is reused as-is. Because single-target mode has no `validate_scope!` pre-flight, a satellite the cascade cannot resolve to a single scopable parent (e.g. it `belongs_to` two scopable hubs) is still dumped in full but now logs a warning. The README also documents that `referenced_by` scoping takes precedence over the hub cascade (which can under-scope) and how to force the hub cascade. SQL adapters only.
|
|
10
|
+
|
|
11
|
+
## [0.9.0] - 2026-06-30
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **`exwiw explain` now supports the mongodb adapter.** It runs the server's [explain command](https://www.mongodb.com/docs/manual/reference/command/explain/) for the `find` each collection would be dumped with, and prints the explain document as JSON alongside the find description. Verbosity is configurable via the `EXWIW_MONGODB_EXPLAIN_VERBOSITY` environment variable or the `explain_verbosity:` config key (the env var wins): `queryPlanner` (default), `executionStats`, or `allPlansExecution`. The default `queryPlanner` only **plans** the query — it does not execute it or scan any documents — so it is safe to point at a production source; `executionStats`/`allPlansExecution` run the real extraction query to gather runtime stats and should be used deliberately. Because a scoped collection's `belongs_to` ids are captured at runtime (which `explain` never does), `explain` fills each scoped collection's real foreign-key filter (e.g. `users` → `{ shop_id: { $in: [...] } }`) with a placeholder id instead of the match-nothing `{_id: {$in: []}}` fallback — so the plan reports the real `IXSCAN`/`COLLSCAN` (queryPlanner selects an index by field, not value; only the bound values are fake). `ExplainRunner` now prints the adapter-agnostic query description (the compiled SELECT for SQL, the find description for mongodb), so the SQL adapters' output is unchanged.
|
|
16
|
+
- **The MongoDB fork-parallel dump (`--parallel-workers`) logs a per-collection extracted-record summary on completion.** The serial path already logs each collection's row count inline, but the parallel path previously emitted only an aggregate (`genuine`/`leaves`/`ref_bt` counts). The leaf and `ref_bt` collections are extracted in forked workers whose counts never reached the parent, so the per-collection numbers were unavailable. Each worker now hands its counts back through the same Marshal-sidecar IPC the consumed-leaf `@state` already uses; the parent merges them with the genuine counts it holds and logs one `name: count` line per collection in processing order (matching the `insert-NNN-` file numbering), plus a `total_records` field on the returned stats. This is purely additive metadata — it does not touch the output files, so the byte-identity guarantee is unchanged.
|
|
17
|
+
|
|
5
18
|
## [0.8.5] - 2026-06-25
|
|
6
19
|
|
|
7
20
|
- **`--parallel-workers=N` parallelizes the MongoDB dump across forked processes (opt-in, byte-identical).** When set with `N≥2` on the mongodb adapter's `export`, exwiw runs an inter-collection fork schedule that decodes whole collections in parallel while preserving each collection's natural row order, so the output files are byte-identical to a serial run (same filenames, same content). Collections are classified into three dependency groups — reference data dumped in full (no `belongs_to`), the scoped DAG reachable to the dump target, and non-reachable reference data — which lets the heavy full-dump collections run concurrently with the scoped pass; only a handful of small `@state` hand-offs cross process boundaries. The win needs real cores: it clears ~2× from 4 workers and saturates there. Requires a dump target and a `fork`-capable runtime (CRuby on POSIX); it falls back to the serial path on JRuby/TruffleRuby/Windows or when no target is given. Also settable as `parallel_workers:` in the config file. The default remains the serial dump.
|
data/README.md
CHANGED
|
@@ -59,7 +59,7 @@ driver implements that auth handshake itself and sidesteps the issue.
|
|
|
59
59
|
exwiw has two subcommands:
|
|
60
60
|
|
|
61
61
|
- `export` (default) — generate INSERT/COPY SQL files. If the subcommand is omitted, `export` is assumed.
|
|
62
|
-
- `explain` — print
|
|
62
|
+
- `explain` — print each query `export` would run together with its `EXPLAIN` output. SQL adapters compile the SELECT without executing it; mongodb runs the server's explain (defaulting to the execution-free `queryPlanner`).
|
|
63
63
|
|
|
64
64
|
### `exwiw export`
|
|
65
65
|
|
|
@@ -115,7 +115,7 @@ idx meaning is the same as insert sql.
|
|
|
115
115
|
|
|
116
116
|
### `exwiw explain`
|
|
117
117
|
|
|
118
|
-
Print the
|
|
118
|
+
Print the query each `export` would run together with its `EXPLAIN` output, to stdout. For the SQL adapters (`mysql`, `postgresql`, `sqlite`) this is the compiled SELECT plus its `EXPLAIN` (estimate-only; `EXPLAIN QUERY PLAN` on SQLite) — no SELECT is executed. For `mongodb` it is the `find` description plus the server's explain document as JSON.
|
|
119
119
|
|
|
120
120
|
```bash
|
|
121
121
|
# preview the queries exwiw would run, without executing the SELECTs
|
|
@@ -129,6 +129,29 @@ exwiw explain \
|
|
|
129
129
|
|
|
130
130
|
The `--output-dir`, `--output-format`, `--insert-only`, and `--after-insert-hook` options are dump-specific and rejected when used with `explain`.
|
|
131
131
|
|
|
132
|
+
#### MongoDB explain verbosity
|
|
133
|
+
|
|
134
|
+
The mongodb explain runs the server's [explain command](https://www.mongodb.com/docs/manual/reference/command/explain/) at a configurable verbosity. The default, **`queryPlanner`, only plans the query and does not execute it**, so it is safe to point at a production source. Set it with the `EXWIW_MONGODB_EXPLAIN_VERBOSITY` environment variable or the `explain_verbosity:` config key (the env var wins):
|
|
135
|
+
|
|
136
|
+
| verbosity | behaviour |
|
|
137
|
+
|---|---|
|
|
138
|
+
| `queryPlanner` (default) | Plans the query only. **The query is not executed** — no documents are scanned. |
|
|
139
|
+
| `executionStats` | **Runs the query** and reports runtime statistics (docs examined, time, etc.). |
|
|
140
|
+
| `allPlansExecution` | Runs the winning plan **and the rejected candidate plans** to gather their stats. |
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
# inspect index usage of the real extraction query (executes it)
|
|
144
|
+
EXWIW_MONGODB_EXPLAIN_VERBOSITY=executionStats exwiw explain \
|
|
145
|
+
--adapter=mongodb \
|
|
146
|
+
--uri="mongodb+srv://reader@cluster/app_production" \
|
|
147
|
+
--schema-dir=exwiw/schema \
|
|
148
|
+
--target-collection=shops --ids=...
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
`executionStats` and `allPlansExecution` execute the extraction query against the source, so use them deliberately on large/production collections.
|
|
152
|
+
|
|
153
|
+
> **Scoped collections use a placeholder id.** A non-target collection is scoped by its parents' ids, which a real dump captures while running each parent query — but `explain` runs nothing. So for scoped collections, `explain` fills the real foreign-key filter (e.g. `users` → `{ shop_id: { $in: [...] } }`) with a placeholder id rather than real values. The plan still reflects the real dump: `queryPlanner` chooses an index by the queried *field*, not its value, so whether a scoped extraction does an `IXSCAN` or a `COLLSCAN` is reported correctly. Only the bound *values* are fake. (The target collection uses the real `--ids`; reference collections with no `belongs_to` use the real `{}` full scan.)
|
|
154
|
+
|
|
132
155
|
### Scope-column mode
|
|
133
156
|
|
|
134
157
|
The default `--target-table` extraction assumes the schema converges on a single
|
|
@@ -197,6 +220,13 @@ Each table is resolved as follows:
|
|
|
197
220
|
path, set `ignore: true` to skip it, or mark it `scope_exempt: true` (below) to
|
|
198
221
|
export it in full.
|
|
199
222
|
|
|
223
|
+
> **Note — referenced-by is preferred over the hub cascade.** A table that is
|
|
224
|
+
> *both* `belongs_to` a scoped hub *and* referenced-by a constrained child is
|
|
225
|
+
> scoped to the (narrower) referenced-by id-set, not the hub cascade, so the hub's
|
|
226
|
+
> other children the child does not reference are dropped (under-scoping). To force
|
|
227
|
+
> the broader hub cascade, set `ignore: true` on the child's `belongs_to` edge that
|
|
228
|
+
> points at this table.
|
|
229
|
+
|
|
200
230
|
Scope-column mode is SQL-only (mysql / postgresql / sqlite). It works with `exwiw
|
|
201
231
|
explain` too, which is the recommended way to preview the queries before exporting.
|
|
202
232
|
|
|
@@ -286,6 +316,7 @@ Notes:
|
|
|
286
316
|
- **Relative paths in the config (`schema_dir`, `output_dir`, `after_insert_hook`) are resolved relative to the config file's own directory**, not the current working directory. So with the config at the project root, `schema_dir: exwiw/schema` reads naturally, and an absolute `--config=/path/to/exwiw.yml` works no matter where you run from. (CLI path flags remain relative to the current directory — each source resolves relative to where it is written.) Absolute paths are used as-is.
|
|
287
317
|
- Unknown keys are rejected so a typo surfaces immediately.
|
|
288
318
|
- Export-only keys (`output_dir`, `output_format`, `insert_only`, `after_insert_hook`) are ignored when running `explain`, so a single config file can be shared by both subcommands.
|
|
319
|
+
- `explain_verbosity` sets the mongodb `explain` verbosity (`queryPlanner` | `executionStats` | `allPlansExecution`, default `queryPlanner`); the `EXWIW_MONGODB_EXPLAIN_VERBOSITY` env var overrides it. Ignored by the SQL adapters and by `export`. See [`exwiw explain`](#mongodb-explain-verbosity).
|
|
289
320
|
|
|
290
321
|
### Generator
|
|
291
322
|
|
|
@@ -634,7 +665,7 @@ Notes:
|
|
|
634
665
|
- **Only scoped referencers belong in `via`.** Each arm's query must come out constrained; an unconstrained referencer (e.g. a `scope_exempt` table, or one with no path to a scope) would project *every* id and union the whole table back — so such an arm is **skipped with a warning** rather than silently widening the dump. An unknown table is likewise skipped with a warning. If no arm survives, the table stays unscopable and (in [scope-column mode](#scope-column-mode)) the run aborts via `validate_scope!`.
|
|
635
666
|
- **NULLs are excluded** per arm (`IS NOT NULL`).
|
|
636
667
|
- **Satellites need no config.** A table that `belongs_to` the reverse-scoped table (e.g. `end_users.id → users.id`, or `identities.user_id → users.id`) tightens to the kept ids automatically through the normal cascade — only the reverse-scoped table itself declares `reverse_scope`. The cascade is **multi-hop**, so a table several `belongs_to` hops below the reverse-scoped table (e.g. `end_user_profiles → end_users → users`) also tightens automatically, with no config of its own.
|
|
637
|
-
- Works in both single-target and scope-column mode. Polymorphic foreign keys are not eligible as anchors (the named `column` is always a concrete column).
|
|
668
|
+
- Works in both single-target and scope-column mode. In single-target mode there is no scope-column pre-flight (`validate_scope!`), so a satellite the cascade cannot resolve to a single scopable parent (e.g. it `belongs_to` two scopable hubs) is dumped in full with a warning rather than aborting. Polymorphic foreign keys are not eligible as anchors (the named `column` is always a concrete column).
|
|
638
669
|
|
|
639
670
|
### Why a JOIN, not `IN (subquery)`
|
|
640
671
|
|
|
@@ -68,6 +68,28 @@ module Exwiw
|
|
|
68
68
|
def initialize(connection_config, logger)
|
|
69
69
|
super
|
|
70
70
|
@state = {}
|
|
71
|
+
@explain_placeholder = false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# A recognizably-fake ObjectId substituted for captured parent ids when
|
|
75
|
+
# building scope filters in `explain` placeholder mode (see
|
|
76
|
+
# #explain_scope_with_placeholders! and #parent_state_for).
|
|
77
|
+
EXPLAIN_PLACEHOLDER_OID_HEX = "ffffffffffffffffffffffff"
|
|
78
|
+
|
|
79
|
+
# Switch #build_query into placeholder-scope mode for `explain`. The
|
|
80
|
+
# mongodb scope of a non-target collection is its parents' captured ids,
|
|
81
|
+
# which the serial dump harvests at runtime in #execute. `explain` never
|
|
82
|
+
# executes a query, so that @state stays empty and every scoped child would
|
|
83
|
+
# otherwise fall back to the match-nothing `{_id: {$in: []}}` filter —
|
|
84
|
+
# hiding which field (e.g. a foreign key) the real dump would actually
|
|
85
|
+
# filter on, and thus whether it is indexed. In this mode #parent_state_for
|
|
86
|
+
# returns a placeholder id for each dumped parent instead of reading
|
|
87
|
+
# @state, so build_query emits the real foreign-key filter shape with a
|
|
88
|
+
# dummy value. queryPlanner picks an index by the queried FIELD, not the
|
|
89
|
+
# value, so index selection (IXSCAN vs COLLSCAN) is reported correctly even
|
|
90
|
+
# though the bound value is fake.
|
|
91
|
+
def explain_scope_with_placeholders!
|
|
92
|
+
@explain_placeholder = true
|
|
71
93
|
end
|
|
72
94
|
|
|
73
95
|
# Propagation @state accessor, used ONLY by MongodbParallelDumper to seed a
|
|
@@ -193,8 +215,30 @@ module Exwiw
|
|
|
193
215
|
raise NotImplementedError, "MongodbAdapter does not support bulk delete"
|
|
194
216
|
end
|
|
195
217
|
|
|
196
|
-
|
|
197
|
-
|
|
218
|
+
# Default explain verbosity. `queryPlanner` asks the server to PLAN the
|
|
219
|
+
# query without executing it, so it is safe to run against a production
|
|
220
|
+
# source — no documents are scanned or returned. `executionStats` and
|
|
221
|
+
# `allPlansExecution` actually run the query to collect runtime stats (the
|
|
222
|
+
# latter also runs the rejected candidate plans), so they carry the cost of
|
|
223
|
+
# the real extraction query.
|
|
224
|
+
DEFAULT_EXPLAIN_VERBOSITY = "queryPlanner"
|
|
225
|
+
|
|
226
|
+
# Server-side explain for the find this dump would issue, returned as
|
|
227
|
+
# pretty-printed relaxed extended JSON (same value semantics as the dumped
|
|
228
|
+
# documents). `verbosity` is one of queryPlanner / executionStats /
|
|
229
|
+
# allPlansExecution; see DEFAULT_EXPLAIN_VERBOSITY for the safety
|
|
230
|
+
# implications. A String verbosity is passed through to the driver as-is.
|
|
231
|
+
def explain(query, verbosity: nil)
|
|
232
|
+
verbosity ||= DEFAULT_EXPLAIN_VERBOSITY
|
|
233
|
+
@logger.debug(" Running explain (verbosity=#{verbosity}) on '#{query.collection}': filter=#{query.filter.inspect}")
|
|
234
|
+
|
|
235
|
+
result = db[query.collection]
|
|
236
|
+
.find(query.filter)
|
|
237
|
+
.projection(query.projection)
|
|
238
|
+
.comment(query_comment_text("collection=#{query.collection}"))
|
|
239
|
+
.explain(verbosity: verbosity)
|
|
240
|
+
|
|
241
|
+
JSON.pretty_generate(result.as_extended_json(mode: :relaxed))
|
|
198
242
|
end
|
|
199
243
|
|
|
200
244
|
def describe_query(query)
|
|
@@ -478,7 +522,21 @@ module Exwiw
|
|
|
478
522
|
# constrained by: the values of the parent field the FK references
|
|
479
523
|
# (`relation.references`, default the parent primary_key). nil when the
|
|
480
524
|
# parent has not been executed yet.
|
|
525
|
+
#
|
|
526
|
+
# In `explain` placeholder mode (#explain_scope_with_placeholders!) there is
|
|
527
|
+
# no captured state, so return a one-element placeholder id for any dumped
|
|
528
|
+
# (non-embedded) parent — enough to make the child's foreign-key clause
|
|
529
|
+
# appear with its real field, so the plan reflects the real dump. An
|
|
530
|
+
# embedded parent is not dumped on its own and yields nil, exactly as a real
|
|
531
|
+
# run's empty @state would.
|
|
481
532
|
private def parent_state_for(relation, config_by_name)
|
|
533
|
+
if @explain_placeholder
|
|
534
|
+
parent = config_by_name[relation.table_name]
|
|
535
|
+
return nil if parent.nil? || parent.embedded?
|
|
536
|
+
|
|
537
|
+
return [explain_placeholder_id]
|
|
538
|
+
end
|
|
539
|
+
|
|
482
540
|
parent_fields = @state[relation.table_name]
|
|
483
541
|
return nil if parent_fields.nil?
|
|
484
542
|
|
|
@@ -487,6 +545,17 @@ module Exwiw
|
|
|
487
545
|
parent_fields[reference_field]
|
|
488
546
|
end
|
|
489
547
|
|
|
548
|
+
# The placeholder id used in explain placeholder mode. Built lazily because
|
|
549
|
+
# build_query can run before any db access loads bson; falls back to the
|
|
550
|
+
# hex String if bson is genuinely unavailable (the filter shape is what
|
|
551
|
+
# matters, not the value's type).
|
|
552
|
+
private def explain_placeholder_id
|
|
553
|
+
require 'bson' unless defined?(::BSON::ObjectId)
|
|
554
|
+
@explain_placeholder_id ||= ::BSON::ObjectId.from_string(EXPLAIN_PLACEHOLDER_OID_HEX)
|
|
555
|
+
rescue LoadError
|
|
556
|
+
EXPLAIN_PLACEHOLDER_OID_HEX
|
|
557
|
+
end
|
|
558
|
+
|
|
490
559
|
# A masking plan compiled once per collection config and reused for every
|
|
491
560
|
# document of that collection. `masked_fields` is `[field_name,
|
|
492
561
|
# template_segments]` for each field carrying a `replace_with`;
|
|
@@ -68,7 +68,7 @@ module Exwiw
|
|
|
68
68
|
StreamingResult.new(client: connection, data_sql: data_sql, count_sql: count_sql)
|
|
69
69
|
end
|
|
70
70
|
|
|
71
|
-
def explain(query_ast)
|
|
71
|
+
def explain(query_ast, verbosity: nil)
|
|
72
72
|
sql = commented_sql(query_ast)
|
|
73
73
|
|
|
74
74
|
@logger.debug(" Executing EXPLAIN: \n#{sql}")
|
|
@@ -90,7 +90,7 @@ module Exwiw
|
|
|
90
90
|
StreamingResult.new(connection: connection, data_sql: data_sql, count_sql: count_sql)
|
|
91
91
|
end
|
|
92
92
|
|
|
93
|
-
def explain(query_ast)
|
|
93
|
+
def explain(query_ast, verbosity: nil)
|
|
94
94
|
sql = commented_sql(query_ast)
|
|
95
95
|
|
|
96
96
|
@logger.debug(" Executing EXPLAIN: \n#{sql}")
|
|
@@ -71,7 +71,7 @@ module Exwiw
|
|
|
71
71
|
StreamingResult.new(connection: connection, data_sql: data_sql, count_sql: count_sql)
|
|
72
72
|
end
|
|
73
73
|
|
|
74
|
-
def explain(query_ast)
|
|
74
|
+
def explain(query_ast, verbosity: nil)
|
|
75
75
|
sql = commented_sql(query_ast)
|
|
76
76
|
|
|
77
77
|
@logger.debug(" Executing EXPLAIN QUERY PLAN: \n#{sql}")
|
data/lib/exwiw/adapter.rb
CHANGED
|
@@ -182,11 +182,23 @@ module Exwiw
|
|
|
182
182
|
|
|
183
183
|
# Run the database-specific EXPLAIN for the given query and return the
|
|
184
184
|
# output as a single string for `explain` subcommand to print.
|
|
185
|
-
#
|
|
186
|
-
|
|
185
|
+
# `verbosity` selects the explain detail level; it is mongodb-only (the
|
|
186
|
+
# MongoDB explain command's queryPlanner / executionStats /
|
|
187
|
+
# allPlansExecution) and the SQL adapters accept it only to keep the
|
|
188
|
+
# contract uniform — they ignore it. SQL adapters override; MongodbAdapter
|
|
189
|
+
# implements verbosity.
|
|
190
|
+
def explain(_query_ast, verbosity: nil)
|
|
187
191
|
raise NotImplementedError, "#{self.class.name} does not implement #explain"
|
|
188
192
|
end
|
|
189
193
|
|
|
194
|
+
# Hook letting `explain` tell an adapter whose scope is resolved at runtime
|
|
195
|
+
# (mongodb captures parent ids during execution) to build placeholder scope
|
|
196
|
+
# filters instead, since explain executes nothing. No-op by default: the
|
|
197
|
+
# SQL adapters embed scoping in the query itself, so their `explain` already
|
|
198
|
+
# reflects the real query without any captured state.
|
|
199
|
+
def explain_scope_with_placeholders!
|
|
200
|
+
end
|
|
201
|
+
|
|
190
202
|
# Identifier text prepended to every query exwiw sends to the (often
|
|
191
203
|
# production) source DB, so the statement is recognizable in the
|
|
192
204
|
# processlist / slow-query log / db.currentOp() and can be killed if it
|
data/lib/exwiw/cli.rb
CHANGED
|
@@ -36,8 +36,15 @@ module Exwiw
|
|
|
36
36
|
ids_field
|
|
37
37
|
scope_column
|
|
38
38
|
parallel_workers
|
|
39
|
+
explain_verbosity
|
|
39
40
|
].freeze
|
|
40
41
|
|
|
42
|
+
# MongoDB explain verbosity levels (passed through to the server's explain
|
|
43
|
+
# command). `queryPlanner` only plans the query and is the safe default —
|
|
44
|
+
# the query is not executed; the other two run it to gather runtime stats.
|
|
45
|
+
EXPLAIN_VERBOSITIES = %w[queryPlanner executionStats allPlansExecution].freeze
|
|
46
|
+
DEFAULT_EXPLAIN_VERBOSITY = "queryPlanner"
|
|
47
|
+
|
|
41
48
|
# Database connection settings are environment-specific (and sometimes
|
|
42
49
|
# secret-adjacent), so they must be passed via CLI/env, never the committed
|
|
43
50
|
# config file. `adapter` is the one connection-ish key allowed in config.
|
|
@@ -82,6 +89,7 @@ module Exwiw
|
|
|
82
89
|
@insert_only = nil
|
|
83
90
|
@after_insert_hook_path = nil
|
|
84
91
|
@parallel_workers = nil
|
|
92
|
+
@explain_verbosity = nil
|
|
85
93
|
# nil (not :info) so we can tell "user passed --log-level" from the default,
|
|
86
94
|
# letting a config-file value fill in; the :info default is applied later.
|
|
87
95
|
@log_level = nil
|
|
@@ -138,6 +146,7 @@ module Exwiw
|
|
|
138
146
|
dump_target: dump_target,
|
|
139
147
|
logger: logger,
|
|
140
148
|
io: $stdout,
|
|
149
|
+
explain_verbosity: @explain_verbosity,
|
|
141
150
|
).run
|
|
142
151
|
end
|
|
143
152
|
end
|
|
@@ -172,6 +181,7 @@ module Exwiw
|
|
|
172
181
|
|
|
173
182
|
if @subcommand == "explain"
|
|
174
183
|
validate_explain_only!
|
|
184
|
+
resolve_explain_verbosity!
|
|
175
185
|
end
|
|
176
186
|
|
|
177
187
|
if @database_adapter != "sqlite"
|
|
@@ -321,6 +331,7 @@ module Exwiw
|
|
|
321
331
|
@ids_field ||= config["ids_field"]
|
|
322
332
|
@scope_column ||= config["scope_column"]
|
|
323
333
|
@parallel_workers ||= parse_parallel_workers(config["parallel_workers"]) if config.key?("parallel_workers")
|
|
334
|
+
@explain_verbosity ||= config["explain_verbosity"]
|
|
324
335
|
end
|
|
325
336
|
|
|
326
337
|
# Strip a trailing slash (like the CLI's dir options) and expand relative to
|
|
@@ -447,11 +458,6 @@ module Exwiw
|
|
|
447
458
|
end
|
|
448
459
|
|
|
449
460
|
private def validate_explain_only!
|
|
450
|
-
if @database_adapter == "mongodb"
|
|
451
|
-
$stderr.puts "mongodb adapter is not yet supported by 'explain' subcommand"
|
|
452
|
-
exit 1
|
|
453
|
-
end
|
|
454
|
-
|
|
455
461
|
rejected = []
|
|
456
462
|
rejected << "--output-dir" unless @output_dir.nil?
|
|
457
463
|
rejected << "--output-format" unless @output_format.nil?
|
|
@@ -465,6 +471,24 @@ module Exwiw
|
|
|
465
471
|
end
|
|
466
472
|
end
|
|
467
473
|
|
|
474
|
+
# Resolve the MongoDB explain verbosity for an `explain` run. It is
|
|
475
|
+
# mongodb-only, so this no-ops (and skips validation) for the SQL adapters,
|
|
476
|
+
# which ignore verbosity. The env var wins over the config-file value (it is
|
|
477
|
+
# the more run-specific override, like a CLI flag would be); when neither is
|
|
478
|
+
# set the safe, execution-free `queryPlanner` default is used.
|
|
479
|
+
private def resolve_explain_verbosity!
|
|
480
|
+
return unless @database_adapter == "mongodb"
|
|
481
|
+
|
|
482
|
+
env = ENV["EXWIW_MONGODB_EXPLAIN_VERBOSITY"]
|
|
483
|
+
@explain_verbosity = env unless env.nil? || env.empty?
|
|
484
|
+
@explain_verbosity ||= DEFAULT_EXPLAIN_VERBOSITY
|
|
485
|
+
|
|
486
|
+
unless EXPLAIN_VERBOSITIES.include?(@explain_verbosity)
|
|
487
|
+
$stderr.puts "Invalid explain verbosity '#{@explain_verbosity}'. Available options are: #{EXPLAIN_VERBOSITIES.join(', ')}"
|
|
488
|
+
exit 1
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
|
|
468
492
|
# The export clears @output_dir before writing (see Runner#clean_output_dir!).
|
|
469
493
|
# That is destructive, so when running interactively (stdin is a tty) ask for
|
|
470
494
|
# confirmation first. In non-interactive contexts (CI, pipes) we proceed
|
|
@@ -533,7 +557,9 @@ module Exwiw
|
|
|
533
557
|
Subcommands:
|
|
534
558
|
export Generate INSERT/COPY SQL files (default when omitted).
|
|
535
559
|
explain Print EXPLAIN output for each extraction query to stdout.
|
|
536
|
-
|
|
560
|
+
For mongodb, set verbosity via EXWIW_MONGODB_EXPLAIN_VERBOSITY
|
|
561
|
+
or `explain_verbosity:` in config (queryPlanner (default,
|
|
562
|
+
no query is executed) | executionStats | allPlansExecution).
|
|
537
563
|
BANNER
|
|
538
564
|
opts.version = Exwiw::VERSION
|
|
539
565
|
|
data/lib/exwiw/explain_runner.rb
CHANGED
|
@@ -7,17 +7,24 @@ module Exwiw
|
|
|
7
7
|
schema_dir:,
|
|
8
8
|
dump_target:,
|
|
9
9
|
logger:,
|
|
10
|
-
io: $stdout
|
|
10
|
+
io: $stdout,
|
|
11
|
+
explain_verbosity: nil
|
|
11
12
|
)
|
|
12
13
|
@connection_config = connection_config
|
|
13
14
|
@schema_dir = schema_dir
|
|
14
15
|
@dump_target = dump_target
|
|
15
16
|
@logger = logger
|
|
16
17
|
@io = io
|
|
18
|
+
# mongodb-only; nil lets the adapter pick its safe default (queryPlanner).
|
|
19
|
+
@explain_verbosity = explain_verbosity
|
|
17
20
|
end
|
|
18
21
|
|
|
19
22
|
def run
|
|
20
23
|
adapter = Adapter.build(@connection_config, @logger)
|
|
24
|
+
# explain executes nothing, so an adapter whose non-target scope is captured
|
|
25
|
+
# at runtime (mongodb) has no parent ids to filter children by; ask it to
|
|
26
|
+
# build placeholder scope filters so each query reflects the real dump.
|
|
27
|
+
adapter.explain_scope_with_placeholders!
|
|
21
28
|
configs = load_table_config(adapter.class.table_config_class)
|
|
22
29
|
validate_ignored(configs)
|
|
23
30
|
|
|
@@ -44,11 +51,13 @@ module Exwiw
|
|
|
44
51
|
@logger.debug("Explaining '#{table_name}'... (#{idx + 1}/#{total_size})")
|
|
45
52
|
|
|
46
53
|
query_ast = adapter.build_query(table, @dump_target, table_by_name)
|
|
47
|
-
|
|
48
|
-
|
|
54
|
+
# describe_query is the adapter-agnostic "what query is this": the
|
|
55
|
+
# commented SELECT for SQL adapters, the find description for mongodb.
|
|
56
|
+
query_text = adapter.describe_query(query_ast)
|
|
57
|
+
explain_text = adapter.explain(query_ast, verbosity: @explain_verbosity)
|
|
49
58
|
|
|
50
59
|
@io.puts "-- [#{idx + 1}/#{total_size}] #{table_name}"
|
|
51
|
-
@io.puts
|
|
60
|
+
@io.puts query_text
|
|
52
61
|
@io.puts
|
|
53
62
|
@io.puts "-- EXPLAIN:"
|
|
54
63
|
@io.puts explain_text
|
|
@@ -41,6 +41,12 @@ module Exwiw
|
|
|
41
41
|
# fork is required; callers must check {.available?} and fall back to the serial
|
|
42
42
|
# Runner on JRuby/TruffleRuby/Windows.
|
|
43
43
|
class MongodbParallelDumper
|
|
44
|
+
# Filename prefix for the per-worker record-count sidecars. Distinct from the
|
|
45
|
+
# consumed-leaf @state sidecars (`<name>.marshal`) so the two never collide in
|
|
46
|
+
# the shared sidecar dir and the counts can be globbed back independently.
|
|
47
|
+
COUNTS_SIDECAR_PREFIX = "__exwiw_counts__"
|
|
48
|
+
private_constant :COUNTS_SIDECAR_PREFIX
|
|
49
|
+
|
|
44
50
|
# True when the runtime can `fork` (CRuby on a POSIX OS). On JRuby/TruffleRuby
|
|
45
51
|
# and Windows it cannot — the caller must run the serial Runner instead.
|
|
46
52
|
def self.available?
|
|
@@ -99,19 +105,24 @@ module Exwiw
|
|
|
99
105
|
|
|
100
106
|
FileUtils.mkdir_p(@output_dir)
|
|
101
107
|
parent = build_adapter
|
|
108
|
+
@counts = {}
|
|
102
109
|
|
|
103
110
|
Dir.mktmpdir("exwiw-mongo-parallel-") do |sidecar_dir|
|
|
104
111
|
phase1_leaf_and_genuine(parent, sidecar_dir)
|
|
105
112
|
phase2_cascade(parent, sidecar_dir)
|
|
106
113
|
phase3_ref_components(parent, sidecar_dir)
|
|
114
|
+
collect_counts(sidecar_dir)
|
|
107
115
|
end
|
|
108
116
|
|
|
117
|
+
log_count_summary
|
|
118
|
+
|
|
109
119
|
{
|
|
110
120
|
workers: @workers,
|
|
111
121
|
genuine: @plan.genuine.size,
|
|
112
122
|
leaves: @plan.leaves.size,
|
|
113
123
|
ref_bt: @plan.ref_bt.size,
|
|
114
124
|
components: @plan.reference_components.map(&:size).sort.reverse,
|
|
125
|
+
total_records: @counts.values.sum,
|
|
115
126
|
}
|
|
116
127
|
end
|
|
117
128
|
|
|
@@ -175,7 +186,7 @@ module Exwiw
|
|
|
175
186
|
pids = groups.reject(&:empty?).map do |group|
|
|
176
187
|
members = group.flatten
|
|
177
188
|
seed = leaf_state.slice(*parents_of(members))
|
|
178
|
-
fork { run_component_worker(group, seed) }
|
|
189
|
+
fork { run_component_worker(group, seed, sidecar_dir) }
|
|
179
190
|
end
|
|
180
191
|
ok = pids.map { |pid| Process.wait(pid); $?.exitstatus&.zero? }.all?
|
|
181
192
|
raise "exwiw parallel ref_bt pool failed" unless ok
|
|
@@ -204,25 +215,29 @@ module Exwiw
|
|
|
204
215
|
|
|
205
216
|
def run_leaf_worker(group, sidecar_dir)
|
|
206
217
|
adapter = build_adapter
|
|
207
|
-
|
|
218
|
+
counts = {}
|
|
219
|
+
group.each { |name| counts[name] = process_collection(adapter, name) }
|
|
208
220
|
group.each do |name|
|
|
209
221
|
next unless @plan.consumed_leaves.include?(name)
|
|
210
222
|
next unless adapter.state.key?(name)
|
|
211
223
|
|
|
212
224
|
File.binwrite(File.join(sidecar_dir, "#{name}.marshal"), Marshal.dump(adapter.state[name]))
|
|
213
225
|
end
|
|
226
|
+
write_counts_sidecar(sidecar_dir, group.first, counts)
|
|
214
227
|
exit!(0)
|
|
215
228
|
rescue StandardError => e
|
|
216
229
|
@logger.error("exwiw parallel leaf worker error (#{group.first}..): #{e.class}: #{e.message}")
|
|
217
230
|
exit!(1)
|
|
218
231
|
end
|
|
219
232
|
|
|
220
|
-
def run_component_worker(group, seed)
|
|
233
|
+
def run_component_worker(group, seed, sidecar_dir)
|
|
221
234
|
adapter = build_adapter
|
|
222
235
|
adapter.state = seed unless seed.empty?
|
|
223
236
|
# Each component is already topologically ordered (parent before child) and
|
|
224
237
|
# dependency-closed over intra-ref_bt edges, so a plain serial walk suffices.
|
|
225
|
-
|
|
238
|
+
counts = {}
|
|
239
|
+
group.each { |component| component.each { |name| counts[name] = process_collection(adapter, name) } }
|
|
240
|
+
write_counts_sidecar(sidecar_dir, group.first.first, counts)
|
|
226
241
|
exit!(0)
|
|
227
242
|
rescue StandardError => e
|
|
228
243
|
@logger.error("exwiw parallel ref_bt worker error (#{group.first&.first}..): #{e.class}: #{e.message}")
|
|
@@ -268,6 +283,34 @@ module Exwiw
|
|
|
268
283
|
end
|
|
269
284
|
end
|
|
270
285
|
|
|
286
|
+
# Gather every collection's extracted record count back into the parent for the
|
|
287
|
+
# completion summary. genuine counts are already in @row_counts (parent-local,
|
|
288
|
+
# post-cascade); the leaf and ref_bt collections were extracted in forked
|
|
289
|
+
# workers, so each worker wrote a small Marshal counts sidecar (the same IPC
|
|
290
|
+
# pattern the consumed-leaf @state uses) that we merge here before the tmpdir is
|
|
291
|
+
# removed.
|
|
292
|
+
def collect_counts(sidecar_dir)
|
|
293
|
+
@counts.merge!(@row_counts)
|
|
294
|
+
Dir.glob(File.join(sidecar_dir, "#{COUNTS_SIDECAR_PREFIX}*.marshal")).each do |path|
|
|
295
|
+
@counts.merge!(Marshal.load(File.binread(path)))
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def write_counts_sidecar(sidecar_dir, tag, counts)
|
|
300
|
+
File.binwrite(File.join(sidecar_dir, "#{COUNTS_SIDECAR_PREFIX}#{tag}.marshal"), Marshal.dump(counts))
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Log one line per extractable collection in the dump's processing order (the
|
|
304
|
+
# same order the insert-NNN- files are numbered), so the counts cross-reference
|
|
305
|
+
# directly with the output files. A collection that matched no rows shows 0
|
|
306
|
+
# (its output file was deleted).
|
|
307
|
+
def log_count_summary
|
|
308
|
+
total = @counts.values.sum
|
|
309
|
+
nonzero = @counts.count { |_, count| count.positive? }
|
|
310
|
+
@logger.info("Per-collection extracted record counts (#{total} record(s) across #{nonzero} collection(s)):")
|
|
311
|
+
@plan.extractable.each { |name| @logger.info(" #{name}: #{@counts.fetch(name, 0)}") }
|
|
312
|
+
end
|
|
313
|
+
|
|
271
314
|
# The distinct belongs_to parent names of `names`, used to slice the leaf @state
|
|
272
315
|
# a worker is seeded with down to only the keys its collections reference.
|
|
273
316
|
def parents_of(names)
|
|
@@ -86,6 +86,28 @@ module Exwiw
|
|
|
86
86
|
where_clauses.push(reverse_clause) if reverse_clause
|
|
87
87
|
end
|
|
88
88
|
|
|
89
|
+
# Forward cascade. A satellite of a reverse_scope'd (or referenced-by-scoped)
|
|
90
|
+
# hub has no belongs_to path to the dump target, so the clauses above stay
|
|
91
|
+
# empty and it would dump every row. When its belongs_to parent is itself
|
|
92
|
+
# scoped, constrain this table to the parent's in-scope ids — the same
|
|
93
|
+
# multi-hop cascade scope-column mode performs in build_scoped.
|
|
94
|
+
if table.name != dump_target.table_name &&
|
|
95
|
+
where_clauses.empty? && join_clauses.empty? &&
|
|
96
|
+
forward_scope_allowed?(table)
|
|
97
|
+
parent_clause = build_belongs_to_scoped_clause(table)
|
|
98
|
+
if parent_clause
|
|
99
|
+
where_clauses.push(parent_clause)
|
|
100
|
+
elsif @allow_reverse && @forward_path.empty? && !scope_exempt?(table) &&
|
|
101
|
+
scopable_parent_candidates(table).size > 1
|
|
102
|
+
@logger.warn(
|
|
103
|
+
" #{table.name} belongs_to multiple scopable parents; the cascade cannot " \
|
|
104
|
+
"pick one unambiguously, so it is dumped in full. If this is intended, set " \
|
|
105
|
+
"`scope_exempt: true`. Otherwise, scope it through a single parent (e.g. ignore one belongs_to edge), " \
|
|
106
|
+
"or switch to scope-column mode to scope it directly."
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
89
111
|
QueryAst::Select.new.tap do |ast|
|
|
90
112
|
ast.from(table.name)
|
|
91
113
|
if table.rails_managed?
|
|
@@ -290,48 +312,18 @@ module Exwiw
|
|
|
290
312
|
)
|
|
291
313
|
end
|
|
292
314
|
|
|
293
|
-
#
|
|
294
|
-
#
|
|
295
|
-
#
|
|
296
|
-
#
|
|
297
|
-
#
|
|
298
|
-
#
|
|
299
|
-
#
|
|
300
|
-
#
|
|
301
|
-
#
|
|
302
|
-
#
|
|
315
|
+
# Builds a `fk IN (SELECT parent.pk FROM <parent extraction query>)` clause
|
|
316
|
+
# for a table whose belongs_to parent is itself scopable but carries no scope
|
|
317
|
+
# column of its own — so find_path_to_scoped cannot terminate on it (via_path
|
|
318
|
+
# fails) and nothing references this table (referenced_by fails). The classic
|
|
319
|
+
# shape is a hub scoped only via referenced_by (e.g. CDP `customer_accounts`,
|
|
320
|
+
# scoped by the `customers` that reference it) with sibling detail tables
|
|
321
|
+
# (`customer_account_details`, ...) hanging off it. Constraining those
|
|
322
|
+
# siblings to the hub's in-scope ids keeps them out of a full dump. Returns
|
|
323
|
+
# nil when there is no single, unambiguous scopable parent, leaving the caller
|
|
324
|
+
# on the unscopable path. Used by both scope-column and single-target mode.
|
|
303
325
|
private def build_belongs_to_scoped_clause(table)
|
|
304
|
-
|
|
305
|
-
# candidate parent already on this path would close a belongs_to cycle, so
|
|
306
|
-
# it is skipped; threading the grown path into the parent build lets the
|
|
307
|
-
# cascade recurse N hops (users -> end_users -> end_user_profiles -> ...)
|
|
308
|
-
# and terminate only when a table reappears.
|
|
309
|
-
forward_path = @forward_path + [table.name]
|
|
310
|
-
|
|
311
|
-
candidates = table.belongs_tos.filter_map do |relation|
|
|
312
|
-
# A polymorphic belongs_to points at several parent tables through one
|
|
313
|
-
# column, so it cannot project to a single parent id set; skip it.
|
|
314
|
-
next if relation.polymorphic?
|
|
315
|
-
|
|
316
|
-
parent = table_by_name[relation.table_name]
|
|
317
|
-
next if parent.nil?
|
|
318
|
-
|
|
319
|
-
# Cycle guard: descending into a parent already on the forward path would
|
|
320
|
-
# loop (a -> b -> a). Stop, leaving this table on the :unscopable path.
|
|
321
|
-
next if forward_path.include?(parent.name)
|
|
322
|
-
|
|
323
|
-
# Build the parent's own scoped query. allow_reverse stays true so the
|
|
324
|
-
# parent may be scoped via referenced_by, and forward scoping stays
|
|
325
|
-
# enabled so a parent that is itself scoped via *its* parent resolves
|
|
326
|
-
# too — this is what makes the cascade multi-hop.
|
|
327
|
-
parent_query = self.class.run(parent.name, table_by_name, dump_target, @logger, allow_reverse: true, forward_path: forward_path)
|
|
328
|
-
|
|
329
|
-
# Only a constrained parent narrows anything; an unconstrained parent
|
|
330
|
-
# would select every pk (i.e. dump all) and not help.
|
|
331
|
-
next unless parent_query.where_clauses.any? || parent_query.join_clauses.any?
|
|
332
|
-
|
|
333
|
-
[relation, parent, parent_query]
|
|
334
|
-
end
|
|
326
|
+
candidates = scopable_parent_candidates(table)
|
|
335
327
|
|
|
336
328
|
# Only the unambiguous single-parent case. Multiple scopable parents would
|
|
337
329
|
# need their subqueries combined (not supported); fall back to unscopable.
|
|
@@ -360,6 +352,45 @@ module Exwiw
|
|
|
360
352
|
)
|
|
361
353
|
end
|
|
362
354
|
|
|
355
|
+
# The scopable belongs_to parents of `table`: each non-polymorphic parent
|
|
356
|
+
# whose own extraction query comes out constrained, paired with the relation
|
|
357
|
+
# and that query. Shared by build_belongs_to_scoped_clause (which requires
|
|
358
|
+
# exactly one) and the single-target full-dump warning (which flags two or
|
|
359
|
+
# more, since the cascade then cannot disambiguate).
|
|
360
|
+
private def scopable_parent_candidates(table)
|
|
361
|
+
# Memoized: #run can resolve this twice for the same table (once via
|
|
362
|
+
# build_belongs_to_scoped_clause, once for the ambiguous-parent warning),
|
|
363
|
+
# and each pass recursively builds every parent's query.
|
|
364
|
+
(@scopable_parent_candidates ||= {})[table.name] ||= begin
|
|
365
|
+
# This table plus every ancestor currently being forward-resolved; a
|
|
366
|
+
# candidate parent already on this path would close a belongs_to cycle, so
|
|
367
|
+
# it is skipped. Threading the grown path into the parent build lets the
|
|
368
|
+
# cascade recurse N hops and terminate only when a table reappears.
|
|
369
|
+
forward_path = @forward_path + [table.name]
|
|
370
|
+
|
|
371
|
+
table.belongs_tos.filter_map do |relation|
|
|
372
|
+
# A polymorphic belongs_to points at several parent tables through one
|
|
373
|
+
# column, so it cannot project to a single parent id set.
|
|
374
|
+
next if relation.polymorphic?
|
|
375
|
+
|
|
376
|
+
parent = table_by_name[relation.table_name]
|
|
377
|
+
next if parent.nil?
|
|
378
|
+
next if forward_path.include?(parent.name)
|
|
379
|
+
|
|
380
|
+
# allow_reverse and forward scoping stay enabled so the parent may itself
|
|
381
|
+
# be scoped via referenced_by or via *its* parent — this is what makes the
|
|
382
|
+
# cascade multi-hop.
|
|
383
|
+
parent_query = self.class.run(parent.name, table_by_name, dump_target, @logger, allow_reverse: true, forward_path: forward_path)
|
|
384
|
+
|
|
385
|
+
# Only a constrained parent narrows anything; an unconstrained parent
|
|
386
|
+
# would select every pk (i.e. dump all) and not help.
|
|
387
|
+
next unless parent_query.where_clauses.any? || parent_query.join_clauses.any?
|
|
388
|
+
|
|
389
|
+
[relation, parent, parent_query]
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
363
394
|
private def build_where_clauses(table, dump_target)
|
|
364
395
|
clauses = []
|
|
365
396
|
|
data/lib/exwiw/version.rb
CHANGED