exwiw 0.5.2 → 0.6.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: 567683d65df5d9f147ab9415a67baf48a80e21ad32e1ef7635c624dfc3d28c47
4
- data.tar.gz: 1513b577f6f2368df60edc45a54c96495ece4f1ee9b453e92adb8991f182fcdf
3
+ metadata.gz: 39362410df244fffa463a86c845062f0e9bacac723e15a6697a50c631db0d5cd
4
+ data.tar.gz: 9670677e7c822886ed6268e476008c81a1caba4e2eacb286bf0e747fef5d3f3c
5
5
  SHA512:
6
- metadata.gz: a9680642eb34f99ed3f0c2924154a5171edf541286dfed14befe15b8c029271419a13d3295694847b1143898b0200bf6eb1d6448e6665dbad7bef026e3c3fbbb
7
- data.tar.gz: 30e6ef9f988965b85f899fdb0646b6e4e2befd68f95a247a9edfeddb8d3a6088f611f07d5376c7d3b9f4f58f147072e03294a140312dec1622994fb6da175720
6
+ metadata.gz: 4af4db84210fbd9da8b6b32e136c1f12af370bc719c5aeee2a1f7183046f1ffae5e8dcdc0bb6d856e30fe800a5febe12aef4d4ff2d2e8c27b162fcc4a056c7f4
7
+ data.tar.gz: 5064dfee83c653ae0c73ae07edf33299752c748c6f9efc5e413424e7b3a92b769f6a99901688a2d1d3415ad3a0939ee807095b1cf24b2aab46fcf89402113a90
data/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.6.0] - 2026-06-20
6
+
7
+ ### Added
8
+
9
+ - Optimize memory usage https://github.com/heyinc/exwiw/pull/118
10
+ - **MongoDB: optional native (C) encoder for the Extended-JSON dump path** (no flag, byte-identical output, pure-Ruby fallback). Encoding each document to MongoDB Relaxed Extended JSON — previously `JSON.generate(doc.as_extended_json(mode: :relaxed))`, which rebuilds the whole document into an intermediate transformed Hash tree and then walks it again — was the dominant per-document CPU cost (~82% of serialization on embed-heavy data). A new C extension (`ext/exwiw/ext_json/`) emits the JSONL line in a single native tree-walk. It formats the structural bulk plus the leaves that dominate a dumped document — `Hash`, `Array`, `String`, fixnum `Integer`, `true`/`false`/`nil`, `BSON::ObjectId` (`_id`), and in-range `Time` (the Mongoid `created_at`/`updated_at` timestamps) — and delegates everything else (`Float`, out-of-int64 `Integer`, out-of-range `Time`, `Symbol`, `Decimal128`, …) back to the exact pure-Ruby path, so the output is provably byte-for-byte identical. On a 30-embedded-post timestamp-heavy document this serializes ~2.8× faster. With `gem install exwiw` the extension compiles automatically; hosts that cannot compile (JRuby/TruffleRuby, no toolchain) fall back to the pure-Ruby encoder, so exwiw stays installable as a pure-Ruby gem. See [`docs/optimize-mongodb-export-with-native-ext.md`](docs/optimize-mongodb-export-with-native-ext.md).
11
+
12
+ ## [0.5.3] - 2026-06-19
13
+
14
+ ### Changed
15
+
16
+ - **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.
17
+
5
18
  ## [0.5.2] - 2026-06-18
6
19
 
7
20
  ### Fixed
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. Encoding each document to MongoDB Extended JSON is accelerated by an **optional native (C) extension** that compiles automatically on `gem install`; where it cannot compile, exwiw falls back to a byte-identical pure-Ruby encoder. See [`docs/optimization-notes.md`](docs/optimization-notes.md) for the performance investigation and [`docs/optimize-mongodb-export-with-native-ext.md`](docs/optimize-mongodb-export-with-native-ext.md) for the native encoder's design. Benchmark your own data with `script/bench_mongodb_dump.rb`.
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
- // scenario/users.json — top-level collection
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
- // scenario/posts.json — embedded under users.posts
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,249 @@
1
+ # Design: optional native (C) extension for the MongoDB Extended-JSON encoder
2
+
3
+ Status: **implemented.** This document captured the design; it now describes the
4
+ shipped encoder. Source: `ext/exwiw/ext_json/ext_json.c` (native emitter) and
5
+ `lib/exwiw/ext_json.rb` (the optional-load shim + pure-Ruby fallback); the
6
+ byte-identity guard is `spec/ext_json_spec.rb`. It is the successor to the
7
+ fork/cursor parallelism that was removed (see
8
+ [`optimization-notes.md`](./optimization-notes.md)).
9
+
10
+ ## Motivation
11
+
12
+ When the MongoDB adapter dumps embed-heavy documents, the dominant CPU cost is
13
+ turning each decoded Mongo document (a Ruby `Hash` containing `BSON::ObjectId` /
14
+ `Time` / nested `Hash`+`Array`) into one JSONL line of MongoDB **Relaxed
15
+ Extended JSON**. Today that is:
16
+
17
+ ```ruby
18
+ # lib/exwiw/adapter/mongodb_adapter.rb
19
+ JSON.generate(doc.as_extended_json(mode: :relaxed))
20
+ ```
21
+
22
+ `as_extended_json` (in the pure-Ruby `bson` gem) **recursively rebuilds the
23
+ whole document into a new intermediate Hash tree** (`ObjectId -> {"$oid"=>…}`,
24
+ `Time -> {"$date"=>…}`, every subdoc/array re-allocated), and then
25
+ `JSON.generate` walks that tree a *second* time. For a 30-embedded-post doc this
26
+ was measured at ~130µs/doc and is ~82% of per-document serialization cost.
27
+
28
+ Earlier experiments established the levers:
29
+
30
+ - Threads give **zero** speedup — `as_extended_json` is pure Ruby and holds the GVL.
31
+ - A pure-Ruby fused single-pass encoder is **slower** (per-leaf `.to_json` C-call
32
+ overhead beats it; `JSON.generate` does the whole tree in one C pass).
33
+ - Multi-process parallelism worked but was judged over-engineered and removed.
34
+
35
+ The remaining lever is a **C extension**: one native walk that emits the
36
+ Extended-JSON text directly — no intermediate transformed-Hash tree, no second
37
+ JSON pass.
38
+
39
+ ## Goals / non-goals
40
+
41
+ - **Goal:** a native encoder that is **byte-for-byte identical** to the current
42
+ pure-Ruby path, behind an **optional** load with a pure-Ruby fallback so
43
+ exwiw stays installable as a pure-Ruby gem (JRuby/TruffleRuby, or any host
44
+ where compilation fails, keep working).
45
+ - **Non-goal:** speeding up the Mongo cursor's BSON→Ruby *decode*. That lives in
46
+ the `mongo`/`bson` driver and is ~40% of total dump wall time. A serialization
47
+ C extension is therefore bounded to roughly the same end-to-end ceiling
48
+ (~2.5×) the removed `--parallel-workers` path had — it does **not** reach the
49
+ removed cursor-parallel path's 3.4–5.5×. This is an accepted trade for a far
50
+ simpler, flag-free, fork-free implementation.
51
+
52
+ ## Exact serialization semantics to reproduce (verified against bson 5.2.0)
53
+
54
+ The byte-exact anchor is `spec/insert_output_snapshot_spec.rb` (committed
55
+ `spec/insert_output_snapshots/mongodb/*.jsonl` fixtures). The C encoder must
56
+ reproduce the following exactly. All rows below were verified empirically and,
57
+ for `Time`, against `bson-5.2.0/lib/bson/time.rb:72-89`.
58
+
59
+ | Ruby value | Relaxed Extended JSON output | Notes |
60
+ |---|---|---|
61
+ | `BSON::ObjectId` | `{"$oid":"<24 lowercase hex>"}` | hex via `ObjectId#to_s` |
62
+ | `Time` (year 1970..9999, sub-second) | `{"$date":"2021-01-02T03:04:05.678Z"}` | floor to ms, `strftime('%Y-%m-%dT%H:%M:%S.%LZ')` |
63
+ | `Time` (year 1970..9999, whole second) | `{"$date":"2021-01-02T03:04:05Z"}` | **no** fraction when `usec == 0` |
64
+ | `Time` (year <1970 or >9999) | `{"$date":{"$numberLong":"<ms>"}}` | `ms = sec*1000 + usec.divmod(1000).first` |
65
+ | `Integer` (fits int64) | bare `42` / `9000000000` | |
66
+ | `Integer` (outside int64) | **raises `RangeError`** | `"Integer … too big to be represented as a MongoDB integer"` |
67
+ | `Float` | `JSON.generate(float)` form | `1e20 → 1e+20` (**not** `Float#to_s`'s `1.0e+20`); `100.0 → 100.0`; `-0.0 → -0.0` |
68
+ | `String` | JSON string | escape only `\b \t \n \f \r \" \\`; other `<0x20` as lowercase `\u00xx`; `/`, DEL, U+2028/U+2029, non-ASCII left raw |
69
+ | `true` / `false` / `nil` | `true` / `false` / `null` | |
70
+ | `Hash` | `{…}` | **insertion order** preserved; keys are Strings (JSON-escaped) |
71
+ | `Array` | `[…]` | |
72
+
73
+ `bson/time.rb` boundary (the highest-risk piece), verified verbatim:
74
+
75
+ ```ruby
76
+ def as_extended_json(**options)
77
+ if options[:mode] == :relaxed && (1970..9999).include?(utc_time.year)
78
+ if utc_time.usec != 0
79
+ utc_time = utc_time.floor(3) # floor to millisecond
80
+ {'$date' => utc_time.strftime('%Y-%m-%dT%H:%M:%S.%LZ')}
81
+ else
82
+ {'$date' => utc_time.strftime('%Y-%m-%dT%H:%M:%SZ')}
83
+ end
84
+ else
85
+ msec = utc_time.usec.divmod(1000).first
86
+ {'$date' => {'$numberLong' => (sec * 1000 + msec).to_s}}
87
+ end
88
+ end
89
+ ```
90
+
91
+ ## Fast-path vs delegate (the byte-identity strategy)
92
+
93
+ The encoder splits values into a **native fast path** and a **Ruby delegate**:
94
+
95
+ - **Native (in C):** `Hash`, `Array`, `String`, `Integer` within int64,
96
+ `true`/`false`/`nil`, `BSON::ObjectId`, and **in-range `Time`** (years
97
+ 1970..9999). These are the structural bulk plus the two most common leaves in
98
+ a dumped document — `_id` and the Mongoid `created_at`/`updated_at` timestamps.
99
+ The in-range Time path resolves the absolute instant with `rb_time_timespec`
100
+ (epoch seconds + nanoseconds, no `rb_funcall`), formats with `gmtime_r` +
101
+ `snprintf`, and reproduces bson's rule exactly: a `.mmm` fraction iff
102
+ `nsec >= 1000` (i.e. `usec != 0`), with the millisecond floored to
103
+ `nsec / 1e6`. The in-range window is the half-open epoch-second range
104
+ `[0, 253402300800)`.
105
+ - **Delegate to Ruby** — call back into
106
+ `JSON.generate(value.as_extended_json(mode: :relaxed))` for the individual
107
+ value and splice the returned fragment into the buffer:
108
+ - `Float` — `Float#to_s` diverges from `JSON.generate` for scientific notation
109
+ (`1e20`), so never reformat floats in C.
110
+ - **out-of-range `Time`** (year < 1970 or > 9999) — its `$numberLong` form
111
+ involves negative-epoch arithmetic, is vanishingly rare in dumped data, and
112
+ is left to Ruby. The in-range ISO branch is handled natively (above).
113
+ - out-of-int64 `Integer` — must surface the identical `RangeError`.
114
+ - any unrecognized class — `Decimal128`, `BSON::Binary`, `Symbol`, `Regexp`,
115
+ `Date`, `BSON::Timestamp`, etc.
116
+
117
+ **Why delegating is provably byte-identical:** `Hash#as_extended_json` and
118
+ `Array#as_extended_json` are *non-transforming structural recursion* — they map
119
+ over children and call `as_extended_json` on each. So the bytes produced for any
120
+ sub-value `v` by `JSON.generate(v.as_extended_json(mode: :relaxed))` are exactly
121
+ the bytes that the whole-document `JSON.generate(doc.as_extended_json(...))`
122
+ would have produced for that position. The native walk can therefore hand any
123
+ value it does not want to format to Ruby and splice the result, with no
124
+ divergence.
125
+
126
+ `Time` was promoted into the native path because the benchmark showed it was
127
+ decisive: with `Time` delegated, a 30-embedded-post timestamp-heavy document
128
+ (32 `Time` fields) sped up only ~1.03× — the per-`Time` `rb_funcall` +
129
+ `as_extended_json` Hash allocation + second `JSON.generate` pass erased the win.
130
+ Formatting in-range `Time` natively brings the same document to ~2.8× (the
131
+ serialization-step ceiling). `Float` remains delegated: matching
132
+ `JSON.generate`'s shortest-round-trip float formatting in C (not `Float#to_s`)
133
+ is not worth the risk for the few floats a typical document carries.
134
+
135
+ ## C source & buffer design
136
+
137
+ - `Exwiw::ExtJson.encode_native(doc) -> String` — returns one JSONL line, **no**
138
+ trailing `\n` (the caller/Runner owns separators).
139
+ - Recursive emitter writing into a single growing buffer (`rb_str_buf_new` +
140
+ `rb_str_cat`/`rb_str_buf_cat`, or a `malloc` buffer finalized to an
141
+ `rb_utf8_str_new`). Result string is UTF-8.
142
+ - Type dispatch via `TYPE()` for the immediates/`T_HASH`/`T_ARRAY`/`T_STRING`/
143
+ `T_FLOAT`/`T_FIXNUM`/`T_BIGNUM`, and a cached `BSON::ObjectId` class reference
144
+ (`rb_const_get`) compared with `rb_obj_is_kind_of` for ObjectId.
145
+ - `Hash`: `rb_hash_foreach` preserves insertion order; emit `key:value` pairs;
146
+ keys are Strings run through the same string escaper.
147
+ - Delegate path: a cached `ID` for a Ruby helper (e.g.
148
+ `Exwiw::ExtJson.encode_fragment(v)`), `rb_funcall`'d, returning the JSON
149
+ fragment String to splice. The `RangeError` for oversized integers propagates
150
+ naturally through the delegate.
151
+ - String escaper implemented in C to match the table above (no per-leaf Ruby
152
+ call for the common String case).
153
+
154
+ ## Packaging, optional load, fallback
155
+
156
+ - **gemspec:** `spec.extensions = ["ext/exwiw/ext_json/extconf.rb"]`. With
157
+ `extensions` set, `gem install exwiw` compiles automatically; hosts that can't
158
+ compile fall back at runtime (below).
159
+ - **`ext/exwiw/ext_json/extconf.rb`:** `require "mkmf"` (stdlib) +
160
+ `create_makefile("exwiw/ext_json_native")`. The compiled lib is named
161
+ `ext_json_native` (distinct from the `ext_json.rb` shim) to avoid a
162
+ `require` self-collision.
163
+ - **`ext/exwiw/ext_json/ext_json.c`:** the emitter; defines
164
+ `Exwiw::ExtJson.encode_native`.
165
+ - **`lib/exwiw/ext_json.rb`** (the shim, always loaded):
166
+
167
+ ```ruby
168
+ require "json"
169
+
170
+ module Exwiw
171
+ module ExtJson
172
+ module_function
173
+
174
+ # Pure-Ruby fragment encoder used by both the fallback and the native
175
+ # delegate path. Byte-identical to today's behavior.
176
+ def encode_fragment(value)
177
+ JSON.generate(value.respond_to?(:as_extended_json) ? value.as_extended_json(mode: :relaxed) : value)
178
+ end
179
+
180
+ begin
181
+ require "exwiw/ext_json_native" # defines Exwiw::ExtJson.encode_native
182
+ def encode(doc) = encode_native(doc)
183
+ rescue LoadError
184
+ def encode(doc) = encode_fragment(doc) # exact current behavior
185
+ end
186
+ end
187
+ end
188
+ ```
189
+
190
+ - **`Rakefile`:** `require "rake/extensiontask"`;
191
+ `Rake::ExtensionTask.new("ext_json_native") { |e| e.ext_dir = "ext/exwiw/ext_json" }`;
192
+ make the `spec` task depend on `compile`.
193
+ - **Dev dependency:** add `rake-compiler` (Gemfile / gemspec dev deps). `mkmf`
194
+ is stdlib, no runtime dep added.
195
+ - **`.gitignore`:** ignore built artifacts (`lib/exwiw/*.bundle`,
196
+ `lib/exwiw/*.so`, `ext/**/*.o`, `ext/**/Makefile`). Commit only the `ext/`
197
+ sources; the gemspec ships files via `git ls-files`.
198
+ - **`lib/exwiw.rb`:** add `require_relative "exwiw/ext_json"`.
199
+
200
+ ## Integration point
201
+
202
+ In `lib/exwiw/adapter/mongodb_adapter.rb`, the per-document serialize step
203
+ becomes (masking still runs in Ruby first; only the encode changes):
204
+
205
+ ```ruby
206
+ def to_bulk_insert(rows, config)
207
+ plan = mask_plan(config)
208
+ rows.map do |doc|
209
+ apply_mask_plan!(doc, plan)
210
+ Exwiw::ExtJson.encode(doc) # was: JSON.generate(extended_json(doc))
211
+ end.join("\n")
212
+ end
213
+ ```
214
+
215
+ The private `#extended_json` helper is removed — its logic (including the
216
+ `respond_to?(:as_extended_json)` guard) moves into `ExtJson.encode_fragment`.
217
+
218
+ ## Test & benchmark strategy
219
+
220
+ - **`spec/ext_json_spec.rb`** (DB-free; the primary byte-identity guard, runs in
221
+ normal CI): assert `encode_native(doc) == encode_fragment(doc)` over a fuzz of
222
+ representative shapes — ObjectId; nested hashes/arrays; `Time` across the year
223
+ boundary, whole-second (no fraction), and sub-second; strings with control
224
+ chars / quotes / backslashes / non-ASCII / U+2028; ints, bignums (assert the
225
+ same `RangeError`), floats (`1e20`, `-0.0`, `100.0`); `nil`; empty
226
+ hash/array/string. Skip the native half with a clear message when the lib
227
+ isn't compiled, so the suite still passes on a fallback-only host.
228
+ - **`spec/insert_output_snapshot_spec.rb`** (live mongo on 27017): the byte-exact
229
+ fixtures must stay green with the native encoder built.
230
+ - **Microbench** (extend `script/bench_mongodb_dump.rb`): native-encode vs
231
+ Ruby-fallback throughput on DB-free synthesized embed-heavy docs, plus the live
232
+ path on 20k×30, to quantify the real speedup.
233
+
234
+ ## Risk register
235
+
236
+ 1. **Time formatting** — variable fraction + ms flooring + `$numberLong`
237
+ boundary. In-range years (1970..9999) are formatted natively via
238
+ `rb_time_timespec` + `gmtime_r`; the rare out-of-range `$numberLong` form is
239
+ delegated. Mitigated by a dense byte-identity fuzz over the whole in-range
240
+ epoch span with mixed nanosecond precision, plus the boundary/sub-ms edges in
241
+ `spec/ext_json_spec.rb`.
242
+ 2. **Float formatting** — `Float#to_s` ≠ `JSON.generate`. Mitigated by delegating.
243
+ 3. **String escaping** — must match JSON exactly. Implemented in C, fuzz-tested
244
+ vs the Ruby fallback.
245
+ 4. **Hash key order** — preserved via `rb_hash_foreach`.
246
+ 5. **Oversized integers** — delegate so the same `RangeError` surfaces.
247
+ 6. **Encoding** — emit UTF-8; pass non-ASCII bytes through unescaped (matches JSON).
248
+ 7. **Build/portability** — optional load + pure-Ruby fallback keeps non-CRuby and
249
+ 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` 統合テスト (`scenario/initdb/init.sqlite3` に対して実行し、出力が `CREATE TABLE IF NOT EXISTS` を含むことを assert) |
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. `scenario/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. `scenario/test_with_mysql2.sh`, `scenario/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. `scenario/test_with_mongodb.sh` を実行し、`dump/insert-000-schema.js` が出力されることと、空 DB に対して `mongosh "mongodb://localhost/empty_db" < dump/insert-000-schema.js` が成功すること、続いて `mongoimport` で各 jsonl が流せることを確認。
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. `scenario/setup_with_mongodb.rb` は seed を `insert_many` で流すだけで、index を一切作っていない
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. `scenario/import_with_mongodb.rb` は `insert-*.jsonl` だけを glob して処理しており、`insert-000-schema.js` を一切実行しない
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
- | `scenario/setup_with_mongodb.rb` | seed 流し込みの後に 3 種類の代表的 index を作る (unique `shops.name` / plain `users.email` / 複合 `orders.shop_id+user_id`) |
29
- | `scenario/import_with_mongodb.rb` | `--no-drop` と `--input-dir DIR` フラグを追加。from-clean は drop すると schema.js が作った index ごと消えてしまうため |
30
- | `scenario/verify_with_mongodb.rb` | `--with-indexes` で target collection の index を assert (default scenario では import 時に drop されるのでスキップ) |
31
- | `scenario/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` |
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 scenario/test_with_mongodb.sh` 既存 scenario 維持を確認 ✓
60
- - `bash scenario/test_with_mongodb_from_clean.sh` 新規 scenario 通過を確認
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 モードは `scenario/test_with_postgresql.sh` が `psql -f` での再取込まで含めて検証している。これに対応する COPY モード版が無い状態。
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. **新規** `scenario/test_with_postgresql_copy.sh` — E2E シェル
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. `scenario/test_with_postgresql_copy.sh`
25
+ ### 1. `e2e/test_with_postgresql_copy.sh`
26
26
 
27
- `scenario/test_with_postgresql.sh` を雛形にして以下のみ差し替え:
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: "scenario/postgresql-schema",
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: scenario/test_with_postgresql_copy.sh
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 scenario/test_with_postgresql_copy.sh` を実行 — exit 0 ならば COPY モード SQL は psql 経由で valid。non-zero なら invalid SQL が表面化(その時点で原因を特定して別途修正)
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(例 `scenario/sqlite3-schema`)に対し
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`(全体)でリグレッションが無いこと。