purplelight 0.1.13 → 0.1.17

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: c500768ed34e247a92be3979f56c1036314b51003cb1ff7953562f1ca8278677
4
- data.tar.gz: f12b305e35b201192e219aec23a0c66c13a7b9eb262c038dbac9d1e2a3c77bc5
3
+ metadata.gz: 006653025fe8b2a5afa958c08992076f171550c7e1a1da4d57442df76d0e659d
4
+ data.tar.gz: 2476b1a5c4f078b539cd83c552e7ec5938277943e46ae6412b4c04df3830a042
5
5
  SHA512:
6
- metadata.gz: 0f6959b02fb695a4c28802ec555d7b6ce6e7020d348024d2df8381efbb022632916d2915c688131cf79aa042f05aa3e2ec8780ad866fdb774b45b51f53093d6b
7
- data.tar.gz: aa2885e120f57bd492c1a98aee91f52b7c83bee15492ce34a0b6d3dae985e0355ea77489e69dfde2f27fb5b51974263b7f7358a51103697d8222a03cd98a4185
6
+ metadata.gz: d31c3447814135d0f723f6bf35e955618932971f28bdcdd15a3831daee2c8c2d6a22ad0dae0bb8af1572f0e570fdbb71cc606f31241a83ee741742a783f3a673
7
+ data.tar.gz: 30bd7c0a089f57b6f8957d93b538b5e0f698e6440b7180dea86e3922cee58e639cace618782d888284883bcd5930456bd6cda9e270b338d54caea538b0804943
@@ -0,0 +1,53 @@
1
+ # Purplelight upgrade and performance notes
2
+
3
+ Updated: 2026-07-21
4
+
5
+ ## Completed
6
+
7
+ - Pinned managed development Ruby 4.0.2; no Homebrew Ruby is used.
8
+ - Updated all Ruby 3.2-compatible dependencies: MongoDB Ruby driver 2.24.1, BSON 5.2.0, zstd-ruby 2.0.6, red-arrow/red-parquet 25.0.0, Rake 13.4.2, RSpec 3.13.2, RuboCop 1.88.2, SimpleCov 1.0.2, StackProf 0.2.28, and current transitives. `parallel` remains on 1.28 because 2.x requires Ruby 3.3; `diff-lcs` 2.0 remains blocked by RSpec's `< 2.0` constraint.
9
+ - Updated CI to MongoDB 7 and 8, Ruby 3.2 and 4.0, Apache Arrow 25, and `actions/checkout@v7.0.1`.
10
+ - Added 24 bounded microbenchmarks covering all eight runtime paths. The harness enforces 100% path registration, takes the median of five samples, measures allocations, and aborts each timed sample or allocation pass after two seconds. The slowest observed timed sample was 0.386 seconds.
11
+ - Added CPU and object-allocation StackProf tasks plus generated flamegraph support.
12
+ - Added SimpleCov line and branch enforcement. The full suite verifies 100% line and branch coverage, including CLI and MongoDB integration behavior.
13
+ - Added focused behavioral coverage for queue backpressure, manifests, telemetry, partition planning, compression backends, rotations, Parquet row groups, resumability, concurrent JSONL writing, progress, and telemetry output.
14
+ - Made persistent MongoDB fixtures rerunnable by dropping static test collections before seeding.
15
+ - Persisted the exact Extended JSON partition plan in manifest version 2. Resume now reuses the original boundaries rather than replanning against a growing collection, preventing boundary replay and duplicate rows.
16
+
17
+ ## Retained performance changes
18
+
19
+ - ByteQueue stores payloads and sizes in parallel arrays instead of allocating a pair per enqueue. Confirmed round-trip runs improved from 1,792,596.54 to 2,637,130.80–2,657,101.17 operations/second: **+47.1% to +48.2%**. Oversized single items are also admitted when the queue is empty, avoiding a producer deadlock.
20
+ - JSONL readers pass row and byte metadata with pre-encoded batches, removing a full `String#count("\n")` scan in the writer. The bounded accounting benchmark rose from 37,313.43 to more than 320,000 operations/second: **over +750%**.
21
+ - JSONL writers cache their thread-local telemetry sink after the first write. Two confirmation runs improved the pre-encoded path by **+3.2% to +6.1%** without a confirmed JSONL pipeline regression.
22
+ - Snapshot progress reporting no longer creates a mandatory two-second shutdown delay. Runs without a callback create no progress thread; callback runs use a condition variable and wake immediately on shutdown.
23
+ - Two JSONL writer threads now perform real concurrent serialization/compression and allocate globally unique part numbers. On the 20,000-document incompressible pipeline, throughput improved from 9.56 to 15.14 exports/second: **+58.4%**, with complete row-count verification.
24
+ - JSONL batching honors `batch_size` as well as the 1 MB byte ceiling, bounding latency and memory for small documents.
25
+ - CSV uses an allocation-conscious serializer instead of constructing one `CSV.generate_line` result per row. Confirmed batch throughput improved from 1,211.39 to 1,716.50–1,738.04 operations/second: **+41.7% to +43.5%**.
26
+ - Manifest configuration avoids redundant atomic rewrites. Confirmed progress runs improved from 1,343.24 to 1,753.49–1,764.93 operations/second: **+30.5% to +31.4%**.
27
+ - Parquet now constructs Arrow record batches directly, caches the first row group's schema, packs primitive buffers without per-value GObject calls, preserves requested row-group boundaries through `Parquet::WriterProperties`, and keeps one native writer open per output part. The 200-document benchmark rose from 306.10 to 871.99–1,004.42 operations/second across three runs (**+184.9% to +228.1%**) while allocations fell from 12,281.0 to 2,263.2 per export (**-81.6%**). At 10,000 documents, median throughput rose from 7.23 to 46.71 operations/second (**+545.7%**) and allocations fell from 565,487 to 54,413 (**-90.4%**); output sizes remained unchanged for the compared row-group layouts.
28
+ - Parquet batches each BSON ObjectId column's raw 12-byte values before one hexadecimal conversion, instead of allocating a hexadecimal string and `String#unpack` result per document. Alternating matched runs improved the 200-document export from 918.40 to 982.90 operations/second (**+7.0%**) and cut allocations from 2,266.15 to 1,872.15 (**-17.4%**). The 10,000-document row-group export improved from 47.05 to 50.23 operations/second (**+6.8%**) and from 54,413 to 34,443 allocations (**-36.7%**); a single 10,000-row group improved **+8.3%** with **-39.3%** allocations. Schemas, values, row-group counts, and artifact sizes remained unchanged.
29
+ - Compression defaults were selected from bounded comparisons: zstd level 3 for JSONL, zstd level 9 for CSV, and gzip level 1 for throughput-oriented fallback.
30
+ - CSV manifests count only rows actually written; pre-encoded strings are skipped without inflating progress.
31
+ - Cached integer schemas now reject later values outside their represented range instead of allowing Arrow's integer builder to wrap them silently.
32
+
33
+ ## Rejected or unnecessary changes
34
+
35
+ - Compact manifest JSON was measured and reverted: it remained below the 3% retention threshold.
36
+ - A ring-buffer rewrite for ByteQueue was rejected. MRI's front-removal behavior kept the bulk-drain case stable, while the lower-allocation parallel-array representation supplied the retained gain.
37
+ - Alternative CSV gzip and zstd levels that did not produce a repeatable throughput/size improvement were rejected.
38
+ - In-place escaping for nested JSON in CSV was measured and reverted. Dedicated row serialization moved by +2.4% and +3.1% across two runs, so it did not repeatedly clear the 3% threshold.
39
+ - Parquet `RecordBatch` substitution alone, native writer batch-size tuning, dictionary disabling, and extra row-group coalescing did not address the dominant per-value GObject conversion cost and were rejected.
40
+ - Preallocating string capacity, avoiding the integer `compact` copy, borrowing caller buffers, bulk-joining ObjectId bytes, direct native writer calls, caching writer properties, and skipping redundant directory creation each stayed below 3% or regressed and were rejected.
41
+ - Direct three-argument `RecordBatch` construction improved the 200-document case by 7.0% but only -0.6% to +1.9% on the 10,000-document cases, so it was rejected as a first-row-group-only gain that did not persist at scale.
42
+ - No replacement gems or external `alexandernicholson/gems` repository were needed. Current maintained dependencies met the compatibility and measured performance requirements.
43
+
44
+ ## Follow-up suggestions
45
+
46
+ - Run the existing opt-in one-million-document load benchmark only on a dedicated, named production-like host; record CPU, storage, MongoDB topology, and compression ratio with every result. It intentionally remains outside the sub-two-second microbenchmark harness.
47
+ - Define schema evolution for CSV and Parquet. Columns are inferred from the first available batch, so fields first appearing in later batches are not added; an explicit Parquet schema option would also let callers avoid narrow first-row-group integer inference.
48
+ - Further Parquet gains likely require an upstream/native bulk-buffer API: after the retained rewrite, CPU samples were dominated by GObject-introspection constructor calls and the native Parquet writer rather than Ruby row transposition.
49
+ - Nested Ruby hashes now dominate the remaining avoidable Parquet allocations because compatibility requires their existing `Hash#inspect` string representation. An explicit nested Struct/JSON encoding option could remove that cost without silently changing current files.
50
+ - Consider batching or journaling manifest checkpoints for high-latency filesystems. Current per-batch atomic rewrites prioritize resumability over maximum throughput.
51
+ - Define an optional ordering contract if consumers need global document order with multiple JSONL writer threads. Current parts are collision-free and complete, but independently scheduled.
52
+ - Track the 3% regression threshold on a pinned benchmark runner. Compression timings vary with host load and should not become a hard gate on shared CI workers.
53
+ - Recheck `diff-lcs` when RSpec permits 2.x, and continue testing both MongoDB 7 and 8 before future driver upgrades.
data/README.md CHANGED
@@ -6,18 +6,29 @@ Snapshot MongoDB collections efficiently from Ruby with resumable, partitioned e
6
6
 
7
7
  Purplelight is published on RubyGems: [purplelight on RubyGems](https://rubygems.org/gems/purplelight).
8
8
 
9
+ Requires Ruby 3.2 or newer and MongoDB 7 or 8.
10
+
9
11
  Add to your Gemfile:
10
12
 
11
13
  ```ruby
12
- gem 'purplelight', '~> 0.1.13'
14
+ gem 'purplelight', '~> 0.1.17'
13
15
  ```
14
16
 
15
17
  Or install directly:
16
18
 
17
19
  ```bash
18
- gem install purplelight
20
+ gem install purplelight -v '~> 0.1.17'
21
+ ```
22
+
23
+ `bigdecimal`, `logger`, and the MongoDB Ruby driver are installed automatically.
24
+ To use zstd compression, add its optional backend:
25
+
26
+ ```ruby
27
+ gem 'zstd-ruby', '~> 2.0'
19
28
  ```
20
29
 
30
+ Without `zstd-ruby` or `ruby-zstds`, a requested zstd output safely falls back to gzip.
31
+
21
32
  ### Quick start
22
33
 
23
34
  ```ruby
@@ -124,8 +135,8 @@ Add optional dependencies:
124
135
  ```ruby
125
136
  # Gemfile
126
137
  group :parquet do
127
- gem 'red-arrow', '~> 15.0'
128
- gem 'red-parquet', '~> 15.0'
138
+ gem 'red-arrow', '>= 25.0'
139
+ gem 'red-parquet', '>= 25.0'
129
140
  end
130
141
  ```
131
142
 
@@ -166,6 +177,7 @@ Notes for Parquet:
166
177
  - Parquet multi-part sizing is controlled by rows via `parquet_max_rows`.
167
178
  - `--rotate-mb` / `part_bytes` do not affect Parquet part size; they apply to JSONL/CSV.
168
179
  - Use `sharding: { mode: :single_file }` to force a single `.parquet` file.
180
+ - The first row group defines the Arrow schema. Later values must remain type-compatible; integer values outside the inferred width raise `RangeError` instead of wrapping.
169
181
 
170
182
  ### Environment variables (optional)
171
183
 
@@ -214,7 +226,7 @@ bundle exec bin/purplelight \
214
226
  - `--parquet-row-group N`: Parquet row group size (rows).
215
227
  - `--parquet-max-rows N`: Parquet max rows per part (enables multi-part when not `--single-file`).
216
228
  - `--write-chunk-mb MB`: JSONL encode/write chunk size before enqueueing.
217
- - `--writer-threads N` (experimental): Number of writer threads (JSONL only).
229
+ - `--writer-threads N`: Number of concurrent JSONL writer threads.
218
230
  - `--telemetry on|off`: Force enable/disable telemetry output.
219
231
  - `--resume-overwrite-incompatible`: Overwrite an existing incompatible manifest to safely resume anew.
220
232
  - `--dry-run`: Print effective read preference JSON and exit (no snapshot).
@@ -223,7 +235,7 @@ bundle exec bin/purplelight \
223
235
  Notes:
224
236
  - Compression backend selection order is: requested format → `zstd-ruby` → `zstds` → `gzip`.
225
237
  - `--single-file` and `--by-size` update only the sharding mode/params and preserve any provided `--prefix`.
226
- Parquet multi-part sizing can be controlled via `parquet_max_rows` programmatically, or via CLI `--parquet-max-rows`.
238
+ - Parquet multi-part sizing can be controlled via `parquet_max_rows` programmatically, or via CLI `--parquet-max-rows`.
227
239
  - To increase concurrent connections, set `maxPoolSize` on your Mongo URI (used by `--uri`), e.g., `mongodb://.../?maxPoolSize=32`. A good starting point is `maxPoolSize >= --partitions`.
228
240
 
229
241
  ### Architecture
@@ -261,6 +273,7 @@ Key points:
261
273
  - **Compression**: prefer `zstd`; adjust level to balance speed/ratio. CLI: `--compression zstd --compression-level N`. For max speed, try `--compression gzip --compression-level 1`.
262
274
  - **Rotation size**: larger (512MB–1GB) reduces finalize overhead for many parts. CLI: `--rotate-mb` (and/or `--by-size`).
263
275
  - **JSONL chunking**: tune builder write chunk size for throughput. CLI: `--write-chunk-mb`.
276
+ - **JSONL writer threads**: use `--writer-threads 2` when serialization or compression is the bottleneck. Writers emit independently numbered parts; downstream consumers must not assume document order across parts.
264
277
  - **Parquet row groups**: choose a row group size that fits downstream readers. CLI: `--parquet-row-group`.
265
278
  - **Parquet parts (rows)**: split Parquet outputs by rows with `parquet_max_rows` (programmatic API). Set `sharding.mode` to anything other than `:single_file` to enable multi-part filenames.
266
279
  - **Read preference**: offload to secondaries or tagged analytics nodes when available. CLI: `--read-preference`, `--read-tags`.
@@ -268,7 +281,19 @@ Key points:
268
281
  - **Cursor timeout**: for very long scans, leave `noCursorTimeout` enabled. CLI: `--no-cursor-timeout true|false`.
269
282
  - **Telemetry**: enable to inspect timing breakdowns; disable for minimal output. CLI: `--telemetry on|off`.
270
283
 
271
- Benchmarking (optional):
284
+ Bounded microbenchmarks:
285
+
286
+ ```bash
287
+ bundle exec rake microbench
288
+
289
+ # Compare against the checked-in baseline.
290
+ bundle exec ruby benchmark/microbench.rb --baseline benchmark/baseline.json
291
+ ```
292
+
293
+ The harness covers every runtime component, takes the median of five samples,
294
+ and aborts any timed sample or allocation pass after two seconds.
295
+
296
+ Optional production-scale load benchmark (not part of the microbenchmark suite):
272
297
 
273
298
  ```bash
274
299
  # 1M docs benchmark with tunables (JSONL)
@@ -330,7 +355,7 @@ bundle exec bin/purplelight \
330
355
  --read-preference secondary --read-tags nodeType=ANALYTICS --dry-run
331
356
  ```
332
357
 
333
- ### Quick Benchmark
358
+ ### Example load benchmark
334
359
  ```
335
360
  % BENCH=1 BENCH_PARTITIONS=16 BENCH_BATCH_SIZE=8000 BENCH_QUEUE_MB=512 BENCH_ROTATE_MB=512 BENCH_COMPRESSION=gzip bundle exec rspec spec/benchmark_perf_spec.rb --format doc
336
361
 
data/Rakefile CHANGED
@@ -12,3 +12,14 @@ rescue LoadError
12
12
  sh 'echo "RSpec not installed"'
13
13
  end
14
14
  end
15
+
16
+ desc 'Run the bounded microbenchmark suite'
17
+ task :microbench do
18
+ ruby 'benchmark/microbench.rb'
19
+ end
20
+
21
+ desc 'Profile all microbenchmarks and render CPU/allocation flamegraphs'
22
+ task :profile do
23
+ ruby 'benchmark/profile.rb --mode cpu'
24
+ ruby 'benchmark/profile.rb --mode object'
25
+ end
@@ -0,0 +1,366 @@
1
+ {
2
+ "generated_at": "2026-07-21T12:41:06Z",
3
+ "coverage": {
4
+ "covered": [
5
+ "manifest",
6
+ "partitioner",
7
+ "queue",
8
+ "snapshot",
9
+ "telemetry",
10
+ "writer_csv",
11
+ "writer_jsonl",
12
+ "writer_parquet"
13
+ ],
14
+ "required": [
15
+ "manifest",
16
+ "partitioner",
17
+ "queue",
18
+ "snapshot",
19
+ "telemetry",
20
+ "writer_csv",
21
+ "writer_jsonl",
22
+ "writer_parquet"
23
+ ],
24
+ "percent": 100.0
25
+ },
26
+ "max_case_seconds": 2.0,
27
+ "samples": 5,
28
+ "results": {
29
+ "byte_queue_round_trip": {
30
+ "iterations": 20000,
31
+ "seconds": 0.053941,
32
+ "sample_seconds": [
33
+ 0.007595,
34
+ 0.018067,
35
+ 0.007425,
36
+ 0.013365,
37
+ 0.007489
38
+ ],
39
+ "operations_per_second": 2633311.36,
40
+ "allocated_objects": 100001,
41
+ "allocations_per_operation": 5.0
42
+ },
43
+ "byte_queue_bulk_drain": {
44
+ "iterations": 10,
45
+ "seconds": 0.058171,
46
+ "sample_seconds": [
47
+ 0.011596,
48
+ 0.011667,
49
+ 0.01175,
50
+ 0.011877,
51
+ 0.011281
52
+ ],
53
+ "operations_per_second": 857.12,
54
+ "allocated_objects": 70,
55
+ "allocations_per_operation": 7.0
56
+ },
57
+ "telemetry_counter_and_timer": {
58
+ "iterations": 50000,
59
+ "seconds": 0.04747,
60
+ "sample_seconds": [
61
+ 0.009476,
62
+ 0.009558,
63
+ 0.009509,
64
+ 0.009478,
65
+ 0.009449
66
+ ],
67
+ "operations_per_second": 5275374.56,
68
+ "allocated_objects": 0,
69
+ "allocations_per_operation": 0.0
70
+ },
71
+ "telemetry_construction": {
72
+ "iterations": 100000,
73
+ "seconds": 0.107426,
74
+ "sample_seconds": [
75
+ 0.02273,
76
+ 0.021634,
77
+ 0.020193,
78
+ 0.021322,
79
+ 0.021547
80
+ ],
81
+ "operations_per_second": 4641017.33,
82
+ "allocated_objects": 300000,
83
+ "allocations_per_operation": 3.0
84
+ },
85
+ "manifest_progress": {
86
+ "iterations": 250,
87
+ "seconds": 0.714513,
88
+ "sample_seconds": [
89
+ 0.144839,
90
+ 0.145152,
91
+ 0.143372,
92
+ 0.140609,
93
+ 0.140541
94
+ ],
95
+ "operations_per_second": 1743.72,
96
+ "allocated_objects": 21250,
97
+ "allocations_per_operation": 85.0
98
+ },
99
+ "partition_range_planning": {
100
+ "iterations": 100000,
101
+ "seconds": 0.045859,
102
+ "sample_seconds": [
103
+ 0.009671,
104
+ 0.009649,
105
+ 0.009258,
106
+ 0.008996,
107
+ 0.008285
108
+ ],
109
+ "operations_per_second": 10801468.79,
110
+ "allocated_objects": 100000,
111
+ "allocations_per_operation": 1.0
112
+ },
113
+ "partition_cursor_sampling": {
114
+ "iterations": 10,
115
+ "seconds": 0.006557,
116
+ "sample_seconds": [
117
+ 0.001337,
118
+ 0.001296,
119
+ 0.001314,
120
+ 0.001294,
121
+ 0.001316
122
+ ],
123
+ "operations_per_second": 7610.35,
124
+ "allocated_objects": 250,
125
+ "allocations_per_operation": 25.0
126
+ },
127
+ "snapshot_configuration": {
128
+ "iterations": 10000,
129
+ "seconds": 0.211803,
130
+ "sample_seconds": [
131
+ 0.043064,
132
+ 0.041384,
133
+ 0.042928,
134
+ 0.042796,
135
+ 0.041631
136
+ ],
137
+ "operations_per_second": 233666.7,
138
+ "allocated_objects": 170000,
139
+ "allocations_per_operation": 17.0
140
+ },
141
+ "snapshot_empty_export": {
142
+ "iterations": 20,
143
+ "seconds": 0.046427,
144
+ "sample_seconds": [
145
+ 0.008102,
146
+ 0.007108,
147
+ 0.008957,
148
+ 0.014756,
149
+ 0.007504
150
+ ],
151
+ "operations_per_second": 2468.53,
152
+ "allocated_objects": 3420,
153
+ "allocations_per_operation": 171.0
154
+ },
155
+ "snapshot_raw_batching": {
156
+ "iterations": 100,
157
+ "seconds": 0.059924,
158
+ "sample_seconds": [
159
+ 0.011836,
160
+ 0.012093,
161
+ 0.012064,
162
+ 0.011962,
163
+ 0.011969
164
+ ],
165
+ "operations_per_second": 8354.92,
166
+ "allocated_objects": 1800,
167
+ "allocations_per_operation": 18.0
168
+ },
169
+ "snapshot_jsonl_serial_pipeline": {
170
+ "iterations": 3,
171
+ "seconds": 1.593689,
172
+ "sample_seconds": [
173
+ 0.332539,
174
+ 0.320695,
175
+ 0.319876,
176
+ 0.30983,
177
+ 0.310749
178
+ ],
179
+ "operations_per_second": 9.38,
180
+ "allocated_objects": 543645,
181
+ "allocations_per_operation": 181215.0
182
+ },
183
+ "snapshot_jsonl_pipeline": {
184
+ "iterations": 3,
185
+ "seconds": 0.978552,
186
+ "sample_seconds": [
187
+ 0.195724,
188
+ 0.195973,
189
+ 0.19579,
190
+ 0.195125,
191
+ 0.19594
192
+ ],
193
+ "operations_per_second": 15.32,
194
+ "allocated_objects": 543867,
195
+ "allocations_per_operation": 181289.0
196
+ },
197
+ "jsonl_batch_write": {
198
+ "iterations": 100,
199
+ "seconds": 0.11875,
200
+ "sample_seconds": [
201
+ 0.022894,
202
+ 0.025733,
203
+ 0.021527,
204
+ 0.024267,
205
+ 0.024329
206
+ ],
207
+ "operations_per_second": 4120.82,
208
+ "allocated_objects": 182800,
209
+ "allocations_per_operation": 1828.0
210
+ },
211
+ "jsonl_zstd_batch_write": {
212
+ "iterations": 50,
213
+ "seconds": 0.071127,
214
+ "sample_seconds": [
215
+ 0.016098,
216
+ 0.012678,
217
+ 0.013116,
218
+ 0.013103,
219
+ 0.016132
220
+ ],
221
+ "operations_per_second": 3812.14,
222
+ "allocated_objects": 92050,
223
+ "allocations_per_operation": 1841.0
224
+ },
225
+ "jsonl_preencoded_accounting": {
226
+ "iterations": 10000,
227
+ "seconds": 0.155715,
228
+ "sample_seconds": [
229
+ 0.031233,
230
+ 0.03071,
231
+ 0.031271,
232
+ 0.031395,
233
+ 0.031106
234
+ ],
235
+ "operations_per_second": 320174.17,
236
+ "allocated_objects": 0,
237
+ "allocations_per_operation": 0.0
238
+ },
239
+ "csv_batch_write": {
240
+ "iterations": 100,
241
+ "seconds": 0.29281,
242
+ "sample_seconds": [
243
+ 0.057804,
244
+ 0.058016,
245
+ 0.058681,
246
+ 0.059051,
247
+ 0.059258
248
+ ],
249
+ "operations_per_second": 1704.13,
250
+ "allocated_objects": 363700,
251
+ "allocations_per_operation": 3637.0
252
+ },
253
+ "csv_zstd_batch_write": {
254
+ "iterations": 50,
255
+ "seconds": 0.222587,
256
+ "sample_seconds": [
257
+ 0.05143,
258
+ 0.046745,
259
+ 0.038643,
260
+ 0.052235,
261
+ 0.033534
262
+ ],
263
+ "operations_per_second": 1069.63,
264
+ "allocated_objects": 182500,
265
+ "allocations_per_operation": 3650.0
266
+ },
267
+ "csv_gzip_batch_write": {
268
+ "iterations": 50,
269
+ "seconds": 0.148172,
270
+ "sample_seconds": [
271
+ 0.027482,
272
+ 0.034167,
273
+ 0.027431,
274
+ 0.031337,
275
+ 0.027755
276
+ ],
277
+ "operations_per_second": 1801.48,
278
+ "allocated_objects": 182550,
279
+ "allocations_per_operation": 3651.0
280
+ },
281
+ "csv_counting_io_write": {
282
+ "iterations": 100000,
283
+ "seconds": 0.041051,
284
+ "sample_seconds": [
285
+ 0.008018,
286
+ 0.008041,
287
+ 0.008821,
288
+ 0.008223,
289
+ 0.007948
290
+ ],
291
+ "operations_per_second": 12436264.16,
292
+ "allocated_objects": 0,
293
+ "allocations_per_operation": 0.0
294
+ },
295
+ "csv_row_serialization": {
296
+ "iterations": 25000,
297
+ "seconds": 0.291323,
298
+ "sample_seconds": [
299
+ 0.061351,
300
+ 0.057502,
301
+ 0.057275,
302
+ 0.057677,
303
+ 0.057518
304
+ ],
305
+ "operations_per_second": 434646.55,
306
+ "allocated_objects": 375000,
307
+ "allocations_per_operation": 15.0
308
+ },
309
+ "parquet_buffer_append": {
310
+ "iterations": 5000,
311
+ "seconds": 0.005627,
312
+ "sample_seconds": [
313
+ 0.001129,
314
+ 0.001121,
315
+ 0.001122,
316
+ 0.001125,
317
+ 0.00113
318
+ ],
319
+ "operations_per_second": 4444444.64,
320
+ "allocated_objects": 0,
321
+ "allocations_per_operation": 0.0
322
+ },
323
+ "parquet_object_id_array": {
324
+ "iterations": 1000,
325
+ "seconds": 0.237818,
326
+ "sample_seconds": [
327
+ 0.048408,
328
+ 0.044886,
329
+ 0.056985,
330
+ 0.043491,
331
+ 0.044048
332
+ ],
333
+ "operations_per_second": 22278.66,
334
+ "allocated_objects": 64000,
335
+ "allocations_per_operation": 64.0
336
+ },
337
+ "parquet_batch_write": {
338
+ "iterations": 10,
339
+ "seconds": 0.049999,
340
+ "sample_seconds": [
341
+ 0.010937,
342
+ 0.00987,
343
+ 0.009626,
344
+ 0.00971,
345
+ 0.009856
346
+ ],
347
+ "operations_per_second": 1014.61,
348
+ "allocated_objects": 18692,
349
+ "allocations_per_operation": 1869.2
350
+ },
351
+ "parquet_row_group_write": {
352
+ "iterations": 2,
353
+ "seconds": 0.18688,
354
+ "sample_seconds": [
355
+ 0.03926,
356
+ 0.036353,
357
+ 0.037264,
358
+ 0.03747,
359
+ 0.036533
360
+ ],
361
+ "operations_per_second": 53.67,
362
+ "allocated_objects": 68880,
363
+ "allocations_per_operation": 34440.0
364
+ }
365
+ }
366
+ }
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'benchmark'
4
+ require 'json'
5
+ require 'timeout'
6
+
7
+ module Purplelight
8
+ module Microbench
9
+ Case = Data.define(:name, :paths, :iterations, :block)
10
+
11
+ # Runs registered microbenchmarks under strict duration and coverage limits.
12
+ class Harness
13
+ MAX_CASE_SECONDS = 2.0
14
+ SAMPLES = 5
15
+ REQUIRED_PATHS = %i[
16
+ manifest partitioner queue snapshot telemetry writer_csv writer_jsonl writer_parquet
17
+ ].freeze
18
+
19
+ def initialize
20
+ @cases = []
21
+ end
22
+
23
+ def register(name, paths:, iterations: 1, &block)
24
+ raise ArgumentError, 'benchmark block required' unless block
25
+
26
+ @cases << Case.new(name:, paths: Array(paths).map(&:to_sym), iterations:, block:)
27
+ end
28
+
29
+ def run(baseline_path: nil)
30
+ verify_coverage!
31
+ results = @cases.to_h { |benchmark_case| [benchmark_case.name, measure(benchmark_case)] }
32
+ report = {
33
+ generated_at: Time.now.utc.iso8601,
34
+ coverage: coverage,
35
+ max_case_seconds: MAX_CASE_SECONDS,
36
+ samples: SAMPLES,
37
+ results:
38
+ }
39
+ report[:comparison] = compare(results, JSON.parse(File.read(baseline_path))) if baseline_path
40
+ report
41
+ end
42
+
43
+ def coverage
44
+ covered = @cases.flat_map(&:paths).uniq
45
+ {
46
+ covered: covered.sort,
47
+ required: REQUIRED_PATHS,
48
+ percent: (covered.length.fdiv(REQUIRED_PATHS.length) * 100).round(2)
49
+ }
50
+ end
51
+
52
+ private
53
+
54
+ def verify_coverage!
55
+ missing = REQUIRED_PATHS - @cases.flat_map(&:paths)
56
+ raise "missing benchmark paths: #{missing.join(', ')}" unless missing.empty?
57
+ end
58
+
59
+ def measure(benchmark_case)
60
+ samples = Array.new(SAMPLES) do
61
+ Timeout.timeout(MAX_CASE_SECONDS) do
62
+ Benchmark.realtime do
63
+ benchmark_case.iterations.times { benchmark_case.block.call }
64
+ end
65
+ end
66
+ end
67
+ allocated_objects = Timeout.timeout(MAX_CASE_SECONDS) do
68
+ allocations_before = GC.stat(:total_allocated_objects)
69
+ benchmark_case.iterations.times { benchmark_case.block.call }
70
+ GC.stat(:total_allocated_objects) - allocations_before
71
+ end
72
+ median = samples.sort.fetch(SAMPLES / 2)
73
+ {
74
+ iterations: benchmark_case.iterations,
75
+ seconds: samples.sum.round(6),
76
+ sample_seconds: samples.map { |elapsed| elapsed.round(6) },
77
+ operations_per_second: (benchmark_case.iterations / median).round(2),
78
+ allocated_objects:,
79
+ allocations_per_operation: allocated_objects.fdiv(benchmark_case.iterations).round(2)
80
+ }
81
+ rescue Timeout::Error
82
+ raise "#{benchmark_case.name} exceeded #{MAX_CASE_SECONDS}s in a single measurement"
83
+ end
84
+
85
+ def compare(results, baseline)
86
+ old_results = baseline.fetch('results')
87
+ results.to_h do |name, result|
88
+ old_result = old_results[name.to_s]
89
+ next [name, { baseline: false }] unless old_result
90
+
91
+ old_rate = old_result.fetch('operations_per_second')
92
+ delta = ((result[:operations_per_second] / old_rate) - 1.0) * 100
93
+ comparison = { percent: delta.round(2), significant: delta.abs > 3.0 }
94
+ if old_result['allocations_per_operation']
95
+ old_allocations = old_result.fetch('allocations_per_operation')
96
+ new_allocations = result[:allocations_per_operation]
97
+ allocation_delta = if old_allocations.zero?
98
+ (new_allocations.zero? ? 0.0 : nil)
99
+ else
100
+ (((new_allocations / old_allocations) - 1.0) * 100)
101
+ end
102
+ comparison[:allocation_percent] = allocation_delta&.round(2)
103
+ end
104
+ [name, comparison]
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end