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 +4 -4
- data/CHANGELOG.md +12 -0
- data/README.md +1 -1
- data/docs/sql-dump-optimization-notes.md +2 -1
- data/lib/exwiw/adapter/identifier_quoting.rb +3 -3
- data/lib/exwiw/adapter/mysql_adapter.rb +78 -4
- data/lib/exwiw/adapter/sql_bulk_insert.rb +18 -5
- data/lib/exwiw/adapter.rb +10 -9
- data/lib/exwiw/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1e66762378ba8b4357a2d42fff5fbb1f0c12e96979d0e59f6fdcaae46bd7da4d
|
|
4
|
+
data.tar.gz: 1c1cd268f516ab60b34915f77fc1ab2ed7e6cdfb8d4cc1e508abf026bb5519fa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
#
|
|
22
|
-
#
|
|
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
|
-
|
|
46
|
+
return [1, stream_single_insert(io, results, table)] unless chunk_size
|
|
47
|
+
|
|
42
48
|
statement_count = 0
|
|
43
49
|
record_count = 0
|
|
44
|
-
|
|
50
|
+
buffer = []
|
|
51
|
+
flush = lambda do
|
|
45
52
|
io.print("\n") if statement_count.positive?
|
|
46
|
-
record_count += stream_single_insert(io,
|
|
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
|
|
124
|
-
#
|
|
125
|
-
#
|
|
126
|
-
#
|
|
127
|
-
#
|
|
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
|
-
|
|
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
|
|
168
|
-
#
|
|
169
|
-
#
|
|
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