exwiw 0.9.12 → 0.9.14

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: 4be92980b62c26dbb27cef847c3534134cf9e5d27c4676dc2ff28468168e52ed
4
- data.tar.gz: 357c2efdbe2e818fa4e5cce3739b5d77bb05b0dda50817dc9a8e622746b3d6ce
3
+ metadata.gz: 1e66762378ba8b4357a2d42fff5fbb1f0c12e96979d0e59f6fdcaae46bd7da4d
4
+ data.tar.gz: 1c1cd268f516ab60b34915f77fc1ab2ed7e6cdfb8d4cc1e508abf026bb5519fa
5
5
  SHA512:
6
- metadata.gz: 632102dbdeb9b713abf435fff5224565675f7d34843e6a1e447d96b71920018ff2a79f9f27971afc153a27813e6cd68cefc7df790dbc00fe8a371d8d4cb32c5a
7
- data.tar.gz: c7dbd4ec63a7b711a276fb4e0c07e5e481621664c5bd07cdc2452b5a60c38c6843e11891cb46d966a969c57e6fe99714d20d5f786a6d9af087d4966180401f58
6
+ metadata.gz: dd5dda5f6dcfa4fce06b3e9bd7ef5c8d9d45fb32a2606db0bc2dcf5aa047784036bec61d7a4efe49d92632fb4bdd9bb460445b72587503a05e1bd49f0fa1fd63
7
+ data.tar.gz: 267f0de9ca7fcca1ee19e4005031c5740493ea9d8550e13af6bc199f90f45377d5f9a840f4379a28fa8fe73ec7e26514a0fa0e2523df4df43143663dbed2dbf0
data/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.9.14] - 2026-07-29
6
+
7
+ ### Changed
8
+
9
+ - **SQL adapters now default `bulk_insert_chunk_size` to 10_000** (previously nil = one INSERT statement per table). A single statement covering a multi-million-row table exceeds the import target's `max_allowed_packet` and aborts the load with "MySQL server has gone away". Output for tables at or below 10,000 rows is byte-identical; larger tables now emit multiple bounded INSERT statements. Set `bulk_insert_chunk_size` in a table config to override.
10
+
11
+ ## [0.9.13] - 2026-07-28
12
+
13
+ ### Changed
14
+
15
+ - **The mysql adapter materializes each scope id-set into a session `TEMPORARY TABLE` and reuses it across tables.** Previously the same scope subquery (e.g. the dump target's users id-set, including a multi-arm `reverse_scope` UNION) was embedded — and re-evaluated by the server — in every descendant table's extraction query, which dominated export time on large tenants. During `export`, each distinct id-set is now computed once (`CREATE TEMPORARY TABLE ... AS SELECT DISTINCT ...`, indexed), and descendant queries JOIN the temp table; nested scopes are materialized bottom-up so outer sets build from already-materialized inner ones. The dumped rows are identical. `explain` still compiles the inline form and executes nothing. Read-only replicas are supported: `NO_ENGINE_SUBSTITUTION` is stripped from the session `sql_mode` before the first CREATE, so e.g. Aurora MySQL 3 reader instances (which cannot create InnoDB temp tables) substitute a permitted engine instead of failing with ERROR 3161. If temp table creation still fails (e.g. the DB user lacks `CREATE TEMPORARY TABLES`), materialization is disabled for the run with a warning and extraction falls back to the previous inline subqueries. postgresql/sqlite adapters are unchanged.
16
+
5
17
  ## [0.9.12] - 2026-07-09
6
18
 
7
19
  ## [0.9.11] - 2026-07-09
data/README.md CHANGED
@@ -753,7 +753,7 @@ Unlike rails-managed entries, `columns` and `belongs_tos` are retained so the en
753
753
 
754
754
  `bulk_insert_chunk_size` splits the generated `INSERT` statement into multiple statements, each containing at most the specified number of rows. This is useful when the number of records per table is large enough to hit limits like MySQL's `max_allowed_packet`.
755
755
 
756
- If omitted, all records for a table are emitted as a single `INSERT` statement.
756
+ If omitted, the adapter default applies: 10,000 rows per statement for the SQL adapters (1,000 documents per chunk for MongoDB). Tables at or below the chunk size still produce a single `INSERT` statement. To force a single statement regardless of table size, set a value larger than the table's row count.
757
757
 
758
758
  ### Filter
759
759
 
@@ -34,7 +34,8 @@ The Runner drives, per table:
34
34
  downstream.
35
35
 
36
36
  2. **to_bulk_insert** — SQL adapters set **no** `default_bulk_insert_chunk_size`
37
- (it is `nil`), so the Runner treats the whole table as one chunk and
37
+ (it was `nil` at measurement time; since then the default is 10_000
38
+ large tables emit multiple bounded INSERT statements), so at measurement time the Runner treated the whole table as one chunk and
38
39
  `to_bulk_insert` builds the **entire** `INSERT INTO ... VALUES (...),(...);`
39
40
  as one giant String — first an `Array` of N per-row tuple strings, then the
40
41
  joined result — held simultaneously with the result set from step 1.
@@ -119,7 +119,7 @@ module Exwiw
119
119
  day_minute day_second dec decimal declare default delayed delete
120
120
  dense_rank desc describe deterministic distinct distinctrow div double
121
121
  drop dual each else elseif empty enclosed escaped except exists exit
122
- explain false fetch first_value float float4 float8 for force foreign
122
+ explain external false fetch first_value float float4 float8 for force foreign
123
123
  from fulltext function generated get grant group grouping groups
124
124
  having high_priority hour_microsecond hour_minute hour_second if
125
125
  ignore in index infile inner inout insensitive insert int int1 int2
@@ -132,13 +132,13 @@ module Exwiw
132
132
  minute_second mod modifies natural not no_write_to_binlog nth_value
133
133
  ntile null numeric of on optimize optimizer_costs option optionally
134
134
  or order out outer outfile over partition percent_rank precision
135
- primary procedure purge range rank read reads read_write real
135
+ primary procedure purge qualify range rank read reads read_write real
136
136
  recursive references regexp release rename repeat replace require
137
137
  resignal restrict return revoke right rlike row rows row_number
138
138
  schema schemas second_microsecond select sensitive separator set show
139
139
  signal smallint spatial specific sql sqlexception sqlstate sqlwarning
140
140
  sql_big_result sql_calc_found_rows sql_small_result ssl starting
141
- stored straight_join system table terminated then tinyblob tinyint
141
+ stored straight_join system table tablesample terminated then tinyblob tinyint
142
142
  tinytext to trailing trigger true undo union unique unlock unsigned
143
143
  update usage use using utc_date utc_time utc_timestamp values
144
144
  varbinary varchar varcharacter varying virtual when where while
@@ -59,11 +59,15 @@ module Exwiw
59
59
  end
60
60
 
61
61
  def execute(query_ast)
62
- data_sql = commented_sql(query_ast)
62
+ data_sql = nil
63
+ count_sql = nil
63
64
  # Count via the same FROM/JOIN/WHERE (projection replaced by COUNT(*)) so
64
65
  # the Runner can skip empty tables and log the row count without draining
65
66
  # the stream. See StreamingResult for why this is not a subquery wrap.
66
- count_sql = "#{sql_query_comment(query_ast)} #{compile_ast(query_ast, count_only: true)}"
67
+ with_scope_materialization do
68
+ data_sql = commented_sql(query_ast)
69
+ count_sql = "#{sql_query_comment(query_ast)} #{compile_ast(query_ast, count_only: true)}"
70
+ end
67
71
 
68
72
  @logger.debug(" Executing SQL (streaming): \n#{data_sql}")
69
73
  StreamingResult.new(client: connection, data_sql: data_sql, count_sql: count_sql)
@@ -292,16 +296,86 @@ module Exwiw
292
296
  # to `<col> IN (subquery)`.
293
297
  private def compile_scope_join(from_table_name, where_clause, idx)
294
298
  subquery = where_clause.value
295
- projection = quote_identifier(subquery_projection_name(subquery))
296
- src_alias = "exwiw_scope_src_#{idx}"
297
299
  ids_alias = "exwiw_scope_ids_#{idx}"
298
300
  outer_key = qualified_name(from_table_name, where_clause.column_name)
299
301
 
302
+ if (scope_table = materialized_scope_table(subquery))
303
+ return "JOIN #{quote_table_name(scope_table)} AS #{ids_alias} " \
304
+ "ON #{outer_key} = #{ids_alias}.exwiw_scope_id"
305
+ end
306
+
307
+ projection = quote_identifier(subquery_projection_name(subquery))
308
+ src_alias = "exwiw_scope_src_#{idx}"
309
+
300
310
  "JOIN (SELECT DISTINCT #{src_alias}.#{projection} AS exwiw_scope_id " \
301
311
  "FROM (#{compile_subquery(subquery)}) AS #{src_alias}) AS #{ids_alias} " \
302
312
  "ON #{outer_key} = #{ids_alias}.exwiw_scope_id"
303
313
  end
304
314
 
315
+ # The same scope subquery (e.g. the target-tenant users id-set) is embedded
316
+ # in every descendant table's extraction query, so the source DB would
317
+ # re-evaluate it once per table — the dominant cost on large tenants.
318
+ # During #execute, materialize each distinct id-set once into a session
319
+ # TEMPORARY TABLE and JOIN that instead. Keyed by the compiled SELECT, so
320
+ # nested scopes reuse already-materialized parents. #explain and
321
+ # #describe_query compile without the flag and stay side-effect free.
322
+ private def with_scope_materialization
323
+ @materialize_scopes = true
324
+ yield
325
+ ensure
326
+ @materialize_scopes = false
327
+ end
328
+
329
+ private def materialized_scope_table(subquery)
330
+ return nil unless @materialize_scopes
331
+ return nil if @scope_materialization_disabled
332
+
333
+ select_sql = "SELECT DISTINCT exwiw_scope_src.#{quote_identifier(subquery_projection_name(subquery))} " \
334
+ "AS exwiw_scope_id FROM (#{compile_subquery(subquery)}) AS exwiw_scope_src"
335
+
336
+ @scope_id_tables ||= {}
337
+ begin
338
+ prepare_scope_session
339
+ @scope_id_tables[select_sql] ||= create_scope_id_table(select_sql)
340
+ rescue StandardError => e
341
+ # e.g. the DB user lacks CREATE TEMPORARY TABLES; keep extracting with
342
+ # the inline (per-query) scope subqueries instead of failing the run.
343
+ @scope_materialization_disabled = true
344
+ @logger.warn("Disabling scope id-set materialization (#{e.class}: #{e.message}); " \
345
+ "falling back to inline scope subqueries.")
346
+ nil
347
+ end
348
+ end
349
+
350
+ # Read-only replicas (e.g. Aurora MySQL 3 reader instances) cannot create
351
+ # InnoDB temporary tables: with NO_ENGINE_SUBSTITUTION in sql_mode the
352
+ # CREATE fails outright (ERROR 3161) instead of substituting a permitted
353
+ # engine such as MyISAM. Strip that flag once per session — it only
354
+ # governs DDL engine fallback, not query semantics.
355
+ private def prepare_scope_session
356
+ return if @scope_session_prepared
357
+
358
+ mode = connection.query("SELECT @@SESSION.sql_mode").rows.dig(0, 0).to_s
359
+ cleaned = mode.split(',').reject { |part| part == 'NO_ENGINE_SUBSTITUTION' }.join(',')
360
+ connection.query("SET SESSION sql_mode = '#{cleaned}'") unless cleaned == mode
361
+ @scope_session_prepared = true
362
+ end
363
+
364
+ # If a step after CREATE fails, the temp table stays in the session until
365
+ # disconnect, but it is never referenced: the name is only cached (and thus
366
+ # only joined) after all three statements succeed, and the caller disables
367
+ # materialization for the rest of the run.
368
+ private def create_scope_id_table(select_sql)
369
+ name = "exwiw_scope_id_set_#{@scope_id_tables.size}"
370
+ connection.query("CREATE TEMPORARY TABLE #{quote_table_name(name)} AS #{select_sql}")
371
+ # ROW_COUNT() reads the CTAS insert count in O(1); a COUNT(*) would
372
+ # re-scan the whole id-set just for this log line.
373
+ count = connection.query("SELECT ROW_COUNT()").rows.dig(0, 0)
374
+ connection.query("ALTER TABLE #{quote_table_name(name)} ADD INDEX `index_exwiw_scope_id` (exwiw_scope_id)")
375
+ @logger.info(" Materialized scope id set #{name} (#{count} ids).")
376
+ name
377
+ end
378
+
305
379
  private def compile_where_condition(where_clause, table_name)
306
380
  # Use as it is if it's a raw query
307
381
  return where_clause if where_clause.is_a?(String)
@@ -18,8 +18,8 @@ module Exwiw
18
18
  # Array#map + Array#join (the same C-level path #to_bulk_insert uses) so it
19
19
  # stays close to whole-string speed — far faster than a naive row-at-a-time
20
20
  # IO#print (see script/bench_sql_dump.rb / docs/sql-dump-optimization-notes.md).
21
- # Mirrors MongoDB's default chunk size: bounded work per flush, but the SQL
22
- # adapters still emit ONE statement (byte-identical to the un-chunked build).
21
+ # Bounded work per flush WITHIN a single statement: flush boundaries never
22
+ # split a statement statement boundaries come from bulk_insert_chunk_size.
23
23
  STREAM_FLUSH_ROWS = 2_000
24
24
 
25
25
  # Build the whole INSERT statement as a single String. Kept for callers
@@ -37,15 +37,28 @@ module Exwiw
37
37
  # resident at a time rather than the entire table's INSERT string. Returns
38
38
  # [statement_count, record_count]; record_count is tallied during the single
39
39
  # streaming drain so the Runner needs no separate SELECT COUNT(*) pass.
40
+ # Chunks are buffered off `#each` rather than `results.each_slice(...)`:
41
+ # each_slice consults the receiver's `#size` (verified on CRuby for both
42
+ # the block and enumerator forms), which on a streaming result issues a
43
+ # redundant `SELECT COUNT(*)` — a second full pass over the same filter.
44
+ # Manual buffering walks the cursor exactly once.
40
45
  def write_inserts(io, results, table, chunk_size)
41
- chunks = chunk_size ? results.each_slice(chunk_size) : [results]
46
+ return [1, stream_single_insert(io, results, table)] unless chunk_size
47
+
42
48
  statement_count = 0
43
49
  record_count = 0
44
- chunks.each do |chunk_rows|
50
+ buffer = []
51
+ flush = lambda do
45
52
  io.print("\n") if statement_count.positive?
46
- record_count += stream_single_insert(io, chunk_rows, table)
53
+ record_count += stream_single_insert(io, buffer, table)
47
54
  statement_count += 1
55
+ buffer.clear
48
56
  end
57
+ results.each do |row|
58
+ buffer << row
59
+ flush.call if buffer.size >= chunk_size
60
+ end
61
+ flush.call unless buffer.empty?
49
62
  [statement_count, record_count]
50
63
  end
51
64
 
data/lib/exwiw/adapter.rb CHANGED
@@ -120,13 +120,14 @@ module Exwiw
120
120
  end
121
121
 
122
122
  # Default bulk-insert chunk size when a table config does not set one.
123
- # The Runner streams each chunk straight to the output file, so a non-nil
124
- # value here bounds how much serialized output (and how many transient
125
- # intermediate objects) live in memory at once. SQL adapters keep nil
126
- # (one statement per table, as before); adapters whose output is large
127
- # and built per-row (e.g. MongoDB JSONL) override with a positive value.
123
+ # The Runner streams each chunk straight to the output file, so this
124
+ # bounds how much serialized output lives in memory at once — and, for
125
+ # SQL adapters, bounds the size of each INSERT statement: a single
126
+ # statement covering a multi-million-row table exceeds the target
127
+ # server's max_allowed_packet at import time ("MySQL server has gone
128
+ # away"). Table configs can override per table.
128
129
  def default_bulk_insert_chunk_size
129
- nil
130
+ 10_000
130
131
  end
131
132
 
132
133
  # Write the bulk INSERT/JSONL output for `results` to the open `io`,
@@ -164,9 +165,9 @@ module Exwiw
164
165
  # untouched, so the cursor is walked exactly once. The chunk boundaries and
165
166
  # "\n" separators reproduce the each_slice output byte-for-byte.
166
167
  #
167
- # chunk_size is always positive for callers of this default (MongoDB); the
168
- # SQL adapters pass nil and override #write_inserts, so the unbounded
169
- # nil-branch buffer is never reached here in practice.
168
+ # chunk_size is always positive for callers of this default (MongoDB,
169
+ # whose adapter default is 1_000); the SQL adapters override
170
+ # #write_inserts (SqlBulkInsert) with their own chunked writer.
170
171
  def write_inserts(io, results, table, chunk_size)
171
172
  statement_count = 0
172
173
  record_count = 0
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.9.12"
4
+ VERSION = "0.9.14"
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.9.12
4
+ version: 0.9.14
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shia