data_drain 0.2.0 → 0.2.1

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.
@@ -0,0 +1,1162 @@
1
+ # DataDrain — Plan de Mejora v0.2.0 → v0.3.1
2
+
3
+ **Versión actual:** 0.2.0
4
+ **Última actualización:** 2026-04-13
5
+ **Owner:** Gabriel
6
+ **Estado global:** No iniciado
7
+
8
+ Documento de seguimiento para coordinar la evolución de la gema con otros agentes (Claude, Gemini) y revisores humanos. Cada item es autocontenido: contexto, cambios, archivos afectados, criterios de aceptación, riesgos.
9
+
10
+ ---
11
+
12
+ ## Índice
13
+
14
+ - [Resumen ejecutivo](#resumen-ejecutivo)
15
+ - [Convenciones del documento](#convenciones-del-documento)
16
+ - [Releases planificados](#releases-planificados)
17
+ - [Items detallados](#items-detallados)
18
+ - [P0 — Seguridad y correctitud (v0.2.0)](#p0--seguridad-y-correctitud-v020)
19
+ - [P1 — Performance y robustez (v0.2.1 / v0.3.0)](#p1--performance-y-robustez-v021--v030)
20
+ - [P2 — Calidad y DX (v0.3.1)](#p2--calidad-y-dx-v031)
21
+ - [Riesgos transversales](#riesgos-transversales)
22
+ - [Checklist de release](#checklist-de-release)
23
+
24
+ ---
25
+
26
+ ## Resumen ejecutivo
27
+
28
+ DataDrain v0.1.19 es una gema bien arquitecturada (Storage Adapter, Observability, thread-local DuckDB) con observabilidad estructurada de clase empresarial. Sin embargo presenta:
29
+
30
+ - **Riesgos de seguridad moderados:** SQL injection en `table_name`/`select_sql`, credenciales S3 interpoladas en queries DuckDB.
31
+ - **Cobertura de tests baja:** solo 4 specs, sin cobertura de Record/Storage/GlueRunner.
32
+ - **Memory leak potencial:** conexión DuckDB thread-local sin cleanup.
33
+ - **Documentación de tuning ausente:** sin guía para purgas masivas, índices, particionamiento.
34
+
35
+ Este plan agrupa 17 items en 4 releases incrementales (v0.2.0 → v0.3.1) priorizados por impacto.
36
+
37
+ ---
38
+
39
+ ## Convenciones del documento
40
+
41
+ ### Estados
42
+
43
+ - `[ ]` no iniciado
44
+ - `[~]` en progreso
45
+ - `[x]` completado
46
+ - `[!]` bloqueado o requiere decisión
47
+
48
+ ### Prioridades
49
+
50
+ - **P0** — bloqueante para producción enterprise. Hardening esencial.
51
+ - **P1** — mejora robustez/performance significativa. No bloqueante.
52
+ - **P2** — calidad de vida del desarrollador, no afecta runtime.
53
+
54
+ ### Etiquetas de tipo
55
+
56
+ - `feat` — funcionalidad nueva
57
+ - `fix` — corrección de bug
58
+ - `refactor` — reorganización sin cambio de comportamiento
59
+ - `docs` — documentación
60
+ - `test` — agregar/mejorar tests
61
+ - `security` — corrección o hardening de seguridad
62
+ - `perf` — performance
63
+ - `chore` — infra (CI, gemspec, etc.)
64
+
65
+ ### Compatibilidad
66
+
67
+ Cada item indica si es **breaking** o **backward-compatible**. Breaking changes exigen bump de minor (v0.2.0 → v0.3.0) según la política semver pre-1.0.
68
+
69
+ ---
70
+
71
+ ## Releases planificados
72
+
73
+ ### v0.2.0 — Hardening de seguridad y testing
74
+ **Foco:** cerrar gaps P0. Producción-ready para datos sensibles.
75
+ **Items:** 1, 2, 3, 4
76
+ **Breaking:** parcial (item 1 y 2 cambian comportamiento si caller dependía del modo viejo).
77
+
78
+ ### v0.2.1 — Robustez operacional
79
+ **Foco:** validaciones, timeouts, alertas, docs de tuning.
80
+ **Items:** 5, 7, 8, 9, 11a
81
+ **Breaking:** no.
82
+
83
+ ### v0.3.0 — Refactor y observabilidad avanzada
84
+ **Foco:** simplificar Engine, sandboxing DuckDB, alertas runtime.
85
+ **Items:** 6, 10, 11b
86
+ **Breaking:** no (refactor interno).
87
+
88
+ ### v0.3.1 — Calidad de código y DX
89
+ **Foco:** YARD, CI, deduplicación, DuckDB Friendly SQL.
90
+ **Items:** 12, 13, 14, 15, 16
91
+ **Breaking:** no.
92
+
93
+ ---
94
+
95
+ ## Items detallados
96
+
97
+ ### P0 — Seguridad y correctitud (v0.2.0)
98
+
99
+ ---
100
+
101
+ #### Item 1 — Migrar credenciales S3 a `credential_chain` de DuckDB
102
+
103
+ **Estado:** `[x]`
104
+ **Prioridad:** P0
105
+ **Tipo:** `security` `feat`
106
+ **Compatibilidad:** backward-compatible (con fallback al modo explícito)
107
+ **Estimación:** S (2-4h)
108
+
109
+ ##### Contexto
110
+
111
+ `Storage::S3#setup_duckdb` interpola credenciales AWS directamente en queries DuckDB:
112
+
113
+ ```ruby
114
+ connection.query("SET s3_access_key_id='#{@config.aws_access_key_id}';")
115
+ connection.query("SET s3_secret_access_key='#{@config.aws_secret_access_key}';")
116
+ ```
117
+
118
+ Las credenciales quedan en el proceso DuckDB y, si el query log de DuckDB se activa, en logs. La skill DuckDB oficial `read-file` muestra el patrón moderno: `CREATE SECRET (TYPE S3, PROVIDER credential_chain)` que usa el AWS credential chain (IAM roles, env vars, `~/.aws/credentials`).
119
+
120
+ ##### Cambios
121
+
122
+ 1. En `lib/data_drain/storage/s3.rb#setup_duckdb`:
123
+ - Si `aws_access_key_id` está seteado en config → modo explícito (compatibilidad):
124
+ ```sql
125
+ CREATE OR REPLACE SECRET s3_secret (
126
+ TYPE S3,
127
+ KEY_ID '...',
128
+ SECRET '...',
129
+ REGION '...'
130
+ );
131
+ ```
132
+ - Si NO está seteado → `credential_chain`:
133
+ ```sql
134
+ CREATE OR REPLACE SECRET s3_secret (
135
+ TYPE S3,
136
+ PROVIDER credential_chain,
137
+ REGION '...'
138
+ );
139
+ ```
140
+ 2. Reemplazar todos los `SET s3_*` por el `CREATE SECRET`.
141
+ 3. Documentar en CLAUDE.md / SKILL.md el nuevo comportamiento.
142
+
143
+ ##### Archivos afectados
144
+
145
+ - `lib/data_drain/storage/s3.rb` (cambio principal)
146
+ - `lib/data_drain/configuration.rb` (sin cambios pero documentar opcionalidad de aws_*)
147
+ - `CLAUDE.md` (sección "Seguridad")
148
+ - `skill/SKILL.md` y `skill/references/api-detallada.md`
149
+ - `README.md` (sección Configuración: aclarar que `aws_access_key_id`/`aws_secret_access_key` son opcionales si hay IAM role / env vars)
150
+ - `CHANGELOG.md`
151
+ - `spec/data_drain/storage/s3_spec.rb` (nuevo, ver item 4)
152
+
153
+ ##### Criterios de aceptación
154
+
155
+ - [ ] `Storage::S3#setup_duckdb` usa `CREATE SECRET` en lugar de `SET s3_*`.
156
+ - [ ] Si `aws_access_key_id` y `aws_secret_access_key` están seteados, usa modo explícito (KEY_ID/SECRET).
157
+ - [ ] Si están vacíos, usa `credential_chain`.
158
+ - [ ] Test integración con env vars (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`) confirma que `credential_chain` resuelve correctamente.
159
+ - [ ] Test que con credenciales explícitas también funciona.
160
+ - [ ] CHANGELOG documenta el cambio y la backward-compat.
161
+
162
+ ##### Riesgos
163
+
164
+ - **Versión de DuckDB:** `CREATE SECRET` requiere DuckDB ≥ 0.10. La gema requiere `~> 1.4`, OK.
165
+ - **Permisos IAM rol insuficientes:** caller puede tener config con KEY/SECRET por costumbre y al borrarlos esperando IAM rol, descubrir que el rol no tiene `s3:GetObject`. Documentar en CHANGELOG.
166
+ - **Compatibilidad regional:** `REGION` debe seguir siendo obligatorio en config.
167
+
168
+ ##### Notas para el revisor
169
+
170
+ - Verificar que el `CREATE OR REPLACE SECRET` no rompe si se llama múltiples veces en la misma conexión DuckDB.
171
+ - Confirmar que el `secret_name` (`s3_secret`) no entra en conflicto con secrets pre-existentes en una sesión compartida.
172
+
173
+ ---
174
+
175
+ #### Item 2 — Validación regex de `table_name`, `primary_key` (anti-SQL injection)
176
+
177
+ **Estado:** `[x]`
178
+ **Prioridad:** P0
179
+ **Tipo:** `security` `fix`
180
+ **Compatibilidad:** backward-compatible (rechaza inputs que antes pasaban silenciosamente)
181
+ **Estimación:** S (1-2h)
182
+
183
+ ##### Contexto
184
+
185
+ `Engine#initialize` acepta `table_name`, `primary_key`, `select_sql`, `where_clause` y los interpola en SQL sin validación. Aunque la gema asume "caller trusted", una validación cheap del identificador SQL en `table_name` y `primary_key` cierra el vector más obvio.
186
+
187
+ `select_sql` y `where_clause` se documentan explícitamente como SQL crudo trusted (no se validan).
188
+
189
+ ##### Cambios
190
+
191
+ 1. En `Engine#initialize` agregar validación:
192
+ ```ruby
193
+ IDENTIFIER_REGEX = /\A[a-zA-Z_][a-zA-Z0-9_]*\z/.freeze
194
+
195
+ def initialize(options)
196
+ # ...
197
+ @table_name = options.fetch(:table_name)
198
+ @primary_key = options.fetch(:primary_key, "id")
199
+
200
+ unless IDENTIFIER_REGEX.match?(@table_name)
201
+ raise DataDrain::ConfigurationError,
202
+ "table_name '#{@table_name}' no es un identificador SQL válido"
203
+ end
204
+ unless IDENTIFIER_REGEX.match?(@primary_key)
205
+ raise DataDrain::ConfigurationError,
206
+ "primary_key '#{@primary_key}' no es un identificador SQL válido"
207
+ end
208
+ # ...
209
+ end
210
+ ```
211
+ 2. Agregar mismo guard en `FileIngestor#initialize` para `folder_name`.
212
+ 3. Documentar en `skill/references/antipatrones.md` (item 13 ya existe — actualizar).
213
+
214
+ ##### Archivos afectados
215
+
216
+ - `lib/data_drain/engine.rb`
217
+ - `lib/data_drain/file_ingestor.rb`
218
+ - `skill/references/antipatrones.md`
219
+ - `CLAUDE.md` (sección "Seguridad")
220
+ - `CHANGELOG.md`
221
+ - `spec/data_drain/engine_spec.rb` (agregar tests de validación)
222
+
223
+ ##### Criterios de aceptación
224
+
225
+ - [ ] `Engine.new(table_name: "; DROP TABLE foo; --")` levanta `ConfigurationError`.
226
+ - [ ] `Engine.new(primary_key: "id; DROP")` levanta `ConfigurationError`.
227
+ - [ ] Identificadores válidos (`"versions"`, `"my_table_2"`) pasan.
228
+ - [ ] Tests cubren happy + sad paths.
229
+ - [ ] Documentado en CHANGELOG y antipatrones.
230
+
231
+ ##### Riesgos
232
+
233
+ - **Tablas con schemas (`schema.table`):** la regex actual no acepta `.`. Si algún caller usa `"public.versions"`, romperá. Decisión: forzar `table_name` solo (sin schema) y mantener el `public.` hardcodeado en el SQL como ya está. Documentar.
234
+ - **Tablas con mayúsculas comilladas:** PostgreSQL acepta `"WeirdTable"` con comillas. La gema NO lo soporta hoy. Mantener restricción.
235
+
236
+ ##### Notas para el revisor
237
+
238
+ - Verificar que ningún caller del repo Wispro pasa `table_name` con `.` o caracteres especiales.
239
+
240
+ ---
241
+
242
+ #### Item 3 — Cleanup de conexión DuckDB thread-local
243
+
244
+ **Estado:** `[x]`
245
+ **Prioridad:** P0
246
+ **Tipo:** `fix` `feat`
247
+ **Compatibilidad:** backward-compatible (agrega API, no quita)
248
+ **Estimación:** M (4-6h)
249
+
250
+ ##### Contexto
251
+
252
+ `Record.connection` cachea `Thread.current[:data_drain_duckdb] = { db:, conn: }` indefinidamente. En Puma/Sidekiq donde los threads son reutilizados, la conexión DuckDB persiste mientras vive el thread (potencialmente días). No hay API para cerrarla.
253
+
254
+ Riesgos:
255
+ - Memoria DuckDB (caches internos) crece sin liberarse.
256
+ - Configuración stale: si cambia `storage_mode` o credenciales, la conexión cacheada queda inválida.
257
+
258
+ ##### Cambios
259
+
260
+ 1. Agregar `Record.disconnect!` (método de clase):
261
+ ```ruby
262
+ def self.disconnect!
263
+ return unless Thread.current[:data_drain_duckdb]
264
+
265
+ entry = Thread.current.delete(:data_drain_duckdb)
266
+ entry[:conn]&.close
267
+ entry[:db]&.close
268
+ rescue StandardError
269
+ # silencio en cleanup
270
+ end
271
+ ```
272
+ 2. Documentar en CLAUDE.md uso recomendado:
273
+ - **Sidekiq:** middleware server que llama `Record.disconnect!` después de cada job.
274
+ - **Puma:** llamar en `on_worker_shutdown` / hooks de lifecycle.
275
+ 3. Considerar `at_exit { Record.disconnect! }` como safety net (opcional, debatir).
276
+ 4. Evaluar agregar `Record.reconnect!` (cierra + lazy reabre en next call).
277
+
278
+ ##### Archivos afectados
279
+
280
+ - `lib/data_drain/record.rb`
281
+ - `CLAUDE.md` (sección "Conexiones thread-local")
282
+ - `skill/references/api-detallada.md` (sección Record)
283
+ - `skill/references/antipatrones.md` (actualizar item 12)
284
+ - `CHANGELOG.md`
285
+ - `spec/data_drain/record_spec.rb` (nuevo)
286
+
287
+ ##### Criterios de aceptación
288
+
289
+ - [ ] `Record.disconnect!` existe y limpia `Thread.current`.
290
+ - [ ] Llamarlo dos veces seguidas no rompe (idempotente).
291
+ - [ ] Después de `disconnect!`, la próxima query reabre conexión nueva.
292
+ - [ ] Test simula múltiples threads con conexiones independientes.
293
+ - [ ] Test confirma que un thread no afecta a otro al desconectar.
294
+ - [ ] Documentación incluye snippet Sidekiq middleware.
295
+
296
+ ##### Riesgos
297
+
298
+ - **Race condition:** si un thread está en medio de una query y otro thread llama `disconnect!`... pero `disconnect!` solo afecta `Thread.current`, así que es seguro.
299
+ - **Snippet Sidekiq:** verificar que el middleware corre incluso en jobs que fallan (probablemente sí, por `ensure`).
300
+
301
+ ##### Notas para el revisor
302
+
303
+ - Confirmar que `DuckDB::Connection#close` es seguro de llamar incluso si la conexión nunca ejecutó queries.
304
+
305
+ ---
306
+
307
+ #### Item 4 — Cobertura de tests P0 (Record, Storage, GlueRunner, Observability)
308
+
309
+ **Estado:** `[x]`
310
+ **Prioridad:** P0
311
+ **Tipo:** `test`
312
+ **Compatibilidad:** N/A
313
+ **Estimación:** L (1-2 días)
314
+
315
+ ##### Contexto
316
+
317
+ Cobertura actual: 4 specs (Engine: 2, FileIngestor: 1, version: 1). Sin tests de Record, Storage::*, GlueRunner, Observability, Configuration. Bloquea confianza para refactors futuros (item 10).
318
+
319
+ ##### Cambios
320
+
321
+ Crear specs para cada componente. Estructura sugerida:
322
+
323
+ ```
324
+ spec/
325
+ data_drain/
326
+ engine_spec.rb [existe, agregar tests de validación item 2]
327
+ file_ingestor_spec.rb [existe, agregar tests de validación item 2]
328
+ record_spec.rb [NUEVO]
329
+ glue_runner_spec.rb [NUEVO]
330
+ observability_spec.rb [NUEVO]
331
+ configuration_spec.rb [NUEVO]
332
+ storage_spec.rb [NUEVO — factory]
333
+ storage/
334
+ local_spec.rb [NUEVO]
335
+ s3_spec.rb [NUEVO]
336
+ types/
337
+ json_type_spec.rb [NUEVO]
338
+ ```
339
+
340
+ ##### Tests por componente
341
+
342
+ **`record_spec.rb`:**
343
+ - `.where` con partition_keys completas y parciales
344
+ - `.find` con id que existe / no existe
345
+ - `.find` sanitiza id con comilla simple (`"foo' OR 1=1"`)
346
+ - `.destroy_all` delega correctamente al adapter
347
+ - `.connection` es thread-local (test con 2 threads)
348
+ - `.disconnect!` (item 3)
349
+ - `.where` retorna `[]` si Parquet no existe (no levanta)
350
+ - `build_query_path` respeta orden de `partition_keys` no de kwargs
351
+
352
+ **`storage/local_spec.rb`:**
353
+ - `prepare_export_path` crea directorio
354
+ - `build_path` arma path correcto con/sin partition
355
+ - `destroy_partitions` con todas las keys → borra directorio específico
356
+ - `destroy_partitions` con keys parciales → wildcard glob
357
+ - `destroy_partitions` retorna count correcto
358
+
359
+ **`storage/s3_spec.rb`:**
360
+ - `setup_duckdb` ejecuta `CREATE SECRET` (item 1)
361
+ - `build_path` retorna `s3://...`
362
+ - `destroy_partitions` mockeado con `Aws::S3::Client.stub_responses`:
363
+ - prefix correcto
364
+ - regex matching
365
+ - batches de 1000
366
+ - retorna count
367
+
368
+ **`glue_runner_spec.rb`:**
369
+ - `run_and_wait` con stub `SUCCEEDED` retorna `true`
370
+ - `run_and_wait` con `FAILED` levanta `RuntimeError`
371
+ - `run_and_wait` con `STOPPED` y `TIMEOUT` ídem
372
+ - Polling: stub que devuelve `RUNNING` 2 veces y luego `SUCCEEDED`
373
+ - `error_message` truncado a 200 chars
374
+ - Logs emitidos con campos correctos
375
+
376
+ **`observability_spec.rb`:**
377
+ - `safe_log` no-op si `@logger` es nil
378
+ - `safe_log` formato KV (`component=X event=Y k=v`)
379
+ - `safe_log` filtra secretos (regex después de item 9)
380
+ - `safe_log` `rescue StandardError` no propaga
381
+ - `exception_metadata` trunca message a 200, escapa `"`
382
+ - `observability_name` extrae primer namespace en snake_case
383
+ - Funciona con `include` (instance) y `extend` (class)
384
+
385
+ **`configuration_spec.rb`:**
386
+ - Defaults correctos
387
+ - `duckdb_connection_string` formato URI correcto
388
+ - `idle_in_transaction_session_timeout = 0` se incluye (no se ignora)
389
+ - `Configuration#validate!` (item 8) levanta cuando falta config
390
+
391
+ **`storage_spec.rb`:**
392
+ - `Storage.adapter` retorna Local cuando `:local`
393
+ - `Storage.adapter` retorna S3 cuando `:s3`
394
+ - `Storage.adapter` levanta `InvalidAdapterError` con modo desconocido
395
+ - `Storage.adapter` cachea (misma instancia entre llamadas)
396
+ - `Storage.reset_adapter!` invalida cache
397
+
398
+ **`types/json_type_spec.rb`:**
399
+ - `cast` con String JSON válido → Hash
400
+ - `cast` con String JSON inválido → retorna String original (no levanta)
401
+ - `cast` con Hash → retorna Hash
402
+ - `cast` con nil → nil
403
+
404
+ ##### Archivos afectados
405
+
406
+ - 9 archivos de spec nuevos
407
+ - `spec/spec_helper.rb` (posibles helpers compartidos)
408
+
409
+ ##### Criterios de aceptación
410
+
411
+ - [ ] Cobertura medida con SimpleCov ≥ 80% líneas.
412
+ - [ ] `bundle exec rspec` corre en < 30s.
413
+ - [ ] No hay tests que dependan de S3 real (todo mockeado).
414
+ - [ ] Tests de Engine e Integration pueden requerir Postgres real (documentar en README).
415
+ - [ ] CI corre todo el suite sin flakes (10 corridas seguidas pasan).
416
+
417
+ ##### Riesgos
418
+
419
+ - **Mocking AWS:** `aws-sdk-s3 ~> 1.114` soporta `Client.stub_responses`. Verificar versión instalada.
420
+ - **Mocking DuckDB:** no hay mock library nativa. Usar archivos Parquet de fixture en `spec/fixtures/`.
421
+ - **Tests con Postgres real:** decidir si CI levanta Postgres (Docker) o si Engine specs se marcan `:integration` y se corren aparte.
422
+
423
+ ##### Notas para el revisor
424
+
425
+ - Definir convención de fixtures (dónde, formato).
426
+ - Decidir umbral de SimpleCov mínimo (sugerido 80%).
427
+
428
+ ---
429
+
430
+ ### P1 — Performance y robustez (v0.2.1 / v0.3.0)
431
+
432
+ ---
433
+
434
+ #### Item 5 — VACUUM ANALYZE opcional post-purga
435
+
436
+ **Estado:** `[ ]`
437
+ **Prioridad:** P1
438
+ **Tipo:** `feat` `perf`
439
+ **Compatibilidad:** backward-compatible (default `false`, opt-in)
440
+ **Estimación:** S (2-3h)
441
+ **Release:** v0.2.1
442
+
443
+ ##### Contexto
444
+
445
+ Purgar millones de rows deja dead tuples en Postgres. Sin `VACUUM`, el espacio no se libera y el siguiente seq scan recorre páginas vacías. En tablas no particionadas esto degrada performance progresivamente.
446
+
447
+ ##### Cambios
448
+
449
+ 1. Agregar a `Configuration`:
450
+ ```ruby
451
+ attr_accessor :vacuum_after_purge # default: false
452
+ ```
453
+ 2. En `Engine#purge_from_postgres`, al final (después del loop, dentro del mismo `begin`/`ensure`):
454
+ ```ruby
455
+ if @config.vacuum_after_purge && total_deleted.positive?
456
+ vacuum_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
457
+ dead_before = fetch_dead_tuple_count(conn)
458
+ conn.exec("VACUUM ANALYZE #{@table_name};")
459
+ dead_after = fetch_dead_tuple_count(conn)
460
+ vacuum_duration = Process.clock_gettime(...) - vacuum_start
461
+
462
+ safe_log(:info, "engine.vacuum_complete", {
463
+ table: @table_name,
464
+ duration_s: vacuum_duration.round(2),
465
+ dead_tuples_before: dead_before,
466
+ dead_tuples_after: dead_after
467
+ })
468
+ end
469
+ ```
470
+ 3. Helper `fetch_dead_tuple_count(conn)` que consulta `pg_stat_user_tables`.
471
+
472
+ ##### Archivos afectados
473
+
474
+ - `lib/data_drain/configuration.rb`
475
+ - `lib/data_drain/engine.rb`
476
+ - `skill/references/eventos-telemetria.md` (agregar `engine.vacuum_complete`)
477
+ - `CLAUDE.md`
478
+ - `README.md` (mencionar opción)
479
+ - `CHANGELOG.md`
480
+ - `spec/data_drain/engine_spec.rb`
481
+
482
+ ##### Criterios de aceptación
483
+
484
+ - [ ] `vacuum_after_purge = true` ejecuta VACUUM ANALYZE post-purga.
485
+ - [ ] No corre si `total_deleted == 0`.
486
+ - [ ] No corre si la verificación de integridad falló (purga abortada).
487
+ - [ ] Emite `engine.vacuum_complete` con métricas.
488
+ - [ ] VACUUM no bloquea por errores (rescue + log warning).
489
+
490
+ ##### Riesgos
491
+
492
+ - **VACUUM no se puede correr dentro de transacción.** El método `conn.exec` directo está bien (autocommit). Verificar que no estamos en bloque BEGIN.
493
+ - **`VACUUM ANALYZE` es costoso.** En tablas grandes puede tardar horas. Documentar.
494
+ - **`VACUUM FULL` ≠ `VACUUM`.** No usar FULL — bloquea la tabla.
495
+ - **Permisos:** usuario Postgres debe tener `VACUUM` privilege (owner de tabla o `MAINTAIN` en PG16+).
496
+
497
+ ##### Notas para el revisor
498
+
499
+ - ¿Permitir `vacuum_after_purge` por tabla, no solo global? Por ahora global, simplifica.
500
+
501
+ ---
502
+
503
+ #### Item 7 — `max_wait_seconds` en `GlueRunner.run_and_wait`
504
+
505
+ **Estado:** `[ ]`
506
+ **Prioridad:** P1
507
+ **Tipo:** `feat`
508
+ **Compatibilidad:** backward-compatible
509
+ **Estimación:** S (1-2h)
510
+ **Release:** v0.2.1
511
+
512
+ ##### Contexto
513
+
514
+ `GlueRunner.run_and_wait` tiene un loop de polling sin timeout. Si Glue queda colgado en `RUNNING`, bloquea indefinidamente.
515
+
516
+ ##### Cambios
517
+
518
+ 1. Agregar parámetro `max_wait_seconds:` (default `nil` = sin límite):
519
+ ```ruby
520
+ def self.run_and_wait(job_name, arguments = {}, polling_interval: 30, max_wait_seconds: nil)
521
+ # ...
522
+ loop do
523
+ if max_wait_seconds && (Process.clock_gettime(...) - start_time) > max_wait_seconds
524
+ safe_log(:error, "glue_runner.timeout", { job: job_name, run_id:, max_wait_seconds: })
525
+ raise DataDrain::Error, "Glue Job #{job_name} excedió max_wait_seconds=#{max_wait_seconds}"
526
+ end
527
+ # ...
528
+ end
529
+ end
530
+ ```
531
+
532
+ ##### Archivos afectados
533
+
534
+ - `lib/data_drain/glue_runner.rb`
535
+ - `skill/references/eventos-telemetria.md` (agregar `glue_runner.timeout`)
536
+ - `skill/references/antipatrones.md` (actualizar item 14 — ya menciona la falta)
537
+ - `README.md`
538
+ - `CHANGELOG.md`
539
+ - `spec/data_drain/glue_runner_spec.rb`
540
+
541
+ ##### Criterios de aceptación
542
+
543
+ - [ ] Sin `max_wait_seconds` (default), comportamiento idéntico al actual.
544
+ - [ ] Con `max_wait_seconds: 60`, si polling tarda > 60s en SUCCEEDED, levanta `DataDrain::Error`.
545
+ - [ ] Emite log `glue_runner.timeout`.
546
+ - [ ] No interfiere con detección de `FAILED|STOPPED|TIMEOUT` de Glue (esos son estados, no timeout local).
547
+
548
+ ##### Riesgos
549
+
550
+ - Ninguno significativo.
551
+
552
+ ---
553
+
554
+ #### Item 8 — `Configuration#validate!`
555
+
556
+ **Estado:** `[ ]`
557
+ **Prioridad:** P1
558
+ **Tipo:** `feat`
559
+ **Compatibilidad:** backward-compatible (validación opcional explícita)
560
+ **Estimación:** S (2-3h)
561
+ **Release:** v0.2.1
562
+
563
+ ##### Contexto
564
+
565
+ `Configuration` no valida defaults ni invariantes. Errores comunes (storage_mode inválido, credenciales faltantes con `:s3`, db_* faltantes en Engine) se manifiestan tarde con errores oscuros (`NoMethodError`, `Aws::Errors`, `PG::ConnectionBad`).
566
+
567
+ ##### Cambios
568
+
569
+ 1. Agregar `Configuration#validate!`:
570
+ ```ruby
571
+ def validate!
572
+ validate_storage_mode!
573
+ validate_aws_config! if storage_mode.to_sym == :s3
574
+ # NO validar db_* acá — depende de si se usa Engine o no
575
+ end
576
+
577
+ def validate_for_engine!
578
+ validate!
579
+ %i[db_host db_port db_user db_name].each do |attr|
580
+ val = send(attr)
581
+ raise ConfigurationError, "config.#{attr} es obligatorio" if val.nil? || val.to_s.empty?
582
+ end
583
+ end
584
+
585
+ private
586
+
587
+ def validate_storage_mode!
588
+ return if [:local, :s3].include?(storage_mode.to_sym)
589
+
590
+ raise ConfigurationError, "storage_mode debe ser :local o :s3, recibido #{storage_mode.inspect}"
591
+ end
592
+
593
+ def validate_aws_config!
594
+ raise ConfigurationError, "aws_region es obligatorio con storage_mode = :s3" if aws_region.nil?
595
+ # NO validar key_id / secret — el credential_chain (item 1) puede usar IAM rol
596
+ end
597
+ ```
598
+ 2. Llamar `validate_for_engine!` al inicio de `Engine#initialize`.
599
+ 3. Llamar `validate!` al inicio de `FileIngestor#initialize` (no requiere db_*).
600
+ 4. Llamar `validate!` al inicio de `GlueRunner.run_and_wait` (requiere `aws_region`).
601
+
602
+ ##### Archivos afectados
603
+
604
+ - `lib/data_drain/configuration.rb`
605
+ - `lib/data_drain/engine.rb`
606
+ - `lib/data_drain/file_ingestor.rb`
607
+ - `lib/data_drain/glue_runner.rb`
608
+ - `CHANGELOG.md`
609
+ - `spec/data_drain/configuration_spec.rb`
610
+
611
+ ##### Criterios de aceptación
612
+
613
+ - [ ] `Engine.new` con `storage_mode = :foo` levanta `ConfigurationError` claro.
614
+ - [ ] `Engine.new` con `storage_mode = :s3` y `aws_region = nil` levanta.
615
+ - [ ] `Engine.new` sin `db_host` levanta.
616
+ - [ ] `FileIngestor.new` con `storage_mode = :s3` y `aws_region = nil` levanta.
617
+ - [ ] `Configuration#validate!` puede llamarse manualmente desde el initializer del cliente.
618
+
619
+ ##### Riesgos
620
+
621
+ - **Backward-compat:** si algún caller actual tiene config medio rota (ej. `db_user=""`) y andaba "de casualidad", romperá. Documentar en CHANGELOG con nota explícita.
622
+
623
+ ---
624
+
625
+ #### Item 9 — Filtro de secretos por regex en Observability
626
+
627
+ **Estado:** `[ ]`
628
+ **Prioridad:** P1
629
+ **Tipo:** `security`
630
+ **Compatibilidad:** backward-compatible (filtra más, no menos)
631
+ **Estimación:** XS (30min)
632
+ **Release:** v0.2.1
633
+
634
+ ##### Contexto
635
+
636
+ `Observability#safe_log` filtra solo claves exactas:
637
+ ```ruby
638
+ %i[password token secret api_key auth].include?(k.to_sym)
639
+ ```
640
+ No filtra `db_password`, `aws_secret_access_key`, `bearer_token`, etc.
641
+
642
+ ##### Cambios
643
+
644
+ ```ruby
645
+ SENSITIVE_KEY_PATTERN = /password|passwd|pass|secret|token|api_key|apikey|auth|credential|private_key/i.freeze
646
+
647
+ # en safe_log:
648
+ val = SENSITIVE_KEY_PATTERN.match?(k.to_s) ? "[FILTERED]" : v
649
+ ```
650
+
651
+ ##### Archivos afectados
652
+
653
+ - `lib/data_drain/observability.rb`
654
+ - `spec/data_drain/observability_spec.rb`
655
+ - `CHANGELOG.md`
656
+
657
+ ##### Criterios de aceptación
658
+
659
+ - [ ] `safe_log(:info, "x", { db_password: "x" })` emite `db_password=[FILTERED]`.
660
+ - [ ] `safe_log(:info, "x", { aws_secret_access_key: "x" })` emite filtrado.
661
+ - [ ] `safe_log(:info, "x", { bearer_token: "x" })` emite filtrado.
662
+ - [ ] `safe_log(:info, "x", { user_id: 42 })` no se filtra (no match).
663
+ - [ ] Coordinado con global standards (`/Users/gabriel/.claude/CLAUDE.md` línea "Filter sensitive keys (password|pass|passwd|secret|token|api_key|auth) → [FILTERED]").
664
+
665
+ ##### Riesgos
666
+
667
+ - **Falsos positivos:** `authorization_id` matchearía con `auth`. ¿Aceptable? Sí, mejor false positive que leak.
668
+
669
+ ---
670
+
671
+ #### Item 11a — Documentación de Postgres tuning por tamaño de tabla
672
+
673
+ **Estado:** `[ ]`
674
+ **Prioridad:** P1
675
+ **Tipo:** `docs`
676
+ **Compatibilidad:** N/A
677
+ **Estimación:** M (4-6h)
678
+ **Release:** v0.2.1
679
+
680
+ ##### Contexto
681
+
682
+ DataDrain hoy no documenta cómo tunear Postgres para purgas masivas. Items recurrentes:
683
+ - ¿Qué índice ayuda al DELETE en lotes?
684
+ - ¿Cuándo migrar a particionamiento?
685
+ - ¿Cómo diagnosticar purgas lentas?
686
+
687
+ ##### Cambios
688
+
689
+ Crear `skill/references/postgres-tuning.md` y referenciarlo desde:
690
+ - `skill/SKILL.md` (sección "Referencias")
691
+ - `CLAUDE.md` (sección nueva "Postgres tuning")
692
+
693
+ Contenido:
694
+
695
+ 1. **Tabla de decisión por tamaño:**
696
+ | Tamaño | Estrategia |
697
+ |--------|-----------|
698
+ | <10GB | Índice composite `(created_at, pk)` con `CREATE INDEX CONCURRENTLY` |
699
+ | 10-100GB | Mismo + `SET maintenance_work_mem='4GB'` + checklist |
700
+ | 100GB-1TB | Particionamiento declarativo por mes |
701
+ | >1TB | Particionamiento obligatorio + `DROP PARTITION` reemplaza DELETE |
702
+
703
+ 2. **Checklist pre-`CREATE INDEX CONCURRENTLY`:**
704
+ - Tamaño actual: `SELECT pg_size_pretty(pg_total_relation_size('table'));`
705
+ - Espacio libre disco (>2x tabla)
706
+ - `SET maintenance_work_mem = '4GB'`
707
+ - `SET statement_timeout = 0`
708
+ - Ventana baja carga
709
+ - Plan rollback (DROP INDEX CONCURRENTLY si saturas I/O)
710
+
711
+ 3. **Riesgos `CONCURRENTLY`:**
712
+ - 2 pasadas (puede tardar horas en 500GB)
713
+ - I/O sostenido
714
+ - Puede fallar y dejar índice INVALID
715
+ - Espacio disco alto
716
+
717
+ 4. **VACUUM ANALYZE post-purga** (link a item 5).
718
+
719
+ 5. **Diagnóstico de purga lenta:**
720
+ ```sql
721
+ EXPLAIN (ANALYZE, BUFFERS) DELETE FROM versions WHERE id IN (...);
722
+ SELECT * FROM pg_stat_activity WHERE query LIKE '%versions%';
723
+ SELECT * FROM pg_stat_user_tables WHERE relname = 'versions';
724
+ ```
725
+
726
+ 6. **Migración a particionamiento:**
727
+ ```sql
728
+ CREATE TABLE versions (...) PARTITION BY RANGE (created_at);
729
+ CREATE TABLE versions_2026_03 PARTITION OF versions
730
+ FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
731
+ ```
732
+ Con esto cada `Engine#call` mensual puede reducirse a `DROP TABLE versions_2026_03` (instant). DataDrain podría agregar soporte nativo en futuro (item out-of-scope ahora).
733
+
734
+ ##### Archivos afectados
735
+
736
+ - `skill/references/postgres-tuning.md` (NUEVO)
737
+ - `skill/SKILL.md` (sección Referencias)
738
+ - `CLAUDE.md` (sección Postgres tuning)
739
+ - `CHANGELOG.md`
740
+
741
+ ##### Criterios de aceptación
742
+
743
+ - [ ] `postgres-tuning.md` cubre las 4 categorías de tamaño.
744
+ - [ ] Incluye SQL ejecutables y verificados (no pseudo-código).
745
+ - [ ] Incluye checklist pre-índice.
746
+ - [ ] Linkea con `engine.purge_heartbeat` y `engine.vacuum_complete`.
747
+ - [ ] Linkea con item 11b (warning runtime).
748
+
749
+ ##### Riesgos
750
+
751
+ - Ninguno (es docs).
752
+
753
+ ---
754
+
755
+ #### Item 6 — Sandboxing de `Record.connection`
756
+
757
+ **Estado:** `[ ]`
758
+ **Prioridad:** P1
759
+ **Tipo:** `security`
760
+ **Compatibilidad:** backward-compatible (con risk de breaking si caller hizo workarounds raros)
761
+ **Estimación:** M (3-4h)
762
+ **Release:** v0.3.0
763
+
764
+ ##### Contexto
765
+
766
+ `Record.connection` es read-only por diseño (consultas Parquet). DuckDB skill `query` sugiere sandboxing post-setup:
767
+ ```sql
768
+ SET enable_external_access=false -- pero S3 ya cargado vía httpfs queda activo
769
+ SET lock_configuration=true
770
+ ```
771
+ Reduce blast radius si alguien intenta inyectar SQL malicioso vía `where_clause` (improbable hoy pero defensa en profundidad).
772
+
773
+ ##### Cambios
774
+
775
+ 1. En `Record.connection`, después de `setup_duckdb`:
776
+ ```ruby
777
+ conn.query("SET lock_configuration=true;")
778
+ ```
779
+ 2. Probar que httpfs y secretos cargados previamente siguen funcionando.
780
+ 3. NO setear `enable_external_access=false` porque rompe S3 — verificar.
781
+ 4. NO setear `allowed_paths` porque la lista es dinámica (cada query distinta).
782
+
783
+ ##### Archivos afectados
784
+
785
+ - `lib/data_drain/record.rb`
786
+ - `spec/data_drain/record_spec.rb`
787
+ - `CHANGELOG.md`
788
+
789
+ ##### Criterios de aceptación
790
+
791
+ - [ ] `lock_configuration` activado tras setup.
792
+ - [ ] Test confirma que `Record.where(...)` sigue funcionando con S3.
793
+ - [ ] Test que intenta `SET memory_limit='1KB'` post-setup falla (locked).
794
+
795
+ ##### Riesgos
796
+
797
+ - **Compatibilidad:** algún caller exótico podría haber dependido de cambiar config en runtime sobre la conexión thread-local. Improbable.
798
+
799
+ ---
800
+
801
+ #### Item 10 — Refactor `Engine#call` (CC=13 → ~5)
802
+
803
+ **Estado:** `[ ]`
804
+ **Prioridad:** P1
805
+ **Tipo:** `refactor`
806
+ **Compatibilidad:** backward-compatible
807
+ **Estimación:** M (4-6h)
808
+ **Release:** v0.3.0
809
+
810
+ ##### Contexto
811
+
812
+ `Engine#call` tiene complejidad ciclomática 13 (alta). Hace todo: setup, count, export condicional, verify, purge, logging granular. Difícil de testear en aislamiento y de extender.
813
+
814
+ ##### Cambios
815
+
816
+ 1. Extraer en métodos privados:
817
+ ```ruby
818
+ def call
819
+ start_time = monotonic
820
+ log_start
821
+
822
+ setup_duckdb
823
+ return skip_empty if step_count.zero?
824
+ step_export unless @skip_export
825
+ return integrity_failed unless step_verify
826
+
827
+ step_purge
828
+ log_complete(start_time)
829
+ true
830
+ end
831
+
832
+ private
833
+
834
+ def step_count
835
+ timed(:db_query) { @pg_count = get_postgres_count }
836
+ @pg_count
837
+ end
838
+
839
+ def step_export
840
+ log(:info, "engine.export_start", count: @pg_count)
841
+ timed(:export) { export_to_parquet }
842
+ end
843
+
844
+ def step_verify
845
+ timed(:integrity) { verify_integrity }
846
+ end
847
+
848
+ def step_purge
849
+ timed(:purge) { purge_from_postgres }
850
+ end
851
+
852
+ def timed(name)
853
+ t = monotonic
854
+ yield
855
+ @durations[name] = monotonic - t
856
+ end
857
+ ```
858
+ 2. `@durations` hash acumula los timings, `log_complete` lo reporta.
859
+
860
+ ##### Archivos afectados
861
+
862
+ - `lib/data_drain/engine.rb`
863
+ - `spec/data_drain/engine_spec.rb` (agregar tests granulares por step)
864
+ - `CHANGELOG.md`
865
+
866
+ ##### Criterios de aceptación
867
+
868
+ - [ ] CC de `#call` ≤ 6 (medido con `rubocop --only Metrics/CyclomaticComplexity`).
869
+ - [ ] Eventos emitidos idénticos al actual (mismos campos, mismos valores).
870
+ - [ ] Tests existentes siguen pasando sin cambios.
871
+ - [ ] Tests nuevos cubren cada `step_*` en aislamiento.
872
+
873
+ ##### Riesgos
874
+
875
+ - **Cambio de orden de eventos:** verificar que `engine.start` sigue siendo el primer evento, `engine.complete` el último, etc.
876
+
877
+ ---
878
+
879
+ #### Item 11b — Warning runtime de purga lenta sin avance
880
+
881
+ **Estado:** `[ ]`
882
+ **Prioridad:** P1
883
+ **Tipo:** `feat` `perf`
884
+ **Compatibilidad:** backward-compatible
885
+ **Estimación:** M (3-4h)
886
+ **Release:** v0.3.0
887
+
888
+ ##### Contexto
889
+
890
+ Hoy `engine.purge_heartbeat` se emite cada 100 lotes, sin importar si los lotes son lentos o rápidos. Si un lote tarda 5 minutos (índice faltante, lock contention), no hay alerta hasta el lote 100 (que podría ser horas).
891
+
892
+ ##### Cambios
893
+
894
+ 1. Agregar a `Configuration`:
895
+ ```ruby
896
+ attr_accessor :slow_batch_threshold_s # default: 30
897
+ attr_accessor :slow_batch_alert_after # default: 5 (lotes lentos consecutivos antes de degraded)
898
+ ```
899
+ 2. En `Engine#purge_from_postgres`:
900
+ ```ruby
901
+ loop do
902
+ batch_start = monotonic
903
+ result = conn.exec(sql)
904
+ batch_duration = monotonic - batch_start
905
+
906
+ count = result.cmd_tuples
907
+ break if count.zero?
908
+
909
+ batches_processed += 1
910
+ total_deleted += count
911
+
912
+ if batch_duration > @config.slow_batch_threshold_s
913
+ slow_batch_streak += 1
914
+ safe_log(:warn, "engine.slow_batch", {
915
+ table: @table_name,
916
+ batch_duration_s: batch_duration.round(2),
917
+ batch_size: count,
918
+ streak: slow_batch_streak
919
+ })
920
+
921
+ if slow_batch_streak == @config.slow_batch_alert_after
922
+ safe_log(:warn, "engine.purge_degraded", {
923
+ table: @table_name,
924
+ consecutive_slow_batches: slow_batch_streak,
925
+ hint: "considerar índice composite o particionamiento (ver postgres-tuning.md)"
926
+ })
927
+ end
928
+ else
929
+ slow_batch_streak = 0
930
+ end
931
+
932
+ # ... heartbeat existente
933
+ sleep(@config.throttle_delay) if @config.throttle_delay.positive?
934
+ end
935
+ ```
936
+
937
+ ##### Archivos afectados
938
+
939
+ - `lib/data_drain/configuration.rb`
940
+ - `lib/data_drain/engine.rb`
941
+ - `skill/references/eventos-telemetria.md` (agregar `engine.slow_batch`, `engine.purge_degraded`)
942
+ - `CHANGELOG.md`
943
+ - `spec/data_drain/engine_spec.rb`
944
+
945
+ ##### Criterios de aceptación
946
+
947
+ - [ ] Lote > `slow_batch_threshold_s` emite `engine.slow_batch` WARN.
948
+ - [ ] N lotes consecutivos lentos emiten `engine.purge_degraded` una sola vez por streak.
949
+ - [ ] Streak se resetea si un lote es rápido.
950
+ - [ ] Defaults razonables (30s threshold, 5 streak).
951
+ - [ ] Configurable.
952
+
953
+ ##### Riesgos
954
+
955
+ - **Spam de warnings:** límite con `slow_batch_alert_after` evita esto.
956
+
957
+ ---
958
+
959
+ ### P2 — Calidad y DX (v0.3.1)
960
+
961
+ ---
962
+
963
+ #### Item 12 — YARD coverage 50% → 90%
964
+
965
+ **Estado:** `[ ]`
966
+ **Prioridad:** P2
967
+ **Tipo:** `docs`
968
+ **Compatibilidad:** N/A
969
+ **Estimación:** M (4-6h)
970
+
971
+ ##### Cambios
972
+
973
+ Documentar con YARD (`@param`, `@return`, `@raise`, `@example`):
974
+ - `Configuration` (todos atributos)
975
+ - `Observability` (3 métodos)
976
+ - `Storage::*` (todos métodos públicos)
977
+ - `Record.destroy_all`
978
+ - `Record.connection`
979
+ - `Record.disconnect!` (item 3)
980
+
981
+ ##### Criterios de aceptación
982
+
983
+ - [ ] `bundle exec yard stats --list-undoc` reporta 0 métodos públicos sin documentar.
984
+ - [ ] Cobertura ≥ 90%.
985
+
986
+ ---
987
+
988
+ #### Item 13 — Extraer `build_path_base` en Storage::Base
989
+
990
+ **Estado:** `[ ]`
991
+ **Prioridad:** P2
992
+ **Tipo:** `refactor`
993
+ **Compatibilidad:** backward-compatible
994
+ **Estimación:** XS (30min)
995
+
996
+ ##### Cambios
997
+
998
+ ```ruby
999
+ # en Base
1000
+ def build_path_base(bucket, folder_name, partition_path)
1001
+ base = File.join(bucket, folder_name)
1002
+ base = File.join(base, partition_path) if partition_path && !partition_path.empty?
1003
+ base
1004
+ end
1005
+
1006
+ # en Local
1007
+ def build_path(bucket, folder_name, partition_path)
1008
+ "#{build_path_base(bucket, folder_name, partition_path)}/**/*.parquet"
1009
+ end
1010
+
1011
+ # en S3
1012
+ def build_path(bucket, folder_name, partition_path)
1013
+ "s3://#{build_path_base(bucket, folder_name, partition_path)}/**/*.parquet"
1014
+ end
1015
+ ```
1016
+
1017
+ ##### Criterios de aceptación
1018
+
1019
+ - [ ] Tests existentes pasan sin cambios.
1020
+ - [ ] Cobertura agrega test directo de `build_path_base`.
1021
+
1022
+ ---
1023
+
1024
+ #### Item 14 — CI con GitHub Actions
1025
+
1026
+ **Estado:** `[ ]`
1027
+ **Prioridad:** P2
1028
+ **Tipo:** `chore`
1029
+ **Compatibilidad:** N/A
1030
+ **Estimación:** M (3-4h)
1031
+
1032
+ ##### Cambios
1033
+
1034
+ Crear `.github/workflows/ci.yml`:
1035
+ - Matrix Ruby 3.0, 3.2, 3.3
1036
+ - Service container Postgres 14 (para tests integration)
1037
+ - Steps: bundle install → rubocop → rspec
1038
+ - Cache de Bundler
1039
+ - Run en push y PR a `main`
1040
+
1041
+ ##### Criterios de aceptación
1042
+
1043
+ - [ ] CI verde en main.
1044
+ - [ ] PRs requieren CI verde para merge (configurar branch protection — manual).
1045
+ - [ ] Tiempo total < 5min.
1046
+
1047
+ ---
1048
+
1049
+ #### Item 15 — Docs DEBUG en bloque y tuning ejemplos
1050
+
1051
+ **Estado:** `[ ]`
1052
+ **Prioridad:** P2
1053
+ **Tipo:** `docs`
1054
+ **Compatibilidad:** N/A
1055
+ **Estimación:** S (2h)
1056
+
1057
+ ##### Cambios
1058
+
1059
+ En `CLAUDE.md` y `skill/SKILL.md`:
1060
+ - Recordar `logger.debug { "k=#{v}" }` para extensiones.
1061
+ - Tabla de tuning recomendado por tamaño de tabla:
1062
+ | Tabla | batch_size | throttle_delay |
1063
+ |-------|-----------|----------------|
1064
+ | <1M filas | 5000 | 0.1 |
1065
+ | 1M-100M | 5000 | 0.5 |
1066
+ | >100M | 10000 | 1.0 |
1067
+ - Contexto: tablas con tráfico OLTP concurrente → throttle alto. Tablas frías → throttle 0.
1068
+
1069
+ ---
1070
+
1071
+ #### Item 16 — Adoptar DuckDB Friendly SQL (cosmético)
1072
+
1073
+ **Estado:** `[ ]`
1074
+ **Prioridad:** P2
1075
+ **Tipo:** `refactor`
1076
+ **Compatibilidad:** backward-compatible
1077
+ **Estimación:** S (1-2h)
1078
+
1079
+ ##### Cambios
1080
+
1081
+ En queries internas:
1082
+ - `COUNT(*)` → `count()`
1083
+ - `SELECT * FROM table` → `FROM table` (cuando aplique)
1084
+
1085
+ Solo donde sea limpio. No forzar.
1086
+
1087
+ ##### Criterios de aceptación
1088
+
1089
+ - [ ] Tests existentes pasan.
1090
+ - [ ] No cambia comportamiento.
1091
+
1092
+ ---
1093
+
1094
+ ## Riesgos transversales
1095
+
1096
+ ### Coordinación con Gemini
1097
+
1098
+ Gemini trabaja en paralelo en este worktree. Antes de mergear cambios:
1099
+ - Verificar `source=` no se agregó manualmente en logs.
1100
+ - Verificar orden de campos: `{ component:, event: }.merge(metadata)`.
1101
+ - Verificar `include` vs `extend` de Observability.
1102
+ - Verificar `private_class_method` tras `extend`.
1103
+ - Fechas en CHANGELOG con la fecha real, no copiar la del item original.
1104
+
1105
+ ### Compatibilidad con consumidores actuales
1106
+
1107
+ La gema se usa en producción en Wispro (al menos `versions` y posiblemente otras tablas). Antes de mergear breaking changes:
1108
+ - Buscar todos los call sites con `rg "DataDrain::" --type ruby` en el monorepo consumidor.
1109
+ - Validar que los cambios de items 1, 2 no rompen casos en uso.
1110
+
1111
+ ### Versión de DuckDB
1112
+
1113
+ `CREATE SECRET` (item 1) y `lock_configuration` (item 6) requieren DuckDB ≥ 0.10. La gema requiere `~> 1.4`. OK.
1114
+
1115
+ ### Postgres version
1116
+
1117
+ `pg_stat_user_tables.n_dead_tup` (item 5) está disponible desde Postgres 8.x. OK.
1118
+ `MAINTAIN` privilege (item 5 nota de permisos) es Postgres 16+. Documentar fallback (owner de tabla o superuser).
1119
+
1120
+ ---
1121
+
1122
+ ## Checklist de release
1123
+
1124
+ Para cada release (v0.2.0, v0.2.1, etc.):
1125
+
1126
+ - [ ] Todos los items del release marcados `[x]`.
1127
+ - [ ] `bundle exec rspec` pasa.
1128
+ - [ ] `bundle exec rubocop` sin ofensas.
1129
+ - [ ] CHANGELOG actualizado con fecha actual y todos los items del release.
1130
+ - [ ] `lib/data_drain/version.rb` bumped.
1131
+ - [ ] `skill/SKILL.md` y `references/` actualizadas si aplica.
1132
+ - [ ] README actualizado si aplica.
1133
+ - [ ] Tag git `v0.X.Y`.
1134
+ - [ ] Si aplica, invocar skill `gem-release` para empaquetar y publicar.
1135
+ - [ ] Skill `data_drain` empaquetada en el `.gem` (ver skill-builder doc).
1136
+
1137
+ ---
1138
+
1139
+ ## Cómo usar este documento
1140
+
1141
+ ### Para Claude/Gemini
1142
+
1143
+ 1. Leer este archivo al inicio de cada sesión que toque DataDrain.
1144
+ 2. Filtrar por estado: items `[ ]` están disponibles; `[~]` los está trabajando alguien (verificar).
1145
+ 3. Antes de empezar un item, marcarlo `[~]` en este archivo (commit aparte).
1146
+ 4. Al terminar, marcar `[x]` y actualizar la sección "Última actualización".
1147
+ 5. Si surge bloqueo, marcar `[!]` y agregar nota explicando.
1148
+
1149
+ ### Para revisores humanos
1150
+
1151
+ - Cada item es autocontenido: contexto + cambios + archivos + criterios + riesgos.
1152
+ - "Notas para el revisor" señala puntos que requieren tu ojo.
1153
+ - Las "Estimaciones" son orientativas (XS <1h, S 1-3h, M 3-6h, L 6h+, XL días).
1154
+
1155
+ ### Para tracking en herramientas externas
1156
+
1157
+ Cada item puede mapearse 1:1 a:
1158
+ - ClickUp task (vía skill `clickup`)
1159
+ - GitHub issue (vía MCP `github`)
1160
+ - AI report (vía skill `ai-reports`)
1161
+
1162
+ Convención sugerida de título: `[DataDrain v0.X.Y] Item N — Resumen corto`.