exwiw 0.8.4 → 0.9.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: ee150e9fd830a7829be8c0eebbeca19f0e2d1b1dbcce50fe78e63d62e878997c
4
- data.tar.gz: 31377340d8737ece209e1c0755eeac49ac40930562503e2c4d04f6362938e9d9
3
+ metadata.gz: 171e3c591c208afcb89c2a9330c2c80d67f8f86ab370858b4690d03c799d28bc
4
+ data.tar.gz: f913a31f6f8e2a29a9e4b2a296926c1266c17cc8655fd4cf33110899f2b468fd
5
5
  SHA512:
6
- metadata.gz: 26151e0bf763b6f48e993d2025b4bac804a373fe2e3cfd71bb70d91b86b453826838ec683ebfd3f4f4336bb83b8a12d567d7dbda7f8a4ef48fc7f50bbe98145a
7
- data.tar.gz: b29a2da42750dde93cd53eb56a11a592478134b690ccba31b59cd312f3685dad2713e63897d09cb77246a8be37dfe55890f1a8505c1aa8b5fd020b7bc7760a03
6
+ metadata.gz: a8d6e99e0881f4cdaa8d6d737cfbd0107382a6585f119df7d3068007ebb768a976b47fd59f78fb2d931ed95147c707523b3c8e5d1e9af0507584de93796e1ec1
7
+ data.tar.gz: f1cbada5c1adb8892cc297a5e864051da3bdc0b7447cbe1c386b32dde4020cbf1ac6be94935d7bb547c2ec7f4482ebf79b3e1df796bb526db7fd7d5fb42ce662
data/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.9.0] - 2026-06-30
6
+
7
+ ### Added
8
+
9
+ - **`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.
10
+ - **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.
11
+
12
+ ## [0.8.5] - 2026-06-25
13
+
14
+ - **`--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.
15
+
5
16
  ## [0.8.4] - 2026-06-24
6
17
 
7
18
  ### Fixed
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 the compiled SQL and its `EXPLAIN` output for each query that `export` would run, without executing the SELECTs.
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 compiled SQL and its `EXPLAIN` output (estimate-only; `EXPLAIN QUERY PLAN` on SQLite) for each query that `export` would run, to stdout. No SELECT is executed. Supported for `mysql`, `postgresql`, and `sqlite`. The `mongodb` adapter is not yet supported.
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
@@ -286,6 +309,7 @@ Notes:
286
309
  - **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
310
  - Unknown keys are rejected so a typo surfaces immediately.
288
311
  - 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.
312
+ - `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
313
 
290
314
  ### Generator
291
315
 
@@ -769,6 +793,7 @@ The MongoDB adapter is experimental. To use it:
769
793
  - `--target-collection=COLLECTION` is a mongodb-only alias of `--target-table` (use whichever reads better for MongoDB). Specifying both, or using `--target-collection` with a non-mongodb adapter, is an error.
770
794
  - `--ids-field=FIELD` matches `--ids` against `FIELD` on the target collection instead of its primary key (e.g. `--target-collection=users --ids=a@example.com --ids-field=email`). Downstream foreign-key propagation still keys off the primary key, so only the target collection's filter changes. Unlike the primary-key path, the supplied ids are **not** type-coerced (the stored type of a custom field is unknown), so pass values matching the field's actual type. This flag is **mongodb-only** (the SQL adapters have no equivalent).
771
795
  - Large or embedded-document-heavy dumps are streamed automatically: the adapter reads the collection through a lazy cursor (not `.to_a`) and writes JSONL in chunks, so peak memory is bounded by the chunk size rather than the collection size — no flag to set. Encoding each document to MongoDB Extended JSON is accelerated by an **optional native (C) extension** that compiles automatically on `gem install`; where it cannot compile, exwiw falls back to a byte-identical pure-Ruby encoder. See [`docs/optimization-notes.md`](docs/optimization-notes.md) for the performance investigation and [`docs/optimize-mongodb-export-with-native-ext.md`](docs/optimize-mongodb-export-with-native-ext.md) for the native encoder's design. Benchmark your own data with `script/bench_mongodb_dump.rb`.
796
+ - `--parallel-workers=N` (opt-in, `export` only) forks `N` worker processes that decode whole collections in parallel — the dominant cost on a large dump is the driver's BSON→Ruby decode, and each worker decodes its own collections in their natural order, so the output stays **byte-identical** to a serial run (same filenames and content). It needs a dump target (the schedule is built around the scoped DAG) and a `fork`-capable runtime (CRuby on POSIX), falling back to the serial path otherwise; it also accepts `parallel_workers:` in the config file. The speedup needs real cores to spend — it reaches ~2× from 4 workers and saturates there. The default is serial. See [`docs/mongodb-dump-parallelism-2x-notes.md`](docs/mongodb-dump-parallelism-2x-notes.md) for the schedule and measurements.
772
797
  - Output is JSON Lines (`insert-{idx}-{collection}.jsonl`) using MongoDB Extended JSON (relaxed mode). Import with `mongoimport`:
773
798
  ```bash
774
799
  mongoimport --db app_dev --collection users --file dump/insert-002-users.jsonl
@@ -134,13 +134,24 @@ bounded by chunk size, and `N×` that stays well under the 7 GiB container limit
134
134
 
135
135
  ## Status
136
136
 
137
- This is a **measured, byte-identical proof** (bench prototype, not yet integrated
138
- into the Runner/CLI). Integrating it means re-introducing process orchestration
139
- and `@state` sidecar IPC the machinery `optimization-notes.md` deliberately
140
- removed as over-engineered for a flag. It is recorded here because, unlike that
141
- removed work, this schedule is (a) byte-identical by construction, (b) measured
142
- past the target on a real extraction, and (c) the lever the task explicitly
143
- invited (scale the task to go faster). A production version would gate it behind a
144
- worker-count option, fall back to serial where `fork` is unavailable
145
- (Windows/JRuby), and reuse the existing genuine-anchor classification to derive
146
- the three groups.
137
+ **Integrated and shipped behind an opt-in flag.** The schedule lives in
138
+ `Exwiw::MongodbParallelPlan` (the static, DB-free classification) and
139
+ `Exwiw::MongodbParallelDumper` (the fork orchestrator: per-group pools, LPT
140
+ bin-packing, the `@state` Marshal-sidecar IPC, and the Phase-2 cascade). The
141
+ `Runner` delegates the whole schema+inserts pass to the dumper when the mongodb
142
+ adapter is used with `--parallel-workers=N` (N≥2), a genuine-anchor dump target is
143
+ present, and the runtime can `fork`; otherwise it runs the serial loop unchanged.
144
+ The CLI exposes `--parallel-workers` / config-file `parallel_workers` (mongodb +
145
+ `export` only). The after-insert hook runs identically on both paths.
146
+
147
+ End-to-end verification through the real `exwiw export` CLI on the same staging
148
+ restore: **189/189 output files byte-identical** to the serial CLI run (same
149
+ filenames, 0 content mismatches), at **2.19× wall-clock** (serial 7.13 s → N=4
150
+ 3.25 s; N=2 3.99 s = 1.81×; N=6 saturates at 3.25 s). Per the curve above the win
151
+ materializes from ~4 real cores.
152
+
153
+ This was the machinery `optimization-notes.md` deliberately removed as
154
+ over-engineered for a flag — re-introduced here because, unlike that removed work,
155
+ this schedule is byte-identical by construction, measured past the 2× target on a
156
+ real extraction, and the lever the task explicitly invited (scale the task to go
157
+ faster). It is **strictly opt-in**: the default remains the serial path.
@@ -68,6 +68,47 @@ 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
93
+ end
94
+
95
+ # Propagation @state accessor, used ONLY by MongodbParallelDumper to seed a
96
+ # forked worker with the slice of parent ids its collections reference and to
97
+ # harvest the ids downstream collections will `$in`-match against (handed
98
+ # between processes as Marshal sidecars). The serial Runner never touches
99
+ # these — it relies on the in-process capture during #execute.
100
+ attr_accessor :state
101
+
102
+ # Cheap, metadata-only document-count estimate for `collection_name`, used by
103
+ # the parallel dumper to weight collections for LPT bin-packing. This only
104
+ # influences which worker processes a collection (never the output bytes), so
105
+ # an imprecise estimate is harmless. Reads collection metadata rather than
106
+ # running a COLLSCAN; returns 0 on any error (e.g. a collection absent from
107
+ # this database just sorts to the lowest weight).
108
+ def estimated_count(collection_name)
109
+ db[collection_name].estimated_document_count
110
+ rescue StandardError
111
+ 0
71
112
  end
72
113
 
73
114
  def dumpable?(config)
@@ -174,8 +215,30 @@ module Exwiw
174
215
  raise NotImplementedError, "MongodbAdapter does not support bulk delete"
175
216
  end
176
217
 
177
- def explain(_query)
178
- raise NotImplementedError, "MongodbAdapter does not support explain yet"
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))
179
242
  end
180
243
 
181
244
  def describe_query(query)
@@ -459,7 +522,21 @@ module Exwiw
459
522
  # constrained by: the values of the parent field the FK references
460
523
  # (`relation.references`, default the parent primary_key). nil when the
461
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.
462
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
+
463
540
  parent_fields = @state[relation.table_name]
464
541
  return nil if parent_fields.nil?
465
542
 
@@ -468,6 +545,17 @@ module Exwiw
468
545
  parent_fields[reference_field]
469
546
  end
470
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
+
471
559
  # A masking plan compiled once per collection config and reused for every
472
560
  # document of that collection. `masked_fields` is `[field_name,
473
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
- # SQL adapters override; MongodbAdapter currently raises.
186
- def explain(_query_ast)
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
@@ -35,8 +35,16 @@ module Exwiw
35
35
  ids
36
36
  ids_field
37
37
  scope_column
38
+ parallel_workers
39
+ explain_verbosity
38
40
  ].freeze
39
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
+
40
48
  # Database connection settings are environment-specific (and sometimes
41
49
  # secret-adjacent), so they must be passed via CLI/env, never the committed
42
50
  # config file. `adapter` is the one connection-ish key allowed in config.
@@ -44,7 +52,7 @@ module Exwiw
44
52
 
45
53
  # Keys that only make sense for `export`. They are skipped when merging config
46
54
  # for `explain` so a shared config file does not trip validate_explain_only!.
47
- EXPORT_ONLY_CONFIG_KEYS = %w[output_dir output_format insert_only after_insert_hook].freeze
55
+ EXPORT_ONLY_CONFIG_KEYS = %w[output_dir output_format insert_only after_insert_hook parallel_workers].freeze
48
56
 
49
57
  def self.start(argv)
50
58
  new(argv).run
@@ -80,6 +88,8 @@ module Exwiw
80
88
  @output_format = nil
81
89
  @insert_only = nil
82
90
  @after_insert_hook_path = nil
91
+ @parallel_workers = nil
92
+ @explain_verbosity = nil
83
93
  # nil (not :info) so we can tell "user passed --log-level" from the default,
84
94
  # letting a config-file value fill in; the :info default is applied later.
85
95
  @log_level = nil
@@ -125,6 +135,7 @@ module Exwiw
125
135
  output_format: @output_format,
126
136
  insert_only: @insert_only,
127
137
  after_insert_hook_path: @after_insert_hook_path,
138
+ parallel_workers: @parallel_workers,
128
139
  cli_options: build_cli_options_hash,
129
140
  logger: logger,
130
141
  ).run
@@ -135,6 +146,7 @@ module Exwiw
135
146
  dump_target: dump_target,
136
147
  logger: logger,
137
148
  io: $stdout,
149
+ explain_verbosity: @explain_verbosity,
138
150
  ).run
139
151
  end
140
152
  end
@@ -165,9 +177,11 @@ module Exwiw
165
177
  resolve_scope_column!
166
178
  resolve_ids_field!
167
179
  resolve_uri_option!
180
+ resolve_parallel_workers!
168
181
 
169
182
  if @subcommand == "explain"
170
183
  validate_explain_only!
184
+ resolve_explain_verbosity!
171
185
  end
172
186
 
173
187
  if @database_adapter != "sqlite"
@@ -316,6 +330,8 @@ module Exwiw
316
330
  end
317
331
  @ids_field ||= config["ids_field"]
318
332
  @scope_column ||= config["scope_column"]
333
+ @parallel_workers ||= parse_parallel_workers(config["parallel_workers"]) if config.key?("parallel_workers")
334
+ @explain_verbosity ||= config["explain_verbosity"]
319
335
  end
320
336
 
321
337
  # Strip a trailing slash (like the CLI's dir options) and expand relative to
@@ -409,17 +425,45 @@ module Exwiw
409
425
  end
410
426
  end
411
427
 
412
- private def validate_explain_only!
413
- if @database_adapter == "mongodb"
414
- $stderr.puts "mongodb adapter is not yet supported by 'explain' subcommand"
428
+ # `--parallel-workers` opts into the MongoDB fork-parallel dump schedule
429
+ # (docs/mongodb-dump-parallelism-2x-notes.md). It is mongodb-only (the SQL
430
+ # adapters shell out to their own dumpers) and must be a positive integer;
431
+ # N<2 is accepted but runs serially. Runs after the adapter name is normalized
432
+ # so the family check is reliable. `explain` rejection is handled separately
433
+ # by validate_explain_only!.
434
+ private def resolve_parallel_workers!
435
+ return if @parallel_workers.nil?
436
+
437
+ if @database_adapter != "mongodb"
438
+ $stderr.puts "--parallel-workers is only supported by the mongodb adapter"
439
+ exit 1
440
+ end
441
+
442
+ if @parallel_workers < 1
443
+ $stderr.puts "--parallel-workers must be a positive integer (got #{@parallel_workers})"
415
444
  exit 1
416
445
  end
446
+ end
447
+
448
+ # Coerce a config-file `parallel_workers` (YAML scalar) to Integer, matching
449
+ # the CLI flag's Integer coercion. A non-integer value is a config typo, so
450
+ # fail fast rather than silently dropping it.
451
+ private def parse_parallel_workers(value)
452
+ return nil if value.nil?
417
453
 
454
+ Integer(value)
455
+ rescue ArgumentError, TypeError
456
+ $stderr.puts "config 'parallel_workers' must be an integer (got #{value.inspect})"
457
+ exit 1
458
+ end
459
+
460
+ private def validate_explain_only!
418
461
  rejected = []
419
462
  rejected << "--output-dir" unless @output_dir.nil?
420
463
  rejected << "--output-format" unless @output_format.nil?
421
464
  rejected << "--insert-only" unless @insert_only.nil?
422
465
  rejected << "--after-insert-hook" unless @after_insert_hook_path.nil?
466
+ rejected << "--parallel-workers" unless @parallel_workers.nil?
423
467
 
424
468
  unless rejected.empty?
425
469
  $stderr.puts "The following options are not applicable in 'explain' subcommand: #{rejected.join(', ')}"
@@ -427,6 +471,24 @@ module Exwiw
427
471
  end
428
472
  end
429
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
+
430
492
  # The export clears @output_dir before writing (see Runner#clean_output_dir!).
431
493
  # That is destructive, so when running interactively (stdin is a tty) ask for
432
494
  # confirmation first. In non-interactive contexts (CI, pipes) we proceed
@@ -495,7 +557,9 @@ module Exwiw
495
557
  Subcommands:
496
558
  export Generate INSERT/COPY SQL files (default when omitted).
497
559
  explain Print EXPLAIN output for each extraction query to stdout.
498
- (not yet supported for the mongodb adapter)
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).
499
563
  BANNER
500
564
  opts.version = Exwiw::VERSION
501
565
 
@@ -526,6 +590,7 @@ module Exwiw
526
590
  opts.on("--after-insert-hook=PATH", "Path to a .rb or .sh post-processing hook executed after all insert/delete files are written (export subcommand only)") do |v|
527
591
  @after_insert_hook_path = File.expand_path(v)
528
592
  end
593
+ opts.on("--parallel-workers=N", Integer, "Fork N workers for the MongoDB dump's parallel schedule (mongodb + export only; N>=2 enables it, default is serial). Output is byte-identical to serial; falls back to serial where fork is unavailable.") { |v| @parallel_workers = v }
529
594
  opts.on("--log-level=LEVEL", "Log level (debug, info). default is info") { |v| @log_level = v.to_sym }
530
595
 
531
596
  opts.on("--help", "Print this help") do
@@ -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
- sql = adapter.commented_sql(query_ast)
48
- explain_text = adapter.explain(query_ast)
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 sql
60
+ @io.puts query_text
52
61
  @io.puts
53
62
  @io.puts "-- EXPLAIN:"
54
63
  @io.puts explain_text
@@ -0,0 +1,333 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require "fileutils"
5
+ require "tmpdir"
6
+
7
+ module Exwiw
8
+ # Runs the inter-collection fork schedule from
9
+ # docs/mongodb-dump-parallelism-2x-notes.md, producing output **byte-identical**
10
+ # to the serial Runner while parallelizing the dominant cost (the Mongo driver's
11
+ # BSON->Ruby decode) across processes — each worker decodes its own collections
12
+ # in their natural order, so order is preserved and the result still matches a
13
+ # serial dump.
14
+ #
15
+ # It consumes the static, config-derived classification from MongodbParallelPlan
16
+ # (the three groups + cascade adjacency + ref_bt components) and adds the live
17
+ # orchestration the plan deliberately leaves out: a fork pool per group, LPT
18
+ # bin-packing on a per-collection cost weight, @state Marshal sidecar IPC for the
19
+ # handful of referenced leaves, and the Phase-2 cascade reprocess.
20
+ #
21
+ # The schedule (one parent process + a pool of `workers` forks):
22
+ #
23
+ # Phase 1 (concurrent): fork the leaf pool; the parent meanwhile dumps the
24
+ # schema and processes the WHOLE genuine DAG optimistically (no leaf @state
25
+ # yet), recording each genuine collection's row count.
26
+ # Barrier: wait for the leaf pool; load the Marshal sidecars the consumed
27
+ # leaves wrote into the parent's @state.
28
+ # Phase 2 (cascade): reprocess only the genuine collections whose output can
29
+ # change now that leaf @state is present (the direct-leaf referencers),
30
+ # cascading to genuine children of any whose row count actually changed.
31
+ # Phase 3: fork the ref_bt collections as dependency-closed components, each
32
+ # worker owning whole components (processed in topological order) seeded with
33
+ # the leaf @state its members reference.
34
+ #
35
+ # Output bytes are independent of the schedule: every collection writes its own
36
+ # insert-NNN-<name>.<ext> file (the index taken over the plan's full ordering,
37
+ # exactly as the serial Runner numbers them) and the per-collection write is the
38
+ # same build_query -> execute -> write_inserts pass the Runner performs. The
39
+ # bin-packing only decides which worker runs which collection, never the bytes.
40
+ #
41
+ # fork is required; callers must check {.available?} and fall back to the serial
42
+ # Runner on JRuby/TruffleRuby/Windows.
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
+
50
+ # True when the runtime can `fork` (CRuby on a POSIX OS). On JRuby/TruffleRuby
51
+ # and Windows it cannot — the caller must run the serial Runner instead.
52
+ def self.available?
53
+ Process.respond_to?(:fork)
54
+ end
55
+
56
+ # Longest-Processing-Time bin-packing: assign `items` to `bins` bins, heaviest
57
+ # first onto the currently least-loaded bin. Returns an Array of `bins` arrays
58
+ # (some may be empty when items < bins). `weight` is called exactly once per
59
+ # item (it may be DB-backed, so it must not be invoked repeatedly). Pure — no
60
+ # DB, no IO — so it is unit-tested directly.
61
+ def self.bin_pack(items, bins, &weight)
62
+ raise ArgumentError, "bins must be >= 1 (got #{bins})" if bins < 1
63
+
64
+ weighted = items.map { |item| [item, weight.call(item)] }.sort_by { |(_, w)| -w }
65
+ groups = Array.new(bins) { [] }
66
+ loads = Array.new(bins, 0)
67
+ weighted.each do |(item, w)|
68
+ i = (0...bins).min_by { |j| loads[j] }
69
+ groups[i] << item
70
+ loads[i] += w
71
+ end
72
+ groups
73
+ end
74
+
75
+ # @param connection_config [ConnectionConfig] used to build a FRESH adapter in
76
+ # the parent and in every fork (a Mongo client cannot be shared across fork)
77
+ # @param plan [MongodbParallelPlan] the static classification for this dump
78
+ # @param dump_target [DumpTarget]
79
+ # @param table_by_name [Hash{String=>config}] ALL configs (embedded included),
80
+ # exactly as Runner builds it
81
+ # @param output_dir [String]
82
+ # @param workers [Integer] fork pool size (>= 1)
83
+ # @param logger [Logger]
84
+ # @param weight_for [#call, nil] optional name -> numeric cost weight for LPT;
85
+ # defaults to the adapter's metadata-only estimated document count
86
+ def initialize(connection_config:, plan:, dump_target:, table_by_name:, output_dir:, workers:, logger:, weight_for: nil)
87
+ raise ArgumentError, "workers must be >= 1 (got #{workers})" if workers < 1
88
+
89
+ @connection_config = connection_config
90
+ @plan = plan
91
+ @dump_target = dump_target
92
+ @table_by_name = table_by_name
93
+ @output_dir = output_dir
94
+ @workers = workers
95
+ @logger = logger
96
+ @weight_for = weight_for
97
+ end
98
+
99
+ # Execute the full schedule. Assumes the caller has already cleaned the output
100
+ # directory (the Runner does this before handing off), mirroring the serial
101
+ # path which dumps the schema into a freshly-cleaned dir. Returns a small stats
102
+ # Hash. Raises if any worker pool reports a non-zero exit.
103
+ def run
104
+ raise "fork is unavailable on this runtime; run the serial Runner instead" unless self.class.available?
105
+
106
+ FileUtils.mkdir_p(@output_dir)
107
+ parent = build_adapter
108
+ @counts = {}
109
+
110
+ Dir.mktmpdir("exwiw-mongo-parallel-") do |sidecar_dir|
111
+ phase1_leaf_and_genuine(parent, sidecar_dir)
112
+ phase2_cascade(parent, sidecar_dir)
113
+ phase3_ref_components(parent, sidecar_dir)
114
+ collect_counts(sidecar_dir)
115
+ end
116
+
117
+ log_count_summary
118
+
119
+ {
120
+ workers: @workers,
121
+ genuine: @plan.genuine.size,
122
+ leaves: @plan.leaves.size,
123
+ ref_bt: @plan.ref_bt.size,
124
+ components: @plan.reference_components.map(&:size).sort.reverse,
125
+ total_records: @counts.values.sum,
126
+ }
127
+ end
128
+
129
+ private
130
+
131
+ # Phase 1: fork the leaf pool to run concurrently while the parent dumps the
132
+ # schema (parent-only, needs no @state) and processes the whole genuine DAG
133
+ # optimistically. The genuine row counts captured here seed the Phase-2 cascade.
134
+ def phase1_leaf_and_genuine(parent, sidecar_dir)
135
+ leaf_master = fork do
136
+ ok = run_leaf_pool(sidecar_dir)
137
+ exit!(ok ? 0 : 1)
138
+ end
139
+
140
+ schema_path = File.join(@output_dir, "insert-000-schema.#{parent.schema_output_extension}")
141
+ ordered_tables = @plan.ordered_all.map { |name| @table_by_name.fetch(name) }
142
+ @logger.info("Writing schema to #{schema_path}...")
143
+ parent.dump_schema(ordered_tables, schema_path)
144
+
145
+ @logger.info("Processing #{@plan.genuine.size} genuine collection(s) (parent, optimistic pass)...")
146
+ @row_counts = {}
147
+ @plan.genuine.each { |name| @row_counts[name] = process_collection(parent, name) }
148
+
149
+ Process.wait(leaf_master)
150
+ raise "exwiw parallel leaf pool failed (exit #{$?.exitstatus})" unless $?.exitstatus&.zero?
151
+ end
152
+
153
+ # Barrier + Phase 2: load the consumed-leaf @state the leaf workers handed back,
154
+ # then reprocess only the genuine collections whose output can change now that
155
+ # leaf @state is present, cascading to genuine children of any that changed.
156
+ def phase2_cascade(parent, sidecar_dir)
157
+ load_sidecars(parent, @plan.consumed_leaves, sidecar_dir)
158
+
159
+ queue = @plan.direct_leaf_genuine.dup
160
+ seen = Set.new
161
+ until queue.empty?
162
+ name = queue.shift
163
+ next if seen.include?(name)
164
+
165
+ seen << name
166
+ new_count = process_collection(parent, name)
167
+ next if new_count == @row_counts[name]
168
+
169
+ @row_counts[name] = new_count
170
+ @plan.genuine_children[name].each { |child| queue << child }
171
+ end
172
+ @logger.info("Cascade reprocessed #{seen.size} genuine collection(s) with leaf @state.") unless seen.empty?
173
+ end
174
+
175
+ # Phase 3: fork the ref_bt collections as dependency-closed weakly-connected
176
+ # components in a single pool (no level barriers, no cross-worker IPC). Each
177
+ # worker owns whole components and processes their members in topological order,
178
+ # seeded only with the leaf @state those members reference.
179
+ def phase3_ref_components(parent, sidecar_dir)
180
+ components = @plan.reference_components
181
+ return if components.empty?
182
+
183
+ leaf_state = parent.state
184
+ groups = self.class.bin_pack(components, @workers) { |component| component.sum { |name| weight_of(parent, name) } }
185
+
186
+ pids = groups.reject(&:empty?).map do |group|
187
+ members = group.flatten
188
+ seed = leaf_state.slice(*parents_of(members))
189
+ fork { run_component_worker(group, seed, sidecar_dir) }
190
+ end
191
+ ok = pids.map { |pid| Process.wait(pid); $?.exitstatus&.zero? }.all?
192
+ raise "exwiw parallel ref_bt pool failed" unless ok
193
+
194
+ @logger.info("Processed #{@plan.ref_bt.size} ref_bt collection(s) in #{groups.reject(&:empty?).size} worker(s).")
195
+ end
196
+
197
+ # Fork `@workers` leaf workers (LPT-packed on cost weight so the single heaviest
198
+ # leaf sits alone) and wait for them. Each worker writes a Marshal sidecar for
199
+ # the consumed leaves it produced. Runs inside the leaf_master fork, so its own
200
+ # weight adapter and the worker connections never touch the parent's.
201
+ def run_leaf_pool(sidecar_dir)
202
+ return true if @plan.leaves.empty?
203
+
204
+ weight_adapter = build_adapter
205
+ groups = self.class.bin_pack(@plan.leaves, @workers) { |name| weight_of(weight_adapter, name) }
206
+
207
+ pids = groups.reject(&:empty?).map do |group|
208
+ fork { run_leaf_worker(group, sidecar_dir) }
209
+ end
210
+ pids.map { |pid| Process.wait(pid); $?.exitstatus&.zero? }.all?
211
+ rescue StandardError => e
212
+ @logger.error("exwiw parallel leaf master error: #{e.class}: #{e.message}")
213
+ false
214
+ end
215
+
216
+ def run_leaf_worker(group, sidecar_dir)
217
+ adapter = build_adapter
218
+ counts = {}
219
+ group.each { |name| counts[name] = process_collection(adapter, name) }
220
+ group.each do |name|
221
+ next unless @plan.consumed_leaves.include?(name)
222
+ next unless adapter.state.key?(name)
223
+
224
+ File.binwrite(File.join(sidecar_dir, "#{name}.marshal"), Marshal.dump(adapter.state[name]))
225
+ end
226
+ write_counts_sidecar(sidecar_dir, group.first, counts)
227
+ exit!(0)
228
+ rescue StandardError => e
229
+ @logger.error("exwiw parallel leaf worker error (#{group.first}..): #{e.class}: #{e.message}")
230
+ exit!(1)
231
+ end
232
+
233
+ def run_component_worker(group, seed, sidecar_dir)
234
+ adapter = build_adapter
235
+ adapter.state = seed unless seed.empty?
236
+ # Each component is already topologically ordered (parent before child) and
237
+ # dependency-closed over intra-ref_bt edges, so a plain serial walk suffices.
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)
241
+ exit!(0)
242
+ rescue StandardError => e
243
+ @logger.error("exwiw parallel ref_bt worker error (#{group.first&.first}..): #{e.class}: #{e.message}")
244
+ exit!(1)
245
+ end
246
+
247
+ # Extract one collection to its insert-NNN-<name>.<ext> file. This mirrors the
248
+ # serial Runner's non-COPY insert path exactly — same filename (index taken over
249
+ # the plan's full ordering), same pre/post hooks (nil for MongoDB), same
250
+ # streaming write_inserts + trailing "\n", and the same empty-result handling
251
+ # (delete the just-opened file) — so the bytes are identical regardless of which
252
+ # process writes them. Returns the row count.
253
+ def process_collection(adapter, name)
254
+ table = @table_by_name.fetch(name)
255
+ query = adapter.build_query(table, @dump_target, @table_by_name)
256
+ results = adapter.execute(query)
257
+
258
+ insert_idx = (@plan.index_of.fetch(name) + 1).to_s.rjust(3, "0")
259
+ path = File.join(@output_dir, "insert-#{insert_idx}-#{name}.#{adapter.output_extension}")
260
+ chunk_size = table.bulk_insert_chunk_size || adapter.default_bulk_insert_chunk_size
261
+
262
+ record_num = 0
263
+ File.open(path, "w") do |file|
264
+ pre = adapter.pre_insert_sql(table)
265
+ file.puts(pre) if pre
266
+ _statement_count, record_num = adapter.write_inserts(file, results, table, chunk_size)
267
+ file.print("\n")
268
+ post = adapter.post_insert_sql(table)
269
+ file.puts(post) if post
270
+ end
271
+ File.delete(path) if record_num.zero?
272
+ record_num
273
+ end
274
+
275
+ # Merge the Marshal sidecars the leaf workers wrote (one per consumed leaf that
276
+ # actually produced rows) into `adapter`'s @state, so the cascade reprocess and
277
+ # the ref_bt workers can constrain on those leaf ids.
278
+ def load_sidecars(adapter, names, sidecar_dir)
279
+ state = adapter.state
280
+ names.each do |name|
281
+ path = File.join(sidecar_dir, "#{name}.marshal")
282
+ state[name] = Marshal.load(File.binread(path)) if File.exist?(path)
283
+ end
284
+ end
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
+
314
+ # The distinct belongs_to parent names of `names`, used to slice the leaf @state
315
+ # a worker is seeded with down to only the keys its collections reference.
316
+ def parents_of(names)
317
+ names.flat_map { |name| @table_by_name.fetch(name).belongs_tos.map(&:table_name) }.uniq
318
+ end
319
+
320
+ def weight_of(adapter, name)
321
+ return @weight_for.call(name) if @weight_for
322
+
323
+ adapter.estimated_count(name)
324
+ end
325
+
326
+ # A fresh adapter (and thus a fresh, lazily-opened Mongo connection). Built per
327
+ # process — the parent and every fork get their own; a Mongo client must never
328
+ # be shared across a fork boundary.
329
+ def build_adapter
330
+ Adapter.build(@connection_config, @logger)
331
+ end
332
+ end
333
+ end
@@ -0,0 +1,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Exwiw
6
+ # Classifies a MongoDB dump's collections into the three dependency groups the
7
+ # inter-collection fork schedule needs, plus the derived adjacency that
8
+ # schedule consumes. See docs/mongodb-dump-parallelism-2x-notes.md for the why;
9
+ # this class is the static, config-derived half of that plan.
10
+ #
11
+ # It is a pure function of the loaded configs and the dump target — no DB
12
+ # access — so it can be computed once up front and unit-tested without a live
13
+ # MongoDB. The fork orchestration (worker pools, LPT bin-packing on output-size
14
+ # weights, @state Marshal sidecars, the Phase-2 cascade loop) lives elsewhere
15
+ # and consumes the structures produced here.
16
+ #
17
+ # Input contract: `configs` are MongodbCollectionConfig already passed through
18
+ # `#reject_ignored_members!` (exactly as Runner#load_table_config produces
19
+ # them), so every surviving belongs_to has a non-nil `table_name`. ignore:true
20
+ # *collections* are still present in `configs` — they contribute to the schema
21
+ # and to the file-index ordering, but their data extraction is skipped — and
22
+ # are therefore excluded from the three processing groups.
23
+ #
24
+ # The three groups partition the extractable collections exactly:
25
+ #
26
+ # - **genuine** — reachable to the dump target by following belongs_to edges
27
+ # (the scoped DAG). Includes the target itself.
28
+ # - **leaf** — no belongs_to at all: reference/master data dumped in full,
29
+ # with no input dependencies (embarrassingly parallel).
30
+ # - **ref_bt** — has belongs_to but is NOT reachable to the target: reference
31
+ # data scoped by the adapter's strict-AND fallback. Its
32
+ # internal edges form shallow components.
33
+ #
34
+ # `reachable` mirrors MongodbAdapter#genuine_scope_set exactly (fixpoint over
35
+ # all non-embedded configs, including ignore:true ones), so the genuine set
36
+ # here matches the adapter's runtime scoping classification.
37
+ class MongodbParallelPlan
38
+ EMPTY_NAMES = [].freeze
39
+ private_constant :EMPTY_NAMES
40
+
41
+ # @param configs [Array<MongodbCollectionConfig>] reject_ignored_members!'d
42
+ # @param target_table_name [String] the dump target collection
43
+ # @param logger [Logger, nil] forwarded to DetermineTableProcessingOrder
44
+ def initialize(configs:, target_table_name:, logger: nil)
45
+ @by = configs.each_with_object({}) { |c, h| h[c.name] = c }
46
+ @target_table_name = target_table_name
47
+
48
+ dumpable = configs.reject(&:embedded?)
49
+ # The file index (insert-NNN-) is taken over the FULL processing order,
50
+ # including ignore:true collections, so the orchestrated run's filenames
51
+ # are byte-identical to the serial Runner's (which numbers files the same
52
+ # way). Data extraction, however, skips ignore:true — see #extractable.
53
+ @ordered_all = DetermineTableProcessingOrder.run(dumpable, logger: logger).freeze
54
+ @index_of = @ordered_all.each_with_index.to_h.freeze
55
+ @extractable = @ordered_all.reject { |n| @by[n].ignore }.freeze
56
+
57
+ @reachable = compute_reachable
58
+ classify
59
+ derive_consumed_leaves
60
+ derive_cascade_adjacency
61
+ @reference_components = compute_reference_components.freeze
62
+ end
63
+
64
+ # Full processing order, INCLUDING ignore:true collections — the sequence the
65
+ # file index (insert-NNN-) is numbered over.
66
+ attr_reader :ordered_all
67
+
68
+ # name => 0-based position in #ordered_all (the file index is position + 1).
69
+ attr_reader :index_of
70
+
71
+ # #ordered_all minus ignore:true collections — the collections whose data is
72
+ # actually extracted. Union of the three groups below.
73
+ attr_reader :extractable
74
+
75
+ # The three groups (each a subset of #extractable, in #ordered_all order):
76
+
77
+ # genuine — reachable to the dump target (includes the target).
78
+ attr_reader :genuine
79
+
80
+ # leaf — no belongs_to; reference/master data with no input dependencies.
81
+ attr_reader :leaves
82
+
83
+ # ref_bt — has belongs_to but not reachable to the target.
84
+ attr_reader :ref_bt
85
+
86
+ # ref_bt collections as dependency-closed weakly-connected components over
87
+ # intra-ref_bt belongs_to edges, each returned in a valid topological order
88
+ # (a parent before its child). A whole component can be processed serially by
89
+ # one worker with no cross-worker @state IPC and no level barriers, seeded
90
+ # only with the leaf @state its members reference.
91
+ attr_reader :reference_components
92
+
93
+ # Leaf collections referenced (via belongs_to) by some non-leaf extractable
94
+ # collection (genuine OR ref_bt). These are the only leaves whose captured
95
+ # @state a downstream collection can need, so they are the ones a leaf worker
96
+ # must hand back (e.g. as a Marshal sidecar). Set<String>.
97
+ attr_reader :consumed_leaves
98
+
99
+ # genuine collections that directly reference a leaf — the only genuine
100
+ # collections whose output can change once leaf @state is present (and only
101
+ # at runtime, when their genuine anchor turns out empty and they fall back to
102
+ # the leaf clause). These seed the Phase-2 cascade reprocess.
103
+ attr_reader :direct_leaf_genuine
104
+
105
+ # name => genuine children (genuine collections that belongs_to it), keyed
106
+ # only by reachable parents. Drives the Phase-2 cascade: when a reprocessed
107
+ # collection's row count changes, its genuine children are re-enqueued.
108
+ attr_reader :genuine_children
109
+
110
+ # The set of collection names genuinely scoped by the target (the target plus
111
+ # everything that can reach it through belongs_to). Exposed for inspection.
112
+ attr_reader :reachable
113
+
114
+ def summary
115
+ {
116
+ extractable: @extractable.size,
117
+ genuine: @genuine.size,
118
+ leaves: @leaves.size,
119
+ ref_bt: @ref_bt.size,
120
+ consumed_leaves: @consumed_leaves.size,
121
+ direct_leaf_genuine: @direct_leaf_genuine.size,
122
+ reference_components: @reference_components.map(&:size).sort.reverse,
123
+ }
124
+ end
125
+
126
+ private
127
+
128
+ # Fixpoint over non-embedded configs: the target, plus every collection that
129
+ # can reach it by following belongs_to (child -> parent) transitively.
130
+ # Mirrors MongodbAdapter#genuine_scope_set (same traversal, same inclusion of
131
+ # ignore:true collections) so the genuine set matches the adapter's runtime
132
+ # scoping decision.
133
+ def compute_reachable
134
+ reachable = Set.new([@target_table_name])
135
+ loop do
136
+ added = false
137
+ @by.each_value do |cfg|
138
+ next if cfg.embedded? || reachable.include?(cfg.name)
139
+ next unless cfg.belongs_tos.any? { |rel| reachable.include?(rel.table_name) }
140
+
141
+ reachable << cfg.name
142
+ added = true
143
+ end
144
+ break unless added
145
+ end
146
+ reachable
147
+ end
148
+
149
+ def classify
150
+ # The three groups partition #extractable: reachable -> genuine; otherwise
151
+ # leaf (no belongs_to) -> leaves; otherwise -> ref_bt. The target is
152
+ # reachable (it seeds the set), so it lands in genuine and is never
153
+ # mis-grouped as a leaf even when it has no belongs_to of its own — which
154
+ # would otherwise double-process it (leaf pool AND parent).
155
+ @genuine = []
156
+ @leaves = []
157
+ @ref_bt = []
158
+ @extractable.each do |name|
159
+ if @reachable.include?(name)
160
+ @genuine << name
161
+ elsif leaf?(name)
162
+ @leaves << name
163
+ else
164
+ @ref_bt << name
165
+ end
166
+ end
167
+ @genuine.freeze
168
+ @leaves.freeze
169
+ @ref_bt.freeze
170
+ # Membership against the leaf *group* (which excludes the target), not the
171
+ # raw structural #leaf? predicate. The target has no belongs_to and is thus
172
+ # structurally leaf-like, but it is genuine — processed by the parent, not a
173
+ # leaf worker — so a belongs_to to the target must not count as referencing
174
+ # a leaf (it would wrongly demand a sidecar / seed the cascade).
175
+ @leaf_set = @leaves.to_set
176
+ end
177
+
178
+ def derive_consumed_leaves
179
+ consumed = Set.new
180
+ (@genuine + @ref_bt).each do |name|
181
+ @by[name].belongs_tos.each do |rel|
182
+ consumed << rel.table_name if @leaf_set.include?(rel.table_name)
183
+ end
184
+ end
185
+ @consumed_leaves = consumed.freeze
186
+ end
187
+
188
+ def derive_cascade_adjacency
189
+ @direct_leaf_genuine = @genuine.select do |name|
190
+ @by[name].belongs_tos.any? { |rel| @leaf_set.include?(rel.table_name) }
191
+ end.freeze
192
+
193
+ children = Hash.new { |h, k| h[k] = [] }
194
+ @genuine.each do |name|
195
+ @by[name].belongs_tos.each do |rel|
196
+ children[rel.table_name] << name if @reachable.include?(rel.table_name)
197
+ end
198
+ end
199
+ # Freeze with a non-mutating default so a lookup of a parent with no genuine
200
+ # children returns [] without trying to write into the frozen hash.
201
+ children.default_proc = nil
202
+ children.default = EMPTY_NAMES
203
+ @genuine_children = children.freeze
204
+ end
205
+
206
+ # ref_bt as dependency-closed weakly-connected components over intra-ref_bt
207
+ # belongs_to edges, each topo-ordered. Ported from the bench prototype: build
208
+ # the directed (child indegree) and undirected (component) views of the
209
+ # intra-ref_bt edges, find weakly-connected components, then Kahn-order each.
210
+ def compute_reference_components
211
+ ref_set = @ref_bt.to_set
212
+ children = Hash.new { |h, k| h[k] = [] }
213
+ adjacency = Hash.new { |h, k| h[k] = [] }
214
+ @ref_bt.each do |name|
215
+ @by[name].belongs_tos.each do |rel|
216
+ next unless ref_set.include?(rel.table_name)
217
+
218
+ children[rel.table_name] << name
219
+ adjacency[rel.table_name] << name
220
+ adjacency[name] << rel.table_name
221
+ end
222
+ end
223
+
224
+ seen = Set.new
225
+ components = []
226
+ @ref_bt.each do |start|
227
+ next if seen.include?(start)
228
+
229
+ stack = [start]
230
+ members = []
231
+ until stack.empty?
232
+ node = stack.pop
233
+ next if seen.include?(node)
234
+
235
+ seen << node
236
+ members << node
237
+ adjacency[node].each { |neighbor| stack << neighbor unless seen.include?(neighbor) }
238
+ end
239
+ components << members
240
+ end
241
+
242
+ components.map { |members| topo_order(members, children) }
243
+ end
244
+
245
+ # Kahn topological order of `members` over intra-component belongs_to edges
246
+ # (parent before child). `children` is the directed intra-ref_bt adjacency.
247
+ def topo_order(members, children)
248
+ member_set = members.to_set
249
+ indegree = members.to_h do |name|
250
+ [name, @by[name].belongs_tos.count { |rel| member_set.include?(rel.table_name) }]
251
+ end
252
+ queue = members.select { |name| indegree[name].zero? }
253
+ ordered = []
254
+ until queue.empty?
255
+ node = queue.shift
256
+ ordered << node
257
+ children[node].each do |child|
258
+ next unless member_set.include?(child)
259
+
260
+ indegree[child] -= 1
261
+ queue << child if indegree[child].zero?
262
+ end
263
+ end
264
+ ordered
265
+ end
266
+
267
+ def leaf?(name)
268
+ (cfg = @by[name]) && !cfg.embedded? && cfg.belongs_tos.empty?
269
+ end
270
+ end
271
+ end
data/lib/exwiw/runner.rb CHANGED
@@ -13,6 +13,7 @@ module Exwiw
13
13
  output_format: 'insert',
14
14
  insert_only: false,
15
15
  after_insert_hook_path: nil,
16
+ parallel_workers: nil,
16
17
  cli_options: {}
17
18
  )
18
19
  @connection_config = connection_config
@@ -22,6 +23,7 @@ module Exwiw
22
23
  @output_format = output_format
23
24
  @insert_only = insert_only
24
25
  @after_insert_hook_path = after_insert_hook_path
26
+ @parallel_workers = parallel_workers
25
27
  @cli_options = cli_options
26
28
  @logger = logger
27
29
  end
@@ -49,6 +51,19 @@ module Exwiw
49
51
 
50
52
  clean_output_dir!
51
53
 
54
+ # Opt-in MongoDB inter-collection fork parallelism (see
55
+ # docs/mongodb-dump-parallelism-2x-notes.md). It is byte-identical to the
56
+ # serial loop below — same filenames (the file index is taken over the same
57
+ # full processing order) and same per-collection bytes — so it is a drop-in
58
+ # replacement for the whole schema+inserts pass, after which the common
59
+ # after-insert hook still runs. Everything before this point (validation,
60
+ # scope check, ordering, output-dir clean) applies to both paths.
61
+ if use_mongodb_parallel?(adapter)
62
+ dump_mongodb_parallel(configs, table_by_name)
63
+ run_after_insert_hook(adapter, ordered_table_names.size)
64
+ return
65
+ end
66
+
52
67
  ordered_tables = ordered_table_names.map { |n| table_by_name.fetch(n) }
53
68
  schema_path = File.join(@output_dir, "insert-000-schema.#{adapter.schema_output_extension}")
54
69
  @logger.info("Writing schema to #{schema_path}...")
@@ -161,17 +176,71 @@ module Exwiw
161
176
  end
162
177
  end
163
178
 
164
- if @after_insert_hook_path
165
- @logger.info("Running after-insert hook: #{@after_insert_hook_path}")
166
- AfterInsertHook.run(
167
- path: @after_insert_hook_path,
168
- cli_options: @cli_options,
169
- output_dir: @output_dir,
170
- next_idx: total_size + 1,
171
- output_extension: adapter.output_extension,
172
- logger: @logger,
173
- )
179
+ run_after_insert_hook(adapter, total_size)
180
+ end
181
+
182
+ # Run the post-processing hook (no-op when none configured). `total_size` is
183
+ # the count of processed tables/collections; the hook's first output file is
184
+ # numbered just past them. Shared by the serial and parallel dump paths.
185
+ private def run_after_insert_hook(adapter, total_size)
186
+ return unless @after_insert_hook_path
187
+
188
+ @logger.info("Running after-insert hook: #{@after_insert_hook_path}")
189
+ AfterInsertHook.run(
190
+ path: @after_insert_hook_path,
191
+ cli_options: @cli_options,
192
+ output_dir: @output_dir,
193
+ next_idx: total_size + 1,
194
+ output_extension: adapter.output_extension,
195
+ logger: @logger,
196
+ )
197
+ end
198
+
199
+ # True when the opt-in MongoDB fork-parallel dump should run instead of the
200
+ # serial loop: the mongodb adapter, a worker count > 1, a genuine-anchor dump
201
+ # target (the schedule is built around the scoped DAG), and a runtime that can
202
+ # fork. Anything else falls back to the serial path (warning when the user
203
+ # explicitly asked for parallelism but it cannot apply).
204
+ private def use_mongodb_parallel?(adapter)
205
+ return false unless adapter.is_a?(Adapter::MongodbAdapter)
206
+ return false unless @parallel_workers && @parallel_workers > 1
207
+
208
+ if @dump_target.table_name.nil?
209
+ @logger.warn("--parallel-workers ignored: MongoDB parallelism needs a --target-collection; running serially.")
210
+ return false
174
211
  end
212
+
213
+ unless MongodbParallelDumper.available?
214
+ @logger.warn("--parallel-workers ignored: fork is unavailable on this runtime; running serially.")
215
+ return false
216
+ end
217
+
218
+ true
219
+ end
220
+
221
+ # Build the static plan and hand the whole schema+inserts pass to the fork
222
+ # orchestrator. `configs` are the reject_ignored_members!'d configs (the plan
223
+ # rejects embedded and orders them itself, identically to the serial path).
224
+ private def dump_mongodb_parallel(configs, table_by_name)
225
+ plan = MongodbParallelPlan.new(
226
+ configs: configs,
227
+ target_table_name: @dump_target.table_name,
228
+ logger: @logger,
229
+ )
230
+ @logger.info(
231
+ "MongoDB parallel dump with #{@parallel_workers} worker(s): " \
232
+ "genuine=#{plan.genuine.size}, leaves=#{plan.leaves.size}, ref_bt=#{plan.ref_bt.size}."
233
+ )
234
+ stats = MongodbParallelDumper.new(
235
+ connection_config: @connection_config,
236
+ plan: plan,
237
+ dump_target: @dump_target,
238
+ table_by_name: table_by_name,
239
+ output_dir: @output_dir,
240
+ workers: @parallel_workers,
241
+ logger: @logger,
242
+ ).run
243
+ @logger.info("MongoDB parallel dump complete: #{stats.inspect}")
175
244
  end
176
245
 
177
246
  # Empty the output dir before writing so each export starts from a clean
data/lib/exwiw/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Exwiw
4
- VERSION = "0.8.4"
4
+ VERSION = "0.9.0"
5
5
  end
data/lib/exwiw.rb CHANGED
@@ -23,6 +23,8 @@ require_relative "exwiw/adapter/mysql_adapter"
23
23
  require_relative "exwiw/adapter/postgresql_adapter"
24
24
  require_relative "exwiw/adapter/mongodb_adapter"
25
25
  require_relative "exwiw/determine_table_processing_order"
26
+ require_relative "exwiw/mongodb_parallel_plan"
27
+ require_relative "exwiw/mongodb_parallel_dumper"
26
28
  require_relative "exwiw/mongo_query"
27
29
  require_relative "exwiw/query_ast"
28
30
  require_relative "exwiw/query_ast_builder"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: exwiw
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.4
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shia
@@ -72,6 +72,8 @@ files:
72
72
  - lib/exwiw/mongo_query.rb
73
73
  - lib/exwiw/mongodb_collection_config.rb
74
74
  - lib/exwiw/mongodb_field.rb
75
+ - lib/exwiw/mongodb_parallel_dumper.rb
76
+ - lib/exwiw/mongodb_parallel_plan.rb
75
77
  - lib/exwiw/mongoid_schema_generator.rb
76
78
  - lib/exwiw/query_ast.rb
77
79
  - lib/exwiw/query_ast_builder.rb