exwiw 0.8.5 → 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: 3934deb015b3ede7c6a8caa25b857d1f7c8957b4efa3284cd26f21ff2620904d
4
- data.tar.gz: e44fef95c3c273aa96b1dde37736212687da88d8d9b98abb5c44fb305d45b7a3
3
+ metadata.gz: 171e3c591c208afcb89c2a9330c2c80d67f8f86ab370858b4690d03c799d28bc
4
+ data.tar.gz: f913a31f6f8e2a29a9e4b2a296926c1266c17cc8655fd4cf33110899f2b468fd
5
5
  SHA512:
6
- metadata.gz: 1bf410fb503b270aca3f96191f34cb8ee67730ac49ea6a57d34550d2b603fed7404206d405c61f6dd3b67808f8865d73c80121d303c628dda513ebcf9c418b17
7
- data.tar.gz: d1cbb8bd0ed7bd10f53d3d3169985844d4e5e509041c95a18d083dff458a06f26c09e325fea44bc5c1e14bf65b53fdc79e4dbe351f3e4e5f916e6c9135cfa71e
6
+ metadata.gz: a8d6e99e0881f4cdaa8d6d737cfbd0107382a6585f119df7d3068007ebb768a976b47fd59f78fb2d931ed95147c707523b3c8e5d1e9af0507584de93796e1ec1
7
+ data.tar.gz: f1cbada5c1adb8892cc297a5e864051da3bdc0b7447cbe1c386b32dde4020cbf1ac6be94935d7bb547c2ec7f4482ebf79b3e1df796bb526db7fd7d5fb42ce662
data/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
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
+
5
12
  ## [0.8.5] - 2026-06-25
6
13
 
7
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.
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
 
@@ -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
- def explain(_query)
197
- 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))
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
- # 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
@@ -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
- (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).
537
563
  BANNER
538
564
  opts.version = Exwiw::VERSION
539
565
 
@@ -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
@@ -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
- group.each { |name| process_collection(adapter, name) }
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
- group.each { |component| component.each { |name| process_collection(adapter, name) } }
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)
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.5"
4
+ VERSION = "0.9.0"
5
5
  end
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.5
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shia