exwiw 0.5.1 → 0.5.3
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 +15 -0
- data/README.md +3 -2
- data/docs/optimization-notes.md +126 -0
- data/docs/optimize-mongodb-export-with-native-ext.md +229 -0
- data/docs/plans/2026-05-15-insert-000-schema-file.md +4 -4
- data/docs/plans/2026-05-16-mongodb-from-clean-scenario.md +8 -8
- data/docs/plans/2026-05-22-postgres-copy-mode-scenario-test.md +7 -7
- data/docs/plans/2026-05-31-ids-column-for-sql-adapters.md +1 -1
- data/docs/plans/2026-06-19-mongodb-export-remove-parallelism-native-ext.md +70 -0
- data/lib/exwiw/adapter/mongodb_adapter.rb +208 -43
- data/lib/exwiw/adapter/postgresql_adapter.rb +18 -1
- data/lib/exwiw/adapter.rb +10 -0
- data/lib/exwiw/determine_table_processing_order.rb +142 -25
- data/lib/exwiw/explain_runner.rb +1 -1
- data/lib/exwiw/runner.rb +25 -7
- data/lib/exwiw/version.rb +1 -1
- data/mise.toml +2 -2
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4df9132fdf081f28d9d0c53ade6bba8267f0f5ec5c40e19ab42fa45d9cfa5a10
|
|
4
|
+
data.tar.gz: 4f20a649c126a4617cd5e90fd09eb2eb7f92b068408d5ef9e927eac98a5fadda
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 309984a32b8cf593b5499427aa089c1c235332a5ca0cda584a2520d3dd0b073f33839adbe20487bce6bc31c20ba66434ce5e0cb1eab5e53bbdfa72bcf115707f
|
|
7
|
+
data.tar.gz: de045c9fef6510561b9fd4e713e6a9ba4be7c145f570aaf3da108cd7a4bf3512d0f8757c33e40470bf78b3e7dbebe5244013978c21a974610bca86ceeab54fd9
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.5.3] - 2026-06-19
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- **MongoDB: dumps now stream, bounding peak memory regardless of collection size** (default; no flag, byte-identical output). The adapter previously loaded each collection's entire result set into memory (`.to_a`) and built the whole collection's JSONL output as one string, so peak memory scaled with collection size. It now wraps the Mongo cursor in a lazy streaming result and writes output in chunks, so at most one chunk of documents (plus the small FK-propagation key arrays) is resident at a time. On a 20k-document × 30-embed collection this cut peak RSS by hundreds of MB and was also faster (less GC pressure). The per-document Extended-JSON masking was also precompiled per collection config, trimming per-document encoding cost. See [`docs/optimization-notes.md`](docs/optimization-notes.md) for the full investigation, and [`docs/optimize-mongodb-export-with-native-ext.md`](docs/optimize-mongodb-export-with-native-ext.md) for the proposed native-encoder follow-up.
|
|
10
|
+
|
|
11
|
+
## [0.5.2] - 2026-06-18
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- **PostgreSQL: an extension the restore target cannot create no longer aborts the restore, and `pglogical` is never emitted.** `dump_schema` prepends `CREATE EXTENSION IF NOT EXISTS` for every extension installed on the source, wrapped in a `DO` block that previously swallowed only `feature_not_supported` (the extension's binaries are unavailable on the target). A source on managed Postgres/AlloyDB carrying `pglogical` (logical replication) emitted `CREATE EXTENSION ... SCHEMA "pglogical"`, which on a target lacking that schema fails with `invalid_schema_name` — an error the handler did not catch, so the whole restore aborted. The handler now also catches `invalid_schema_name`, and instead of silently discarding the skip it re-raises it as a `WARNING` (carrying SQLSTATE and the original message) so the skip is visible in the restore logs rather than vanishing. `insufficient_privilege` is intentionally **not** caught: a restore role lacking `CREATE` privilege is a misconfiguration that must fail loudly. Separately, `pglogical` is now excluded from the prepended extensions entirely (alongside `plpgsql` and the `google_*`/`rds_*`/`aiven_*` managed-platform extensions) — it is a replication mechanism of the source, not part of the copied data.
|
|
16
|
+
- **Table processing order no longer aborts on `belongs_to` cycles.** Two distinct problems made `export`/`explain` fail with "Circular belongs_to dependency detected" on schemas that have no resolvable order: (1) a `belongs_to` whose target table is **not part of the run** — most commonly an embedded MongoDB collection, which is masked through its parent and never dumped on its own — was treated as a dependency that could never be satisfied, so every table that (transitively) referenced one froze and was misreported as a cycle; such out-of-run targets are now ignored when ordering. (2) A **genuine** cycle (e.g. `a belongs_to b` and `b belongs_to a`) now **breaks deterministically with a warning** instead of raising: exwiw emits the cycle member (a table in a strongly-connected component of the unresolved-dependency graph) with the fewest unresolved dependencies, preferring one that still has an already-ordered parent so its extraction stays constrained, and logs which `belongs_to` edge was dropped. The dropped edge is not enforced while ordering, so for the mongodb adapter that table may match a superset of rows (the not-yet-processed parent contributes no `$in` filter); mark one of the `belongs_to` entries forming the cycle with `ignore: true` to break it explicitly instead. Acyclic tables that merely wait on a cycle are never reordered ahead of their parents.
|
|
17
|
+
- **MongoDB: `dump_schema` tolerates collections declared in the schema but absent from the source database.** Listing indexes for a non-existent collection makes the driver raise `NamespaceNotFound` (code 26), which aborted the whole export when the schema covered more collections than the connected database actually had (schema/DB drift, or a sparse development database). The existing collections are now resolved once up front and indexes are emitted only for those; `createCollection` is still emitted for every configured collection, so the target schema is created in full.
|
|
18
|
+
- **MongoDB: a related collection that cannot be scoped no longer falls back to dumping the whole collection.** When a non-target collection's `belongs_to` parents all yield no ids to filter by — because every parent matched nothing or is not dumped on its own (e.g. an embedded collection) — the assembled filter is empty. Previously that empty filter was sent as `find({})`, scanning and dumping the **entire collection across every scope** (a cross-scope data-exposure risk). Such a collection is now constrained to match no rows and a warning is logged instead. A collection with no `belongs_to` at all is still treated as reference/master data and dumped in full.
|
|
19
|
+
|
|
5
20
|
## [0.5.1] - 2026-06-18
|
|
6
21
|
|
|
7
22
|
### Added
|
data/README.md
CHANGED
|
@@ -647,6 +647,7 @@ The MongoDB adapter is experimental. To use it:
|
|
|
647
647
|
- `--ids` values are coerced to the type actually stored in `_id` before filtering: integer-looking ids become `Integer`, 24-char hex ids become `BSON::ObjectId` (Mongoid's default `_id` type — a plain String would never match an ObjectId), and any other string is left as-is.
|
|
648
648
|
- `--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.
|
|
649
649
|
- `--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 use `--ids-column` instead (see below).
|
|
650
|
+
- 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. The dominant remaining cost is encoding each document to MongoDB Extended JSON (pure Ruby); 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 proposed native-encoder follow-up. Benchmark your own data with `script/bench_mongodb_dump.rb`.
|
|
650
651
|
- Output is JSON Lines (`insert-{idx}-{collection}.jsonl`) using MongoDB Extended JSON (relaxed mode). Import with `mongoimport`:
|
|
651
652
|
```bash
|
|
652
653
|
mongoimport --db app_dev --collection users --file dump/insert-002-users.jsonl
|
|
@@ -664,7 +665,7 @@ The MongoDB adapter is experimental. To use it:
|
|
|
664
665
|
MongoDB models often store one-to-many relationships as embedded subdocument arrays (e.g. `users` documents with a `posts: [...]` field). To mask fields inside embedded subdocuments, declare a separate config with `embedded_in`:
|
|
665
666
|
|
|
666
667
|
```jsonc
|
|
667
|
-
//
|
|
668
|
+
// e2e/users.json — top-level collection
|
|
668
669
|
{
|
|
669
670
|
"name": "users",
|
|
670
671
|
"primary_key": "_id",
|
|
@@ -676,7 +677,7 @@ MongoDB models often store one-to-many relationships as embedded subdocument arr
|
|
|
676
677
|
]
|
|
677
678
|
}
|
|
678
679
|
|
|
679
|
-
//
|
|
680
|
+
// e2e/posts.json — embedded under users.posts
|
|
680
681
|
{
|
|
681
682
|
"name": "posts",
|
|
682
683
|
"primary_key": "_id",
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# MongoDB dump performance: investigation notes
|
|
2
|
+
|
|
3
|
+
This records what was learned while making the MongoDB adapter's dump faster and
|
|
4
|
+
lighter, **what shipped**, and **what was explored and deliberately removed**.
|
|
5
|
+
It exists so the removed work isn't re-discovered from scratch and so the
|
|
6
|
+
trade-offs behind the current design are legible.
|
|
7
|
+
|
|
8
|
+
The reproducible harness is `script/bench_mongodb_dump.rb` (seeds a synthetic
|
|
9
|
+
large/embed-heavy dataset and measures the dump phases). The correctness anchor
|
|
10
|
+
throughout is `spec/insert_output_snapshot_spec.rb` — a **byte-exact** snapshot
|
|
11
|
+
of the dump output; every change below was required to keep it green.
|
|
12
|
+
|
|
13
|
+
## The two hotspots
|
|
14
|
+
|
|
15
|
+
On an embed-heavy benchmark (20k users × 30 embedded posts → ~154 MB JSONL):
|
|
16
|
+
|
|
17
|
+
1. **Memory.** Two compounding costs. `MongodbAdapter#execute` did `.to_a`,
|
|
18
|
+
loading the entire result set onto the heap (~600–900 MB / ~9.5M Ruby
|
|
19
|
+
objects for 20k docs). Separately, with no chunking the Runner built the whole
|
|
20
|
+
collection's JSONL output as **one giant string** before writing, held
|
|
21
|
+
simultaneously with the result set.
|
|
22
|
+
2. **CPU.** `doc.as_extended_json(mode: :relaxed)` is ~82% of per-document
|
|
23
|
+
serialization (~104µs of ~124µs for a 30-post doc). It recursively rebuilds
|
|
24
|
+
the document into a new intermediate Hash tree, so cost scales with embedding
|
|
25
|
+
depth/count; `JSON.generate` over that tree is comparatively cheap (~10µs).
|
|
26
|
+
|
|
27
|
+
## What shipped (default, no flags, byte-identical)
|
|
28
|
+
|
|
29
|
+
- **Chunked output streaming.** The Runner writes each bulk-insert chunk straight
|
|
30
|
+
to the file instead of joining the whole table's output into one string.
|
|
31
|
+
`MongodbAdapter` sets a positive `default_bulk_insert_chunk_size` (1000) so
|
|
32
|
+
MongoDB output is chunked by default while SQL adapters keep one statement per
|
|
33
|
+
table. Cut peak RSS ~112 MB and was ~30% faster, byte-identical.
|
|
34
|
+
- **Streaming result set.** `#execute` returns a lazy `StreamingResult` wrapping
|
|
35
|
+
the Mongo cursor instead of `.to_a`. The Runner pulls documents through
|
|
36
|
+
`each_slice`, so only one chunk is resident at a time. `#size` is answered with
|
|
37
|
+
a cheap `count_documents` (index-only) rather than draining the cursor, and the
|
|
38
|
+
FK-propagation `@state` is captured *as the cursor streams* and published once
|
|
39
|
+
the pass completes (the Runner always fully consumes a non-empty result, so
|
|
40
|
+
propagation is unaffected). Cut peak RSS growth ~360 MB and wall time ~40%.
|
|
41
|
+
- **Precompiled masking (`MaskPlan`).** Masking runs over every document **and**
|
|
42
|
+
every embedded subdocument, so per-config decisions (which fields carry a
|
|
43
|
+
`replace_with`, how each template splits, where embedded children live) were
|
|
44
|
+
recomputed many times per document. Compiling a `MaskPlan` once per collection
|
|
45
|
+
config dropped per-document masking ~17–22% and ~35 allocations/doc, scaling
|
|
46
|
+
down with embedding count. Byte-identical.
|
|
47
|
+
|
|
48
|
+
Net default result: memory is bounded by chunk size rather than collection size,
|
|
49
|
+
with a meaningful wall-time improvement and no API/flag surface.
|
|
50
|
+
|
|
51
|
+
## What was explored and removed
|
|
52
|
+
|
|
53
|
+
After the memory work, the remaining cost was almost entirely the pure-Ruby
|
|
54
|
+
`as_extended_json`. Threads give **zero** speedup (it holds the GVL), and a
|
|
55
|
+
hand-rolled pure-Ruby fused encoder is **slower** than `as_extended_json +
|
|
56
|
+
JSON.generate` (per-leaf `.to_json` C-call overhead; `JSON.generate` does the
|
|
57
|
+
whole tree in one C pass). `bson` 5.2.0 has no native Extended-JSON serializer to
|
|
58
|
+
borrow (`to_extended_json` is literally `as_extended_json(**opts).to_json`, all
|
|
59
|
+
pure Ruby). That left two levers, both of which were built, measured, and then
|
|
60
|
+
**removed for being disproportionately complex**:
|
|
61
|
+
|
|
62
|
+
### Fork-parallel serialization (`--parallel-workers=N`)
|
|
63
|
+
|
|
64
|
+
Forked `N` worker processes to serialize contiguous document slices in parallel,
|
|
65
|
+
parent concatenating parts in order (byte-identical). The *serialization step*
|
|
66
|
+
parallelized ~2–2.5× at 4–8 workers, **but the end-to-end dump speedup was only
|
|
67
|
+
~1.1–1.4×** on embed-heavy data. Reason (Amdahl): ~40% of dump wall time is the
|
|
68
|
+
**serial Mongo cursor BSON→Ruby decode** in the parent, which serialization
|
|
69
|
+
parallelism cannot touch — capping the win — and fork/concat overhead eroded most
|
|
70
|
+
of the rest.
|
|
71
|
+
|
|
72
|
+
### Cursor-parallel fetch (`--cursor-parallel`)
|
|
73
|
+
|
|
74
|
+
Went further: split each collection into `N` disjoint `_id` ranges, each fetched
|
|
75
|
+
by a forked worker with its own connection+cursor, so the **decode** was
|
|
76
|
+
parallelized too. Measured byte-identical at ~2.5–5.5× depending on dataset size
|
|
77
|
+
— a real, larger win. But it required a lot of machinery:
|
|
78
|
+
|
|
79
|
+
- `_id`-range partitioning (an index-only scan + range split) — `MongoIdPartitioner`.
|
|
80
|
+
- A fork orchestrator writing ordered part files + Marshal'd state sidecars — `ForkedPartWriter`.
|
|
81
|
+
- Distributed FK-propagation: each worker captures its range's `@state` slice and
|
|
82
|
+
the parent merges them in range order — `PropagationCapture`.
|
|
83
|
+
- A per-worker fresh-connection builder (the Mongo driver is not fork-safe).
|
|
84
|
+
- New adapter seams (`write_bulk_insert`, `write_inserts`) and CLI→Runner→Adapter
|
|
85
|
+
threading for two flags + their validations.
|
|
86
|
+
- A user-visible caveat: per-range cursors must `sort(_id)`, so the output is
|
|
87
|
+
**ordered by `_id` rather than natural order** — a different byte stream
|
|
88
|
+
(semantically equivalent re-import), so it could not be the snapshot-tested
|
|
89
|
+
default.
|
|
90
|
+
|
|
91
|
+
### Why both were removed
|
|
92
|
+
|
|
93
|
+
The cursor-parallel win was real but bought with multi-process orchestration,
|
|
94
|
+
IPC, distributed state, a fresh-connection-per-worker requirement, fork
|
|
95
|
+
fallbacks for Windows/JRuby, and a non-default output ordering — a large,
|
|
96
|
+
permanently-maintained surface for a single adapter's export path. The
|
|
97
|
+
maintainer's call was that this is **over-engineered** for the benefit, and that
|
|
98
|
+
the CPU hotspot is better addressed by the lever every earlier iteration kept
|
|
99
|
+
pointing at: a native (C) Extended-JSON encoder. The memory wins above are
|
|
100
|
+
unrelated to the parallelism and were kept.
|
|
101
|
+
|
|
102
|
+
## Where the speedup goes next
|
|
103
|
+
|
|
104
|
+
A C extension can collapse `as_extended_json + JSON.generate` into one native
|
|
105
|
+
tree-walk (no intermediate tree, no second pass), as a flag-free, fork-free,
|
|
106
|
+
single-process win — bounded by the same serial-decode ceiling (~2.5×) the
|
|
107
|
+
`--parallel-workers` path hit, since it also doesn't touch the driver's decode.
|
|
108
|
+
The full design (byte-identity strategy, fast-path vs Ruby-delegate types,
|
|
109
|
+
optional-load + pure-Ruby fallback, packaging) is in
|
|
110
|
+
[`optimize-mongodb-export-with-native-ext.md`](./optimize-mongodb-export-with-native-ext.md).
|
|
111
|
+
|
|
112
|
+
## Methodology notes (for re-running)
|
|
113
|
+
|
|
114
|
+
- The CPU hotspot reproduces **with no database**: the Mongo driver hands back
|
|
115
|
+
plain Ruby `Hash` + `BSON::ObjectId`/`Time`, so synthesizing that shape in
|
|
116
|
+
memory yields the exact `as_extended_json + JSON.generate` cost and runs under
|
|
117
|
+
the normal sandbox. DB-touching measurement needs live mongo on `localhost:27017`
|
|
118
|
+
(the dev sandbox blocks it — disable the sandbox for those runs).
|
|
119
|
+
- In-process sequential bench passes accumulate RSS (Ruby reclaims to the OS
|
|
120
|
+
lazily), which inflates a later serial baseline and overstates a parallel
|
|
121
|
+
speedup; isolate sections in fresh processes for defensible numbers.
|
|
122
|
+
- Chunk size never changes output **bytes** — the Runner inserts the same `"\n"`
|
|
123
|
+
between chunks that `to_bulk_insert` inserts between documents — so it is purely
|
|
124
|
+
a memory/throughput knob, safe against the snapshot guard.
|
|
125
|
+
- Ruby 4.0 removed the `benchmark` stdlib from default gems; use
|
|
126
|
+
`Process.clock_gettime(Process::CLOCK_MONOTONIC)`.
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# Design: optional native (C) extension for the MongoDB Extended-JSON encoder
|
|
2
|
+
|
|
3
|
+
Status: **proposed / not implemented.** This document captures the design for a
|
|
4
|
+
future change. It is the planned successor to the fork/cursor parallelism that
|
|
5
|
+
was removed (see [`optimization-notes.md`](./optimization-notes.md)).
|
|
6
|
+
|
|
7
|
+
## Motivation
|
|
8
|
+
|
|
9
|
+
When the MongoDB adapter dumps embed-heavy documents, the dominant CPU cost is
|
|
10
|
+
turning each decoded Mongo document (a Ruby `Hash` containing `BSON::ObjectId` /
|
|
11
|
+
`Time` / nested `Hash`+`Array`) into one JSONL line of MongoDB **Relaxed
|
|
12
|
+
Extended JSON**. Today that is:
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
# lib/exwiw/adapter/mongodb_adapter.rb
|
|
16
|
+
JSON.generate(doc.as_extended_json(mode: :relaxed))
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`as_extended_json` (in the pure-Ruby `bson` gem) **recursively rebuilds the
|
|
20
|
+
whole document into a new intermediate Hash tree** (`ObjectId -> {"$oid"=>…}`,
|
|
21
|
+
`Time -> {"$date"=>…}`, every subdoc/array re-allocated), and then
|
|
22
|
+
`JSON.generate` walks that tree a *second* time. For a 30-embedded-post doc this
|
|
23
|
+
was measured at ~130µs/doc and is ~82% of per-document serialization cost.
|
|
24
|
+
|
|
25
|
+
Earlier experiments established the levers:
|
|
26
|
+
|
|
27
|
+
- Threads give **zero** speedup — `as_extended_json` is pure Ruby and holds the GVL.
|
|
28
|
+
- A pure-Ruby fused single-pass encoder is **slower** (per-leaf `.to_json` C-call
|
|
29
|
+
overhead beats it; `JSON.generate` does the whole tree in one C pass).
|
|
30
|
+
- Multi-process parallelism worked but was judged over-engineered and removed.
|
|
31
|
+
|
|
32
|
+
The remaining lever is a **C extension**: one native walk that emits the
|
|
33
|
+
Extended-JSON text directly — no intermediate transformed-Hash tree, no second
|
|
34
|
+
JSON pass.
|
|
35
|
+
|
|
36
|
+
## Goals / non-goals
|
|
37
|
+
|
|
38
|
+
- **Goal:** a native encoder that is **byte-for-byte identical** to the current
|
|
39
|
+
pure-Ruby path, behind an **optional** load with a pure-Ruby fallback so
|
|
40
|
+
exwiw stays installable as a pure-Ruby gem (JRuby/TruffleRuby, or any host
|
|
41
|
+
where compilation fails, keep working).
|
|
42
|
+
- **Non-goal:** speeding up the Mongo cursor's BSON→Ruby *decode*. That lives in
|
|
43
|
+
the `mongo`/`bson` driver and is ~40% of total dump wall time. A serialization
|
|
44
|
+
C extension is therefore bounded to roughly the same end-to-end ceiling
|
|
45
|
+
(~2.5×) the removed `--parallel-workers` path had — it does **not** reach the
|
|
46
|
+
removed cursor-parallel path's 3.4–5.5×. This is an accepted trade for a far
|
|
47
|
+
simpler, flag-free, fork-free implementation.
|
|
48
|
+
|
|
49
|
+
## Exact serialization semantics to reproduce (verified against bson 5.2.0)
|
|
50
|
+
|
|
51
|
+
The byte-exact anchor is `spec/insert_output_snapshot_spec.rb` (committed
|
|
52
|
+
`spec/insert_output_snapshots/mongodb/*.jsonl` fixtures). The C encoder must
|
|
53
|
+
reproduce the following exactly. All rows below were verified empirically and,
|
|
54
|
+
for `Time`, against `bson-5.2.0/lib/bson/time.rb:72-89`.
|
|
55
|
+
|
|
56
|
+
| Ruby value | Relaxed Extended JSON output | Notes |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| `BSON::ObjectId` | `{"$oid":"<24 lowercase hex>"}` | hex via `ObjectId#to_s` |
|
|
59
|
+
| `Time` (year 1970..9999, sub-second) | `{"$date":"2021-01-02T03:04:05.678Z"}` | floor to ms, `strftime('%Y-%m-%dT%H:%M:%S.%LZ')` |
|
|
60
|
+
| `Time` (year 1970..9999, whole second) | `{"$date":"2021-01-02T03:04:05Z"}` | **no** fraction when `usec == 0` |
|
|
61
|
+
| `Time` (year <1970 or >9999) | `{"$date":{"$numberLong":"<ms>"}}` | `ms = sec*1000 + usec.divmod(1000).first` |
|
|
62
|
+
| `Integer` (fits int64) | bare `42` / `9000000000` | |
|
|
63
|
+
| `Integer` (outside int64) | **raises `RangeError`** | `"Integer … too big to be represented as a MongoDB integer"` |
|
|
64
|
+
| `Float` | `JSON.generate(float)` form | `1e20 → 1e+20` (**not** `Float#to_s`'s `1.0e+20`); `100.0 → 100.0`; `-0.0 → -0.0` |
|
|
65
|
+
| `String` | JSON string | escape only `\b \t \n \f \r \" \\`; other `<0x20` as lowercase `\u00xx`; `/`, DEL, U+2028/U+2029, non-ASCII left raw |
|
|
66
|
+
| `true` / `false` / `nil` | `true` / `false` / `null` | |
|
|
67
|
+
| `Hash` | `{…}` | **insertion order** preserved; keys are Strings (JSON-escaped) |
|
|
68
|
+
| `Array` | `[…]` | |
|
|
69
|
+
|
|
70
|
+
`bson/time.rb` boundary (the highest-risk piece), verified verbatim:
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
def as_extended_json(**options)
|
|
74
|
+
if options[:mode] == :relaxed && (1970..9999).include?(utc_time.year)
|
|
75
|
+
if utc_time.usec != 0
|
|
76
|
+
utc_time = utc_time.floor(3) # floor to millisecond
|
|
77
|
+
{'$date' => utc_time.strftime('%Y-%m-%dT%H:%M:%S.%LZ')}
|
|
78
|
+
else
|
|
79
|
+
{'$date' => utc_time.strftime('%Y-%m-%dT%H:%M:%SZ')}
|
|
80
|
+
end
|
|
81
|
+
else
|
|
82
|
+
msec = utc_time.usec.divmod(1000).first
|
|
83
|
+
{'$date' => {'$numberLong' => (sec * 1000 + msec).to_s}}
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Fast-path vs delegate (the byte-identity strategy)
|
|
89
|
+
|
|
90
|
+
The encoder splits values into a **native fast path** and a **Ruby delegate**:
|
|
91
|
+
|
|
92
|
+
- **Native (in C):** `Hash`, `Array`, `String`, `Integer` within int64,
|
|
93
|
+
`true`/`false`/`nil`, and `BSON::ObjectId`. These are the structural bulk plus
|
|
94
|
+
the single most common leaf (`_id`), and their formatting is simple and stable.
|
|
95
|
+
- **Delegate to Ruby** — call back into
|
|
96
|
+
`JSON.generate(value.as_extended_json(mode: :relaxed))` for the individual
|
|
97
|
+
value and splice the returned fragment into the buffer:
|
|
98
|
+
- `Float` — `Float#to_s` diverges from `JSON.generate` for scientific notation
|
|
99
|
+
(`1e20`), so never reformat floats in C.
|
|
100
|
+
- `Time` — variable fractional digits + the `[1970,9999]` `$numberLong`
|
|
101
|
+
boundary + ms flooring; too fragile to risk in C v1.
|
|
102
|
+
- out-of-int64 `Integer` — must surface the identical `RangeError`.
|
|
103
|
+
- any unrecognized class — `Decimal128`, `BSON::Binary`, `Symbol`, `Regexp`,
|
|
104
|
+
`Date`, `BSON::Timestamp`, etc.
|
|
105
|
+
|
|
106
|
+
**Why delegating is provably byte-identical:** `Hash#as_extended_json` and
|
|
107
|
+
`Array#as_extended_json` are *non-transforming structural recursion* — they map
|
|
108
|
+
over children and call `as_extended_json` on each. So the bytes produced for any
|
|
109
|
+
sub-value `v` by `JSON.generate(v.as_extended_json(mode: :relaxed))` are exactly
|
|
110
|
+
the bytes that the whole-document `JSON.generate(doc.as_extended_json(...))`
|
|
111
|
+
would have produced for that position. The native walk can therefore hand any
|
|
112
|
+
value it does not want to format to Ruby and splice the result, with no
|
|
113
|
+
divergence.
|
|
114
|
+
|
|
115
|
+
`Time` and `Float` are candidates for later promotion into the native path if a
|
|
116
|
+
benchmark shows the per-leaf delegate call is a meaningful fraction of the win
|
|
117
|
+
(timestamp-heavy docs call out to Ruby once per `Time` field).
|
|
118
|
+
|
|
119
|
+
## C source & buffer design
|
|
120
|
+
|
|
121
|
+
- `Exwiw::ExtJson.encode_native(doc) -> String` — returns one JSONL line, **no**
|
|
122
|
+
trailing `\n` (the caller/Runner owns separators).
|
|
123
|
+
- Recursive emitter writing into a single growing buffer (`rb_str_buf_new` +
|
|
124
|
+
`rb_str_cat`/`rb_str_buf_cat`, or a `malloc` buffer finalized to an
|
|
125
|
+
`rb_utf8_str_new`). Result string is UTF-8.
|
|
126
|
+
- Type dispatch via `TYPE()` for the immediates/`T_HASH`/`T_ARRAY`/`T_STRING`/
|
|
127
|
+
`T_FLOAT`/`T_FIXNUM`/`T_BIGNUM`, and a cached `BSON::ObjectId` class reference
|
|
128
|
+
(`rb_const_get`) compared with `rb_obj_is_kind_of` for ObjectId.
|
|
129
|
+
- `Hash`: `rb_hash_foreach` preserves insertion order; emit `key:value` pairs;
|
|
130
|
+
keys are Strings run through the same string escaper.
|
|
131
|
+
- Delegate path: a cached `ID` for a Ruby helper (e.g.
|
|
132
|
+
`Exwiw::ExtJson.encode_fragment(v)`), `rb_funcall`'d, returning the JSON
|
|
133
|
+
fragment String to splice. The `RangeError` for oversized integers propagates
|
|
134
|
+
naturally through the delegate.
|
|
135
|
+
- String escaper implemented in C to match the table above (no per-leaf Ruby
|
|
136
|
+
call for the common String case).
|
|
137
|
+
|
|
138
|
+
## Packaging, optional load, fallback
|
|
139
|
+
|
|
140
|
+
- **gemspec:** `spec.extensions = ["ext/exwiw/ext_json/extconf.rb"]`. With
|
|
141
|
+
`extensions` set, `gem install exwiw` compiles automatically; hosts that can't
|
|
142
|
+
compile fall back at runtime (below).
|
|
143
|
+
- **`ext/exwiw/ext_json/extconf.rb`:** `require "mkmf"` (stdlib) +
|
|
144
|
+
`create_makefile("exwiw/ext_json_native")`. The compiled lib is named
|
|
145
|
+
`ext_json_native` (distinct from the `ext_json.rb` shim) to avoid a
|
|
146
|
+
`require` self-collision.
|
|
147
|
+
- **`ext/exwiw/ext_json/ext_json.c`:** the emitter; defines
|
|
148
|
+
`Exwiw::ExtJson.encode_native`.
|
|
149
|
+
- **`lib/exwiw/ext_json.rb`** (the shim, always loaded):
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
require "json"
|
|
153
|
+
|
|
154
|
+
module Exwiw
|
|
155
|
+
module ExtJson
|
|
156
|
+
module_function
|
|
157
|
+
|
|
158
|
+
# Pure-Ruby fragment encoder used by both the fallback and the native
|
|
159
|
+
# delegate path. Byte-identical to today's behavior.
|
|
160
|
+
def encode_fragment(value)
|
|
161
|
+
JSON.generate(value.respond_to?(:as_extended_json) ? value.as_extended_json(mode: :relaxed) : value)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
begin
|
|
165
|
+
require "exwiw/ext_json_native" # defines Exwiw::ExtJson.encode_native
|
|
166
|
+
def encode(doc) = encode_native(doc)
|
|
167
|
+
rescue LoadError
|
|
168
|
+
def encode(doc) = encode_fragment(doc) # exact current behavior
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
- **`Rakefile`:** `require "rake/extensiontask"`;
|
|
175
|
+
`Rake::ExtensionTask.new("ext_json_native") { |e| e.ext_dir = "ext/exwiw/ext_json" }`;
|
|
176
|
+
make the `spec` task depend on `compile`.
|
|
177
|
+
- **Dev dependency:** add `rake-compiler` (Gemfile / gemspec dev deps). `mkmf`
|
|
178
|
+
is stdlib, no runtime dep added.
|
|
179
|
+
- **`.gitignore`:** ignore built artifacts (`lib/exwiw/*.bundle`,
|
|
180
|
+
`lib/exwiw/*.so`, `ext/**/*.o`, `ext/**/Makefile`). Commit only the `ext/`
|
|
181
|
+
sources; the gemspec ships files via `git ls-files`.
|
|
182
|
+
- **`lib/exwiw.rb`:** add `require_relative "exwiw/ext_json"`.
|
|
183
|
+
|
|
184
|
+
## Integration point
|
|
185
|
+
|
|
186
|
+
In `lib/exwiw/adapter/mongodb_adapter.rb`, the per-document serialize step
|
|
187
|
+
becomes (masking still runs in Ruby first; only the encode changes):
|
|
188
|
+
|
|
189
|
+
```ruby
|
|
190
|
+
def to_bulk_insert(rows, config)
|
|
191
|
+
plan = mask_plan(config)
|
|
192
|
+
rows.map do |doc|
|
|
193
|
+
apply_mask_plan!(doc, plan)
|
|
194
|
+
Exwiw::ExtJson.encode(doc) # was: JSON.generate(extended_json(doc))
|
|
195
|
+
end.join("\n")
|
|
196
|
+
end
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
The private `#extended_json` helper is removed — its logic (including the
|
|
200
|
+
`respond_to?(:as_extended_json)` guard) moves into `ExtJson.encode_fragment`.
|
|
201
|
+
|
|
202
|
+
## Test & benchmark strategy
|
|
203
|
+
|
|
204
|
+
- **`spec/ext_json_spec.rb`** (DB-free; the primary byte-identity guard, runs in
|
|
205
|
+
normal CI): assert `encode_native(doc) == encode_fragment(doc)` over a fuzz of
|
|
206
|
+
representative shapes — ObjectId; nested hashes/arrays; `Time` across the year
|
|
207
|
+
boundary, whole-second (no fraction), and sub-second; strings with control
|
|
208
|
+
chars / quotes / backslashes / non-ASCII / U+2028; ints, bignums (assert the
|
|
209
|
+
same `RangeError`), floats (`1e20`, `-0.0`, `100.0`); `nil`; empty
|
|
210
|
+
hash/array/string. Skip the native half with a clear message when the lib
|
|
211
|
+
isn't compiled, so the suite still passes on a fallback-only host.
|
|
212
|
+
- **`spec/insert_output_snapshot_spec.rb`** (live mongo on 27017): the byte-exact
|
|
213
|
+
fixtures must stay green with the native encoder built.
|
|
214
|
+
- **Microbench** (extend `script/bench_mongodb_dump.rb`): native-encode vs
|
|
215
|
+
Ruby-fallback throughput on DB-free synthesized embed-heavy docs, plus the live
|
|
216
|
+
path on 20k×30, to quantify the real speedup.
|
|
217
|
+
|
|
218
|
+
## Risk register
|
|
219
|
+
|
|
220
|
+
1. **Time formatting** — variable fraction + ms flooring + `$numberLong`
|
|
221
|
+
boundary. Mitigated by delegating `Time` to Ruby in v1.
|
|
222
|
+
2. **Float formatting** — `Float#to_s` ≠ `JSON.generate`. Mitigated by delegating.
|
|
223
|
+
3. **String escaping** — must match JSON exactly. Implemented in C, fuzz-tested
|
|
224
|
+
vs the Ruby fallback.
|
|
225
|
+
4. **Hash key order** — preserved via `rb_hash_foreach`.
|
|
226
|
+
5. **Oversized integers** — delegate so the same `RangeError` surfaces.
|
|
227
|
+
6. **Encoding** — emit UTF-8; pass non-ASCII bytes through unescaped (matches JSON).
|
|
228
|
+
7. **Build/portability** — optional load + pure-Ruby fallback keeps non-CRuby and
|
|
229
|
+
no-compiler installs working.
|
|
@@ -117,7 +117,7 @@ Error: `pg_dump` not found in PATH. exwiw needs pg_dump to generate insert-000-s
|
|
|
117
117
|
| `lib/exwiw/ddl_postprocessor.rb` (新規) | `IF NOT EXISTS` 書き換え / DO ブロックラップ |
|
|
118
118
|
| `lib/exwiw.rb` | 新規ファイルの require |
|
|
119
119
|
| `README.md` | `dump/` の出力に `insert-000-schema.{sql,js}` を追記、import 手順を更新 |
|
|
120
|
-
| `spec/adapter/sqlite3_adapter_spec.rb` | `dump_schema` 統合テスト (`
|
|
120
|
+
| `spec/adapter/sqlite3_adapter_spec.rb` | `dump_schema` 統合テスト (`e2e/initdb/init.sqlite3` に対して実行し、出力が `CREATE TABLE IF NOT EXISTS` を含むことを assert) |
|
|
121
121
|
| `spec/adapter/mongodb_adapter_spec.rb` | `dump_schema` テスト (db スタブで `listIndexes` を返し、出力 JS を assert) |
|
|
122
122
|
| `spec/runner_spec.rb` | `insert-000-schema.sql` が `output_dir` に書かれることを assert (Sqlite3 経由で実際に流れることを確認) |
|
|
123
123
|
|
|
@@ -134,9 +134,9 @@ Error: `pg_dump` not found in PATH. exwiw needs pg_dump to generate insert-000-s
|
|
|
134
134
|
2. `bundle exec rspec spec/adapter/mongodb_adapter_spec.rb` — mongo クライアントをスタブして JS 出力に `db.createCollection("users")` と該当 collection の `createIndex(...)` が含まれることを確認。
|
|
135
135
|
|
|
136
136
|
### E2E (scenario スクリプト経由)
|
|
137
|
-
3. `
|
|
138
|
-
4. `
|
|
139
|
-
5. `
|
|
137
|
+
3. `e2e/test_with_sqlite3.sh` を実行し、`dump/insert-000-schema.sql` が生成されることと、空 DB に対して `sqlite3 empty.db < dump/insert-000-schema.sql && for f in dump/insert-*.sql; do sqlite3 empty.db < $f; done` が成功することを確認する。
|
|
138
|
+
4. `e2e/test_with_mysql2.sh`, `e2e/test_with_postgresql.sh` も同様に、`mysql empty_db < dump/insert-000-schema.sql` / `psql empty_db -f dump/insert-000-schema.sql` が成功 → 続けて insert ファイル群が流せることを確認。**`mysqldump` / `pg_dump` を docker compose のコンテナ内 (`compose.yml` で起動する DB コンテナ) で実行する必要がある場合は、scenario スクリプトを更新する。**
|
|
139
|
+
5. `e2e/test_with_mongodb.sh` を実行し、`dump/insert-000-schema.js` が出力されることと、空 DB に対して `mongosh "mongodb://localhost/empty_db" < dump/insert-000-schema.js` が成功すること、続いて `mongoimport` で各 jsonl が流せることを確認。
|
|
140
140
|
6. **idempotency 確認**: 同じ schema ファイルを 2 回流してもエラーにならないこと (`IF NOT EXISTS` / `DO $$ EXCEPTION WHEN duplicate_object` / `try/catch on createCollection` が効いている)。
|
|
141
141
|
|
|
142
142
|
### 手動確認のチェックポイント
|
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
`createCollection` / `createIndex` を書き出す実装を既に持っているが、scenario 側で
|
|
7
7
|
これを apply するパスが無く、CI でも検証できていなかった。具体的なギャップ:
|
|
8
8
|
|
|
9
|
-
1. `
|
|
9
|
+
1. `e2e/setup_with_mongodb.rb` は seed を `insert_many` で流すだけで、index を一切作っていない
|
|
10
10
|
2. その結果 `tmp/mongodb/insert-000-schema.js` は `createCollection` 行のみで `createIndex` が 0 行
|
|
11
|
-
3. `
|
|
11
|
+
3. `e2e/import_with_mongodb.rb` は `insert-*.jsonl` だけを glob して処理しており、`insert-000-schema.js` を一切実行しない
|
|
12
12
|
|
|
13
13
|
sqlite3 / mysql2 / postgresql で導入済みの「from clean DB から立ち上げる」流れと
|
|
14
14
|
MongoDB の `insert-000-schema.js` が連動していない状態だった (issue #16)。
|
|
@@ -25,10 +25,10 @@ MongoDB の `insert-000-schema.js` が連動していない状態だった (issu
|
|
|
25
25
|
### scenario 層
|
|
26
26
|
| パス | 変更 |
|
|
27
27
|
|---|---|
|
|
28
|
-
| `
|
|
29
|
-
| `
|
|
30
|
-
| `
|
|
31
|
-
| `
|
|
28
|
+
| `e2e/setup_with_mongodb.rb` | seed 流し込みの後に 3 種類の代表的 index を作る (unique `shops.name` / plain `users.email` / 複合 `orders.shop_id+user_id`) |
|
|
29
|
+
| `e2e/import_with_mongodb.rb` | `--no-drop` と `--input-dir DIR` フラグを追加。from-clean は drop すると schema.js が作った index ごと消えてしまうため |
|
|
30
|
+
| `e2e/verify_with_mongodb.rb` | `--with-indexes` で target collection の index を assert (default scenario では import 時に drop されるのでスキップ) |
|
|
31
|
+
| `e2e/test_with_mongodb_from_clean.sh` (新規) | `mongosh dropDatabase` → exwiw 実行 → `mongosh insert-000-schema.js` → `import --no-drop --input-dir tmp/mongodb-clean` → `verify --with-indexes` |
|
|
32
32
|
| `.github/workflows/scenario.yml` | with_mongodb job に `mongodb-mongosh` install ステップと `test_with_mongodb_from_clean.sh` 実行ステップを追加。apt repo の codename は `jammy` 固定 (ubuntu-latest が noble に上がる前提) |
|
|
33
33
|
|
|
34
34
|
### snapshot test 層
|
|
@@ -56,8 +56,8 @@ MongoDB の `insert-000-schema.js` が連動していない状態だった (issu
|
|
|
56
56
|
|
|
57
57
|
## Verification
|
|
58
58
|
|
|
59
|
-
- `bash
|
|
60
|
-
- `bash
|
|
59
|
+
- `bash e2e/test_with_mongodb.sh` 既存 scenario 維持を確認 ✓
|
|
60
|
+
- `bash e2e/test_with_mongodb_from_clean.sh` 新規 scenario 通過を確認
|
|
61
61
|
(indexes round-trip OK) ✓
|
|
62
62
|
- `bundle exec rspec` 全 153 examples / 0 failures ✓
|
|
63
63
|
- `tmp/mongodb-clean/insert-000-schema.js` を目視で確認:
|
|
@@ -9,22 +9,22 @@
|
|
|
9
9
|
|
|
10
10
|
しかし **生成された COPY-mode SQL を実際に `psql -f` で取り込めるかを検証する end-to-end テストが存在しない**。ユーザーは COPY モードで invalid な SQL が出ているのではと疑っており、それを実DBに対して検証したい。
|
|
11
11
|
|
|
12
|
-
既存の INSERT モードは `
|
|
12
|
+
既存の INSERT モードは `e2e/test_with_postgresql.sh` が `psql -f` での再取込まで含めて検証している。これに対応する COPY モード版が無い状態。
|
|
13
13
|
|
|
14
14
|
ゴール: COPY モード出力を実際に psql に食わせる E2E シナリオ + スナップショット回帰テストを追加し、潜在的な invalid SQL を表面化する。
|
|
15
15
|
|
|
16
16
|
## 変更ファイル
|
|
17
17
|
|
|
18
|
-
1. **新規** `
|
|
18
|
+
1. **新規** `e2e/test_with_postgresql_copy.sh` — E2E シェル
|
|
19
19
|
2. **修正** `spec/insert_output_snapshot_spec.rb` — COPY 用の SCENARIOS エントリと `snapshot_subdir` 対応
|
|
20
20
|
3. **修正** `.github/workflows/scenario.yml` — `with_postgres` ジョブに新ステップ
|
|
21
21
|
4. **新規** `spec/insert_output_snapshots/postgresql-copy/insert-*.sql` — `UPDATE_SNAPSHOTS=1` で自動生成
|
|
22
22
|
|
|
23
23
|
## 詳細
|
|
24
24
|
|
|
25
|
-
### 1. `
|
|
25
|
+
### 1. `e2e/test_with_postgresql_copy.sh`
|
|
26
26
|
|
|
27
|
-
`
|
|
27
|
+
`e2e/test_with_postgresql.sh` を雛形にして以下のみ差し替え:
|
|
28
28
|
|
|
29
29
|
- `FROM_DATABASE_NAME="exwiw_scenario_prod_db_copy"`
|
|
30
30
|
- `TO_DATABASE_NAME="exwiw_scenario_dev_db_copy"`(並列実行されても既存シナリオと衝突しない名前)
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
```ruby
|
|
48
48
|
{
|
|
49
49
|
adapter: "postgresql",
|
|
50
|
-
config_dir: "
|
|
50
|
+
config_dir: "e2e/postgresql-schema",
|
|
51
51
|
output_format: "copy",
|
|
52
52
|
snapshot_subdir: "postgresql-copy",
|
|
53
53
|
connection: { adapter: "postgresql", database_name: "exwiw_test",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
|
|
65
65
|
```yaml
|
|
66
66
|
- name: Run exwiw (copy mode)
|
|
67
|
-
run:
|
|
67
|
+
run: e2e/test_with_postgresql_copy.sh
|
|
68
68
|
```
|
|
69
69
|
|
|
70
70
|
`postgres:17-alpine` サービスと `postgresql-client-17` インストールは既存ステップで完了済みなので追加不要。
|
|
@@ -80,7 +80,7 @@ UPDATE_SNAPSHOTS=1 bundle exec rspec spec/insert_output_snapshot_spec.rb
|
|
|
80
80
|
## 検証手順
|
|
81
81
|
|
|
82
82
|
1. ローカルで `docker compose up -d postgres` を起動
|
|
83
|
-
2. `bash
|
|
83
|
+
2. `bash e2e/test_with_postgresql_copy.sh` を実行 — exit 0 ならば COPY モード SQL は psql 経由で valid。non-zero なら invalid SQL が表面化(その時点で原因を特定して別途修正)
|
|
84
84
|
3. `UPDATE_SNAPSHOTS=1 bundle exec rspec spec/insert_output_snapshot_spec.rb` でスナップショットを生成
|
|
85
85
|
4. `bundle exec rspec spec/insert_output_snapshot_spec.rb` を `UPDATE_SNAPSHOTS` 無しで再実行し、全シナリオ(sqlite3 / mysql2 / postgresql / postgresql-copy / mongodb)が通ることを確認
|
|
86
86
|
5. CI 上で `with_postgres` ジョブの新ステップ `Run exwiw (copy mode)` が通る(または invalid SQL を検出する)ことを確認
|
|
@@ -81,7 +81,7 @@ indirect / polymorphic を一律に正しく扱える。
|
|
|
81
81
|
`ids_field` 指定時に対象テーブルの WHERE が主キーではなく当該カラムになることを確認する
|
|
82
82
|
ケースを追加。
|
|
83
83
|
- `explain` サブコマンド(SQL のみ対応)で end-to-end 確認:
|
|
84
|
-
既存 scenario(例 `
|
|
84
|
+
既存 scenario(例 `e2e/sqlite3-schema`)に対し
|
|
85
85
|
`--target-table=... --ids=... --ids-column=<col>` を渡し、出力 SQL の WHERE が
|
|
86
86
|
`<table>.<col> IN (...)` になることを目視確認。
|
|
87
87
|
- `bundle exec rspec`(全体)でリグレッションが無いこと。
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Plan: Back out MongoDB fork/cursor parallelism → optional Extended-JSON C extension
|
|
2
|
+
|
|
3
|
+
## Context (why)
|
|
4
|
+
|
|
5
|
+
This branch (`gnhf/rt-rails-mongodb-dum-ed518c`) shipped a large MongoDB-dump perf effort across 18 iterations. The memory wins (iter 2–5: streaming result set + chunked output + precompiled `MaskPlan`) are clean, byte-identical, no-flag defaults and stay. But the CPU/throughput half (iter 6–18) grew into heavy multi-process machinery: two CLI flags (`--parallel-workers`, `--cursor-parallel`), four components (`ParallelSerializer`, `MongoIdPartitioner`, `PropagationCapture`, `ForkedPartWriter`), adapter `write_bulk_insert`/`write_inserts` seams, CLI→Runner→Adapter threading, fork orchestration, Marshal IPC, `_id`-range partitioning, distributed `@state` merge, and a sorted-output caveat — to buy ~1.1–1.4× (serialize-fork) / ~2.5–5.5× (cursor-parallel).
|
|
6
|
+
|
|
7
|
+
The maintainer's decision: **this is over-engineered.** Preserve the findings as `docs/optimization-notes.md`, **remove the parallelism**, and address the CPU hotspot with a much simpler lever the earlier iterations kept pointing at — a **C extension** for the dominant `as_extended_json(mode: :relaxed) + JSON.generate` cost.
|
|
8
|
+
|
|
9
|
+
**Honest scope of the win:** a C extension speeds only the per-document Extended-JSON *serialization* (~82% of per-doc serialize cost). It cannot touch the Mongo cursor's BSON→Ruby *decode* (~40% of total wall time, inside the driver) — that was cursor-parallel's job and is being removed. So end-to-end is bounded ~2.5× (the same Amdahl ceiling `--parallel-workers` hit), not cursor-parallel's 3.4–5.5×. The trade is deliberate: far simpler code + a no-flag, no-fork, single-process CPU win, for a smaller peak speedup. Memory behavior is unchanged (streaming stays the default).
|
|
10
|
+
|
|
11
|
+
## Part 1 — Remove the fork/cursor parallelism (keep streaming + MaskPlan)
|
|
12
|
+
|
|
13
|
+
Verified: `git diff b389204..HEAD` on the core lib files is **entirely** parallelism (`b389204` = iter-5 "MaskPlan" commit). So the iter-5 versions are exactly the "keep streaming, drop parallel" baseline.
|
|
14
|
+
|
|
15
|
+
**Restore to `b389204` (clean streaming baseline):**
|
|
16
|
+
- `lib/exwiw/runner.rb` — back to the inline `each_slice` chunk loop (`file.print(adapter.to_bulk_insert(chunk))`), no `write_inserts`/`parallel_workers`/`cursor_parallel`.
|
|
17
|
+
- `lib/exwiw/adapter.rb` — `Base#initialize(connection_config, logger)` (drop the two kwargs + ivars), `self.build(connection_config, logger)`; drop the `write_bulk_insert`/`write_inserts` seams (they exist only for parallelism). Keep `default_bulk_insert_chunk_size` (streaming).
|
|
18
|
+
- `lib/exwiw/adapter/mongodb_adapter.rb` — iter-5 form: `StreamingResult` without `query`/`keys` readers; `db` with inline client construction (drop `build_client`); no `write_bulk_insert`/`write_inserts`/`cursor_parallel`/`parallel_workers`/partitioner/capture. Keep `StreamingResult`, `MaskPlan`, chunking. (Then patch `serialize_document` in Part 3.)
|
|
19
|
+
- `lib/exwiw/cli.rb` — drop `--parallel-workers`/`--cursor-parallel` flags, ivars, `validate_parallel_workers!`/`validate_cursor_parallel!`, and the Runner kwargs.
|
|
20
|
+
- `lib/exwiw.rb` — drop the four parallel `require_relative` lines (Part 3 adds the `ext_json` require).
|
|
21
|
+
|
|
22
|
+
**Delete (components + specs + probes):**
|
|
23
|
+
- `lib/exwiw/{parallel_serializer,forked_part_writer,mongo_id_partitioner,propagation_capture}.rb`
|
|
24
|
+
- `spec/{parallel_serializer,forked_part_writer,mongo_id_partitioner,propagation_capture}_spec.rb`
|
|
25
|
+
- `script/bench_mongodb_parallel_probe.rb`, `script/bench_mongodb_cursor_parallel_probe.rb`
|
|
26
|
+
|
|
27
|
+
**Restore to `b389204`:** `spec/adapter_spec.rb`, `spec/cli_spec.rb`, `spec/adapter/mongodb_adapter_spec.rb`, `script/bench_mongodb_dump.rb` (all post-iter-5 changes there are parallel-only).
|
|
28
|
+
|
|
29
|
+
**Edit, don't restore:** `README.md`, `CHANGELOG.md` — remove the `--parallel-workers` / `--cursor-parallel` / `EXWIW_MONGODB_*` sections; the `[Unreleased]` entry becomes "streaming/chunked MongoDB dump by default + optional Extended-JSON C extension", pointing to `docs/optimization-notes.md`.
|
|
30
|
+
|
|
31
|
+
## Part 2 — `docs/optimization-notes.md`
|
|
32
|
+
|
|
33
|
+
Distill the 18-iteration log (`.gnhf/runs/.../notes.md`) into a durable doc: the two hotspots (result-set memory; `as_extended_json` CPU), what shipped by default (streaming + chunking + `MaskPlan`), and the **explored-and-removed** parallelism — fork serialize (~1.1–1.4×), cursor-parallel (~3.4–5.5× but heavy: IPC, `_id` partitioning, distributed `@state`, sorted output) — with the Amdahl reasoning (serial cursor decode floor) and *why* it was removed in favor of the C extension. This is the knowledge-preservation the maintainer asked for.
|
|
34
|
+
|
|
35
|
+
## Part 3 — Optional Extended-JSON C extension — DOCUMENT ONLY (not implemented now)
|
|
36
|
+
|
|
37
|
+
**Revised scope (per maintainer):** Part 3 is NOT implemented in this pass. Instead, write the design below into `docs/optimize-mongodb-export-with-native-ext.md` as a future-work / design doc. Only Part 1 and Part 2 are executed now.
|
|
38
|
+
|
|
39
|
+
Design to capture: replaces `JSON.generate(doc.as_extended_json(mode: :relaxed))` with one native tree-walk that emits the Relaxed Extended JSON line directly — no intermediate transformed-Hash tree, no second JSON pass.
|
|
40
|
+
|
|
41
|
+
**New files**
|
|
42
|
+
- `ext/exwiw/ext_json/extconf.rb` — `mkmf` (stdlib), `create_makefile("exwiw/ext_json_native")`.
|
|
43
|
+
- `ext/exwiw/ext_json/ext_json.c` — defines `Exwiw::ExtJson.encode_native(doc) -> String` (one JSONL line, no trailing `\n`). Recursive emitter into a growth buffer.
|
|
44
|
+
- `lib/exwiw/ext_json.rb` — shim: `require "exwiw/ext_json_native"` (distinct name avoids self-collision); on `LoadError`, define a pure-Ruby `encode`. Exposes one stable `Exwiw::ExtJson.encode(doc)`. Fallback is **byte-for-byte today's code**:
|
|
45
|
+
```ruby
|
|
46
|
+
JSON.generate(doc.respond_to?(:as_extended_json) ? doc.as_extended_json(mode: :relaxed) : doc)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**Native fast-path vs delegate (byte-identity strategy, from the Plan agent's findings)**
|
|
50
|
+
- Native in C: `Hash` (insertion order via `rb_hash_foreach`; String keys), `Array`, `String` (escape only `\b\t\n\f\r\"\\`; lowercase `\u00xx` for other <0x20; leave `/`, DEL, U+2028/9, non-ASCII raw), `Integer` within int64, `true`/`false`/`nil`, and `BSON::ObjectId` → `{"$oid":"<24 hex>"}` (hex via `to_s`).
|
|
51
|
+
- **Delegate to Ruby** (call back to a fallback helper returning the JSON fragment for that value): `Float` (`Float#to_s` ≠ `JSON.generate` for sci-notation, e.g. `1e20`), `Time` (variable fractional digits + the years-[1970,9999] `$numberLong` boundary — highest risk), out-of-int64 `Integer` (must preserve the existing `RangeError`), and any unrecognized class (Decimal128, Binary, Symbol, Regexp, Date, …). This is provably byte-identical because `Hash/Array#as_extended_json` are non-transforming structural recursion, so `JSON.generate(v.as_extended_json(mode: :relaxed))` on any sub-value matches the whole-tree output. Time/Float are candidates for later native promotion if the benchmark justifies the added risk.
|
|
52
|
+
|
|
53
|
+
**Packaging / wiring**
|
|
54
|
+
- `exwiw.gemspec` — `spec.extensions = ["ext/exwiw/ext_json/extconf.rb"]` (auto-compiles on `gem install`; fallback covers platforms that can't).
|
|
55
|
+
- `Rakefile` — `Rake::ExtensionTask.new("ext_json_native")`; make `spec` depend on `compile`.
|
|
56
|
+
- Add `rake-compiler` as a dev dep (Gemfile/gemspec). `mkmf` is stdlib.
|
|
57
|
+
- `.gitignore` — ignore built artifacts (`lib/exwiw/*.bundle`, `lib/exwiw/*.so`, `ext/**/*.o`, `ext/**/Makefile`); commit only `ext/` sources (gemspec ships via `git ls-files`).
|
|
58
|
+
- `lib/exwiw.rb` — `require_relative "exwiw/ext_json"`.
|
|
59
|
+
- `lib/exwiw/adapter/mongodb_adapter.rb` — `serialize_document`/`to_bulk_insert` inner call becomes `ExtJson.encode(doc)` (after `apply_mask_plan!`); remove the now-unused private `#extended_json`.
|
|
60
|
+
|
|
61
|
+
## Verification (Part 1 + Part 2 scope)
|
|
62
|
+
|
|
63
|
+
- **`bundle exec rspec`** — full suite green after the revert. The parallel specs are deleted; the restored specs match iter-5. (`spec/insert_output_snapshot_spec.rb` and other mongodb-touching specs need live mongo on 27017 → sandbox disabled; the rest run normally.)
|
|
64
|
+
- **`spec/insert_output_snapshot_spec.rb`** (mongodb fixtures, live mongo) — the byte-exact guard; output bytes must be unchanged by the revert (streaming default is byte-identical to iter-5).
|
|
65
|
+
- `git grep -nE 'parallel_workers|cursor_parallel|ParallelSerializer|MongoIdPartitioner|PropagationCapture|ForkedPartWriter'` returns nothing in `lib/`, `spec/`, `exe/`, `README.md` after the revert (confirms full removal).
|
|
66
|
+
- `docs/optimize-mongodb-export-with-native-ext.md` and `docs/optimization-notes.md` exist and read coherently.
|
|
67
|
+
|
|
68
|
+
## Notes
|
|
69
|
+
- No `git rebase`/`push -f` (history may stay messy; backing out via forward edits + deletions, not history rewrite).
|
|
70
|
+
- After implementation, offer to commit the plan via the remember-plan skill.
|