data_drain 0.2.1 → 0.2.2

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,891 @@
1
+ # Plan de Ejecución — v0.2.2
2
+
3
+ **Release objetivo:** v0.2.2 — Robustez operacional (items P1 cheap)
4
+ **Items del roadmap:** 9, 7, 8, 11a ([ver IMPROVEMENT_PLAN.md](../IMPROVEMENT_PLAN.md#p1--performance-y-robustez-v021--v030))
5
+ **Branch sugerido:** `feature/v0.2.2`
6
+ **Base:** `main` (contiene v0.2.0 + v0.2.1)
7
+ **Estado:** No iniciado
8
+ **Última actualización:** 2026-04-14
9
+
10
+ ---
11
+
12
+ ## Contexto
13
+
14
+ v0.2.0 cerró los P0 (security + testing). v0.2.1 fue un patch CI + PR feedback. Este release mete los items P1 "baratos" que aportan valor operacional sin refactors grandes:
15
+
16
+ | Item | Resumen | Estimación |
17
+ |------|---------|------------|
18
+ | 9 | Filtro secretos por regex en Observability | 30min |
19
+ | 7 | `max_wait_seconds` en `GlueRunner.run_and_wait` | 1-2h |
20
+ | 8 | `Configuration#validate!` | 2-3h |
21
+ | 11a | Docs Postgres tuning por tamaño de tabla | 4-6h |
22
+ | cleanup A1 | Fix typo `依赖` en CHANGELOG v0.2.1 | 2min |
23
+ | cleanup A2 | Comment en `disconnect!` rescue | 2min |
24
+ | cleanup A3 | Rename + agregar test string/symbol keys en `record_spec.rb` | 10min |
25
+ | cleanup A4 | Cerrar `db`+`conn` en `record_spec.rb#before(:all)` | 5min |
26
+ | cleanup B1 | Reordenar `public`/`private` en `storage/s3.rb` | 15min |
27
+
28
+ **Total estimado:** 1-2 días de trabajo enfocado.
29
+
30
+ **Excluidos intencionalmente** (se mueven a v0.3.0 por ser refactor/más costosos):
31
+ - Item 5 (VACUUM post-purga) — toca purge path crítico, requiere tests con Postgres real
32
+ - Item 6 (sandboxing Record) — requiere validación con S3 real
33
+ - Item 10 (refactor Engine#call) — refactor mayor
34
+ - Item 11b (warning runtime purga lenta) — requiere mocks elaborados
35
+
36
+ ---
37
+
38
+ ## Review de agentes — incorporado
39
+
40
+ Revisión por **big-pickle (opencode)** 2026-04-14 ([ClickUp task 86b9dka0c](https://app.clickup.com/t/86b9dka0c)). 4 riesgos planteados, todos incorporados:
41
+
42
+ | Riesgo | Resolución | Ubicación en este plan |
43
+ |--------|-----------|----------------------|
44
+ | 1: `db_port` con default 5432 — validación pasa siempre | Excluido de `validate_db_config!` con nota explicativa | Fase 3.1 |
45
+ | 2: `db_pass` no se valida sin justificar | Excluido con nota: auth peer/trust/IAM pueden tener nil | Fase 3.1 + test nuevo 3.2 |
46
+ | 3: Mock `Process.clock_gettime` puede ser flakey | Plan B ya contempla Timecop como fallback | Sin cambio |
47
+ | 4: Acceso al monorepo Wispro no alcanzable desde gema | Marcado como step manual explícito | Fase 3.3 |
48
+
49
+ ---
50
+
51
+ ## Orden de ejecución y dependencias
52
+
53
+ ```
54
+ Fase 0: setup branch + baseline + A1 (fix CHANGELOG v0.2.1)
55
+
56
+
57
+ Fase 1: Item 9 (filtro secretos regex) ──► isolated, warm-up
58
+
59
+
60
+ Fase 2: Item 7 (max_wait_seconds GlueRunner) ──► isolated
61
+
62
+
63
+ Fase 3: Item 8 (Configuration#validate!) ──► toca Engine/FileIngestor/GlueRunner
64
+ │ (depende de item 1 de v0.2.0 ya mergeado)
65
+
66
+ Fase 4: Item 11a (docs Postgres tuning) ──► pure docs, sin deps
67
+
68
+
69
+ Fase 4.5: Cleanup del review de v0.2.0 (A2, A3, A4, B1)
70
+
71
+
72
+ Fase 5: Release (CHANGELOG, version bump, tag)
73
+ ```
74
+
75
+ **Razonamiento:**
76
+ - 9 primero: XS, isolated a Observability, warm-up.
77
+ - 7 segundo: isolated a GlueRunner, patrón simple.
78
+ - 8 tercero: toca 3 clases, establece el patrón de `raise ConfigurationError`. Depende de item 1 (v0.2.0) — la validación NO debe exigir `aws_access_key_id` porque ahora es opcional con `credential_chain`.
79
+ - 11a último: docs puras, puede generarse con la skill `postgresql-optimization` como apoyo.
80
+
81
+ ---
82
+
83
+ ## Pre-requisitos (Fase 0)
84
+
85
+ ### 0.1 Verificar entorno
86
+
87
+ - [ ] `git checkout main && git pull`
88
+ - [ ] Confirmar versión actual en `lib/data_drain/version.rb` = `"0.2.1"`
89
+ - [ ] `bundle exec rspec` pasa en main (112 specs, coverage ≥ 80%)
90
+ - [ ] `bundle exec rubocop` sin ofensas en `lib/` (specs excluidas por `.rubocop.yml`)
91
+
92
+ ### 0.2 Crear branch
93
+
94
+ - [ ] `git checkout -b feature/v0.2.2`
95
+
96
+ ### 0.3 Revisar skill `postgresql-optimization`
97
+
98
+ Para item 11a. Ubicada en `.agents/skills/postgresql-optimization/SKILL.md`. Tiene material sobre:
99
+ - Índices composite/parcial/expression/covering
100
+ - EXPLAIN ANALYZE
101
+ - pg_stat_statements, pg_stat_activity
102
+ - Particionamiento declarativo
103
+ - VACUUM
104
+
105
+ - [ ] Abrir skill para consulta durante Fase 4
106
+
107
+ ### 0.4 Cleanup A1 — Fix typo `依赖` en CHANGELOG v0.2.1
108
+
109
+ - [ ] Editar `CHANGELOG.md`, encontrar la línea:
110
+ ```
111
+ CI: Descarga binario pre-compilado de DuckDB en vez de依赖 del sistema (`libduckdb-dev`).
112
+ ```
113
+ Reemplazar `en vez de依赖 del sistema` por `en vez de depender del sistema`.
114
+ - [ ] Commit: `fix(changelog): typo en CHANGELOG v0.2.1 (cleanup A1)`
115
+
116
+ ### Checkpoint Fase 0
117
+
118
+ - [ ] Branch creado
119
+ - [ ] Baseline tests verdes
120
+ - [ ] Entorno limpio
121
+ - [ ] CHANGELOG v0.2.1 corregido
122
+
123
+ ---
124
+
125
+ ## Fase 1 — Item 9: Filtro secretos por regex (P1)
126
+
127
+ **Roadmap:** [Item 9](../IMPROVEMENT_PLAN.md#item-9--filtro-de-secretos-por-regex-en-observability)
128
+
129
+ ### Contexto
130
+
131
+ `Observability#safe_log` filtra solo claves exactas (`%i[password token secret api_key auth]`). No filtra variantes como `db_password`, `aws_secret_access_key`, `bearer_token`. Cambio trivial pero aumenta defensa en profundidad significativamente.
132
+
133
+ ### 1.1 Implementación
134
+
135
+ - [ ] Editar `lib/data_drain/observability.rb`:
136
+ - Agregar constante al módulo (antes de `module_function` equivalente):
137
+ ```ruby
138
+ SENSITIVE_KEY_PATTERN = /password|passwd|pass|secret|token|api_key|apikey|auth|credential|private_key/i
139
+ ```
140
+ - En `safe_log`, reemplazar:
141
+ ```ruby
142
+ val = %i[password token secret api_key auth].include?(k.to_sym) ? "[FILTERED]" : v
143
+ ```
144
+ por:
145
+ ```ruby
146
+ val = SENSITIVE_KEY_PATTERN.match?(k.to_s) ? "[FILTERED]" : v
147
+ ```
148
+
149
+ ### 1.2 Tests
150
+
151
+ - [ ] Editar `spec/data_drain/observability_spec.rb`. Agregar al bloque `describe "#safe_log"`:
152
+ ```ruby
153
+ it "filtra db_password (regex)" do
154
+ instance.emit(:info, "test", db_password: "x")
155
+ expect(test_logger.string).to include("db_password=[FILTERED]")
156
+ expect(test_logger.string).not_to include("db_password=x")
157
+ end
158
+
159
+ it "filtra aws_secret_access_key (regex)" do
160
+ instance.emit(:info, "test", aws_secret_access_key: "akia123")
161
+ expect(test_logger.string).to include("aws_secret_access_key=[FILTERED]")
162
+ end
163
+
164
+ it "filtra bearer_token (regex)" do
165
+ instance.emit(:info, "test", bearer_token: "eyJhbGc...")
166
+ expect(test_logger.string).to include("bearer_token=[FILTERED]")
167
+ end
168
+
169
+ it "filtra private_key (regex)" do
170
+ instance.emit(:info, "test", private_key: "-----BEGIN RSA")
171
+ expect(test_logger.string).to include("private_key=[FILTERED]")
172
+ end
173
+
174
+ it "filtra credential" do
175
+ instance.emit(:info, "test", aws_credential: "x")
176
+ expect(test_logger.string).to include("aws_credential=[FILTERED]")
177
+ end
178
+
179
+ it "no filtra campos no sensibles" do
180
+ instance.emit(:info, "test", count: 42, table: "versions", user_id: 5)
181
+ expect(test_logger.string).to include("count=42")
182
+ expect(test_logger.string).to include("user_id=5")
183
+ end
184
+ ```
185
+
186
+ ### 1.3 Validación local
187
+
188
+ - [ ] `bundle exec rspec spec/data_drain/observability_spec.rb`
189
+ - [ ] `bundle exec rubocop lib/data_drain/observability.rb`
190
+
191
+ ### 1.4 Docs
192
+
193
+ - [ ] Actualizar `CLAUDE.md` sección "Logging" — mencionar regex filter
194
+ - [ ] Actualizar `skill/references/api-detallada.md` sección Observability#safe_log
195
+ - [ ] Alinear con `/Users/gabriel/.claude/CLAUDE.md` línea `Filter sensitive keys (password|pass|passwd|secret|token|api_key|auth) → [FILTERED]` — el regex de v0.2.2 es **superset** (agrega `apikey`, `credential`, `private_key`), así que cumple con el estándar global.
196
+
197
+ ### 1.5 Commit
198
+
199
+ - [ ] `git add lib/data_drain/observability.rb spec/data_drain/observability_spec.rb`
200
+ - [ ] `git add CLAUDE.md skill/`
201
+ - [ ] Commit: `feat(security): filtro secretos por regex en Observability (item 9)`
202
+
203
+ ### Checkpoint Fase 1
204
+
205
+ - [ ] Tests verdes
206
+ - [ ] Rubocop limpio
207
+ - [ ] Coverage no bajó
208
+
209
+ ---
210
+
211
+ ## Fase 2 — Item 7: `max_wait_seconds` en GlueRunner (P1)
212
+
213
+ **Roadmap:** [Item 7](../IMPROVEMENT_PLAN.md#item-7--max_wait_seconds-en-gluerunnerrun_and_wait)
214
+
215
+ ### Contexto
216
+
217
+ `GlueRunner.run_and_wait` no tiene timeout máximo. Si Glue queda colgado en `RUNNING`, bloquea indefinidamente.
218
+
219
+ ### 2.1 Implementación
220
+
221
+ - [ ] Editar `lib/data_drain/glue_runner.rb`:
222
+ - Agregar parámetro `max_wait_seconds:` con default `nil`:
223
+ ```ruby
224
+ def self.run_and_wait(job_name, arguments = {}, polling_interval: 30, max_wait_seconds: nil)
225
+ ```
226
+ - En el loop, antes del `get_job_run`, agregar guard:
227
+ ```ruby
228
+ loop do
229
+ if max_wait_seconds &&
230
+ (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) > max_wait_seconds
231
+ safe_log(:error, "glue_runner.timeout", {
232
+ job: job_name,
233
+ run_id: run_id,
234
+ max_wait_seconds: max_wait_seconds
235
+ })
236
+ raise DataDrain::Error,
237
+ "Glue Job #{job_name} (Run ID: #{run_id}) excedió max_wait_seconds=#{max_wait_seconds}"
238
+ end
239
+
240
+ run_info = client.get_job_run(...).job_run
241
+ # ... resto del case actual
242
+ end
243
+ ```
244
+ - Actualizar YARD:
245
+ ```ruby
246
+ # @param max_wait_seconds [Integer, nil] Timeout máximo en segundos.
247
+ # nil = sin límite (comportamiento anterior).
248
+ # @raise [DataDrain::Error] si max_wait_seconds excede antes de SUCCEEDED
249
+ ```
250
+
251
+ ### 2.2 Tests
252
+
253
+ - [ ] Editar `spec/data_drain/glue_runner_spec.rb`. Agregar:
254
+ ```ruby
255
+ it "levanta DataDrain::Error cuando max_wait_seconds se excede" do
256
+ start_response = double("start_resp", job_run_id: "run-timeout")
257
+ running_info = double("run_info", job_run_state: "RUNNING", error_message: nil)
258
+
259
+ allow(mock_client).to receive(:start_job_run).and_return(start_response)
260
+ allow(mock_client).to receive(:get_job_run)
261
+ .and_return(double("get_resp", job_run: running_info))
262
+
263
+ # Simular tiempo: start_time es t0, primer check pasa, segundo check excede
264
+ times = [0.0, 0.1, 200.0]
265
+ allow(Process).to receive(:clock_gettime).with(Process::CLOCK_MONOTONIC) do
266
+ times.shift || 300.0
267
+ end
268
+ allow(Kernel).to receive(:sleep)
269
+
270
+ expect do
271
+ described_class.run_and_wait("slow-job", {}, polling_interval: 1, max_wait_seconds: 60)
272
+ end.to raise_error(DataDrain::Error, /max_wait_seconds=60/)
273
+ end
274
+
275
+ it "sin max_wait_seconds mantiene comportamiento anterior (no timeout local)" do
276
+ start_response = double("start_resp", job_run_id: "run-ok")
277
+ succeeded_info = double("run_info", job_run_state: "SUCCEEDED", error_message: nil)
278
+
279
+ allow(mock_client).to receive(:start_job_run).and_return(start_response)
280
+ allow(mock_client).to receive(:get_job_run)
281
+ .and_return(double("get_resp", job_run: succeeded_info))
282
+
283
+ # max_wait_seconds nil por default — no se chequea
284
+ expect { described_class.run_and_wait("ok-job") }.not_to raise_error
285
+ end
286
+ ```
287
+
288
+ ### 2.3 Validación local
289
+
290
+ - [ ] `bundle exec rspec spec/data_drain/glue_runner_spec.rb`
291
+ - [ ] `bundle exec rubocop lib/data_drain/glue_runner.rb`
292
+
293
+ ### 2.4 Docs
294
+
295
+ - [ ] `skill/references/api-detallada.md` sección GlueRunner — agregar `max_wait_seconds`
296
+ - [ ] `skill/references/eventos-telemetria.md` — agregar evento `glue_runner.timeout`
297
+ - [ ] `skill/references/antipatrones.md` — actualizar antipatrón 14 ("Confiar en que GlueRunner tiene timeout máximo") para mencionar que ahora sí se puede con `max_wait_seconds:`
298
+
299
+ ### 2.5 Commit
300
+
301
+ - [ ] Commit: `feat(glue): max_wait_seconds en GlueRunner.run_and_wait (item 7)`
302
+
303
+ ### Checkpoint Fase 2
304
+
305
+ - [ ] Tests verdes
306
+ - [ ] Antipatrón 14 actualizado
307
+
308
+ ---
309
+
310
+ ## Fase 3 — Item 8: `Configuration#validate!` (P1)
311
+
312
+ **Roadmap:** [Item 8](../IMPROVEMENT_PLAN.md#item-8--configurationvalidate)
313
+
314
+ ### Contexto
315
+
316
+ `Configuration` no valida invariantes. Errores típicos (storage_mode inválido, aws_region faltante con `:s3`, db_* faltantes en Engine) se manifiestan tarde con errores oscuros (`NoMethodError`, `Aws::Errors`, `PG::ConnectionBad`).
317
+
318
+ **Importante:** item 1 de v0.2.0 hizo `aws_access_key_id`/`aws_secret_access_key` **opcionales** (credential_chain). `validate!` NO debe exigirlos, solo `aws_region`.
319
+
320
+ ### 3.1 Implementación
321
+
322
+ - [ ] Editar `lib/data_drain/configuration.rb`:
323
+ ```ruby
324
+ # Valida invariantes generales (storage_mode + AWS si aplica).
325
+ # Llamado por FileIngestor#initialize y GlueRunner.run_and_wait.
326
+ #
327
+ # @raise [DataDrain::ConfigurationError]
328
+ def validate!
329
+ validate_storage_mode!
330
+ validate_aws_config! if storage_mode.to_sym == :s3
331
+ end
332
+
333
+ # Valida además las credenciales PostgreSQL.
334
+ # Llamado por Engine#initialize.
335
+ #
336
+ # @raise [DataDrain::ConfigurationError]
337
+ def validate_for_engine!
338
+ validate!
339
+ validate_db_config!
340
+ end
341
+
342
+ private
343
+
344
+ def validate_storage_mode!
345
+ return if %i[local s3].include?(storage_mode.to_sym)
346
+
347
+ raise DataDrain::ConfigurationError,
348
+ "storage_mode debe ser :local o :s3, recibido #{storage_mode.inspect}"
349
+ end
350
+
351
+ def validate_aws_config!
352
+ return unless aws_region.nil? || aws_region.to_s.empty?
353
+
354
+ raise DataDrain::ConfigurationError,
355
+ "aws_region es obligatorio con storage_mode = :s3"
356
+ end
357
+
358
+ # NOTA (review big-pickle riesgo 1): db_port se excluye porque tiene
359
+ # default 5432 — la validación siempre pasaría, sería código muerto.
360
+ # Si alguien lo setea a nil intencionalmente, Postgres rompe con error
361
+ # descriptivo igual.
362
+ #
363
+ # NOTA (review big-pickle riesgo 2): db_pass se excluye porque puede
364
+ # ser nil cuando Postgres usa auth peer/trust (sockets locales) o IAM
365
+ # (RDS IAM authentication). Requerirlo rompería esos casos válidos.
366
+ def validate_db_config!
367
+ %i[db_host db_user db_name].each do |attr|
368
+ val = public_send(attr)
369
+ next unless val.nil? || val.to_s.empty?
370
+
371
+ raise DataDrain::ConfigurationError,
372
+ "config.#{attr} es obligatorio para Engine (storage_mode=#{storage_mode})"
373
+ end
374
+ end
375
+ ```
376
+
377
+ - [ ] Editar `lib/data_drain/engine.rb#initialize`. Después de capturar `@config`:
378
+ ```ruby
379
+ @config = DataDrain.configuration
380
+ @config.validate_for_engine!
381
+ @logger = @config.logger
382
+ @adapter = DataDrain::Storage.adapter
383
+ ```
384
+
385
+ - [ ] Editar `lib/data_drain/file_ingestor.rb#initialize`. Después de capturar `@config`:
386
+ ```ruby
387
+ @config = DataDrain.configuration
388
+ @config.validate!
389
+ @logger = @config.logger
390
+ @adapter = DataDrain::Storage.adapter
391
+ ```
392
+
393
+ - [ ] Editar `lib/data_drain/glue_runner.rb.run_and_wait`. Al principio:
394
+ ```ruby
395
+ def self.run_and_wait(job_name, arguments = {}, polling_interval: 30, max_wait_seconds: nil)
396
+ config = DataDrain.configuration
397
+ config.validate!
398
+ # ... resto
399
+ end
400
+ ```
401
+
402
+ ### 3.2 Tests
403
+
404
+ - [ ] Crear `spec/data_drain/configuration_validate_spec.rb` (o agregar a `configuration_spec.rb`):
405
+ ```ruby
406
+ RSpec.describe DataDrain::Configuration do
407
+ describe "#validate!" do
408
+ it "no levanta con storage_mode :local" do
409
+ config = described_class.new
410
+ config.storage_mode = :local
411
+ expect { config.validate! }.not_to raise_error
412
+ end
413
+
414
+ it "levanta con storage_mode :foo" do
415
+ config = described_class.new
416
+ config.storage_mode = :foo
417
+ expect { config.validate! }.to raise_error(DataDrain::ConfigurationError, /storage_mode/)
418
+ end
419
+
420
+ it "levanta con storage_mode :s3 sin aws_region" do
421
+ config = described_class.new
422
+ config.storage_mode = :s3
423
+ config.aws_region = nil
424
+ expect { config.validate! }.to raise_error(DataDrain::ConfigurationError, /aws_region/)
425
+ end
426
+
427
+ it "no levanta con storage_mode :s3 + aws_region, sin credenciales (credential_chain)" do
428
+ config = described_class.new
429
+ config.storage_mode = :s3
430
+ config.aws_region = "us-east-1"
431
+ expect { config.validate! }.not_to raise_error
432
+ end
433
+ end
434
+
435
+ describe "#validate_for_engine!" do
436
+ it "levanta sin db_host" do
437
+ config = described_class.new
438
+ config.db_host = nil
439
+ config.db_user = "u"
440
+ config.db_name = "d"
441
+ expect { config.validate_for_engine! }.to raise_error(DataDrain::ConfigurationError, /db_host/)
442
+ end
443
+
444
+ it "levanta sin db_name" do
445
+ config = described_class.new
446
+ config.db_user = "u"
447
+ config.db_name = nil
448
+ expect { config.validate_for_engine! }.to raise_error(DataDrain::ConfigurationError, /db_name/)
449
+ end
450
+
451
+ it "no levanta con todos los campos requeridos seteados" do
452
+ config = described_class.new
453
+ config.db_user = "u"
454
+ config.db_name = "d"
455
+ # db_pass intencionalmente nil — válido con auth peer/trust/IAM
456
+ expect { config.validate_for_engine! }.not_to raise_error
457
+ end
458
+
459
+ it "no levanta con db_pass nil (auth peer/trust/IAM)" do
460
+ config = described_class.new
461
+ config.db_user = "u"
462
+ config.db_pass = nil
463
+ config.db_name = "d"
464
+ expect { config.validate_for_engine! }.not_to raise_error
465
+ end
466
+ end
467
+ end
468
+ ```
469
+
470
+ - [ ] Agregar a `engine_spec.rb` bloque `describe "validación de configuración"`:
471
+ ```ruby
472
+ it "levanta ConfigurationError si db_name falta" do
473
+ DataDrain.configure { |c| c.db_user = "u"; c.db_name = nil }
474
+ expect do
475
+ described_class.new(base_options.merge(table_name: "versions"))
476
+ end.to raise_error(DataDrain::ConfigurationError, /db_name/)
477
+ ensure
478
+ DataDrain.reset_configuration!
479
+ end
480
+ ```
481
+
482
+ - [ ] Tests similares en `file_ingestor_spec.rb` y `glue_runner_spec.rb`.
483
+
484
+ ### 3.3 Impacto backward-compat
485
+
486
+ - [ ] **Riesgo:** si algún caller actual tiene config con `db_user=""` o `db_name=nil` (estaba roto pero no se ejecutaba el path), ahora romperá en `Engine.new`.
487
+ - [ ] **⚠️ Step manual (review big-pickle riesgo 4):** agentes en esta gema aislada NO tienen acceso al monorepo Wispro. Ejecutar manualmente desde el monorepo:
488
+ ```bash
489
+ cd ~/src/wispro-monorepo # o donde esté
490
+ rg "DataDrain.configure" -A 20 --type ruby
491
+ rg "DataDrain::Engine.new" -A 10 --type ruby
492
+ ```
493
+ Verificar que todos los campos requeridos (db_host, db_user, db_name; aws_region si :s3) están seteados. Documentar callers encontrados:
494
+ > Callers con config incompleta: ___________________
495
+ - [ ] Documentar en CHANGELOG como "BREAKING preventivo" (similar al item 2 de v0.2.0).
496
+
497
+ ### 3.4 Validación local
498
+
499
+ - [ ] `bundle exec rspec`
500
+ - [ ] `bundle exec rubocop lib/`
501
+
502
+ ### 3.5 Docs
503
+
504
+ - [ ] `skill/references/api-detallada.md` sección Configuration — agregar `#validate!` y `#validate_for_engine!`
505
+ - [ ] `CLAUDE.md` sección "Configuración" con nota sobre validación automática
506
+ - [ ] `skill/references/antipatrones.md` — agregar nuevo antipatrón: "No llamar `Engine.new` con `db_name` faltante esperando que 'use el default' — ahora falla rápido con error descriptivo".
507
+
508
+ ### 3.6 Commit
509
+
510
+ - [ ] Commit: `feat(config): Configuration#validate! invocada en Engine/FileIngestor/GlueRunner (item 8)`
511
+
512
+ ### Checkpoint Fase 3
513
+
514
+ - [ ] Tests verdes (incluyendo engine_spec / file_ingestor_spec actualizados)
515
+ - [ ] Coverage no bajó
516
+ - [ ] Documentado como BREAKING preventivo en CHANGELOG (borrador)
517
+
518
+ ---
519
+
520
+ ## Fase 4 — Item 11a: Docs Postgres tuning por tamaño de tabla (P1)
521
+
522
+ **Roadmap:** [Item 11a](../IMPROVEMENT_PLAN.md#item-11a--documentación-de-postgres-tuning-por-tamaño-de-tabla)
523
+
524
+ ### Contexto
525
+
526
+ DataDrain no documenta tuning de Postgres para purgas masivas. Items recurrentes: ¿qué índice ayuda al DELETE en lotes? ¿cuándo migrar a particionamiento? ¿cómo diagnosticar purgas lentas?
527
+
528
+ La skill `postgresql-optimization` (en `.agents/skills/`) aporta material base sobre índices, EXPLAIN ANALYZE, `pg_stat_statements`, particionamiento.
529
+
530
+ ### 4.1 Crear `skill/references/postgres-tuning.md`
531
+
532
+ Estructura sugerida:
533
+
534
+ ```markdown
535
+ # Postgres Tuning para DataDrain
536
+
537
+ Guía operacional para tablas que DataDrain archiva y purga. Cubre índices,
538
+ VACUUM, particionamiento y diagnóstico.
539
+
540
+ ## Tabla de decisión por tamaño
541
+
542
+ | Tamaño | Estrategia |
543
+ |--------|-----------|
544
+ | <10GB | Índice composite `(created_at, pk)` con `CREATE INDEX CONCURRENTLY` |
545
+ | 10-100GB | Mismo + `SET maintenance_work_mem='4GB'` + checklist |
546
+ | 100GB-1TB | Particionamiento declarativo por mes |
547
+ | >1TB | Particionamiento obligatorio + `DROP PARTITION` reemplaza DELETE |
548
+
549
+ ## Índice recomendado
550
+
551
+ Para tablas <100GB, DataDrain se beneficia de un índice composite:
552
+
553
+ CREATE INDEX CONCURRENTLY idx_versions_created_at_id
554
+ ON versions (created_at, id);
555
+
556
+ El DELETE en batches usa `WHERE created_at >= X AND created_at < Y` + `IN (SELECT id LIMIT N)`.
557
+ El índice composite lo convierte en index scan por rango + acceso directo al id.
558
+
559
+ ### Checklist pre-`CREATE INDEX CONCURRENTLY`
560
+
561
+ - [ ] Tamaño actual: `SELECT pg_size_pretty(pg_total_relation_size('versions'));`
562
+ - [ ] Espacio libre disco (>2x tabla)
563
+ - [ ] `SET maintenance_work_mem = '4GB';` (sesión)
564
+ - [ ] `SET statement_timeout = 0;`
565
+ - [ ] Ventana de baja carga
566
+ - [ ] Plan rollback: `DROP INDEX CONCURRENTLY` si satura I/O
567
+
568
+ ### Riesgos de `CONCURRENTLY`
569
+
570
+ 1. **Dos pasadas** (puede tardar horas en 500GB)
571
+ 2. **I/O sostenido** (satura IOPS en EBS gp3 sin provisioned)
572
+ 3. **Puede fallar y dejar índice INVALID** → recuperar con `DROP INDEX CONCURRENTLY idx; CREATE INDEX CONCURRENTLY idx ...`
573
+ 4. **Espacio en disco alto** durante build (sort externo si `maintenance_work_mem` bajo)
574
+
575
+ ## VACUUM ANALYZE post-purga
576
+
577
+ En tablas no particionadas, purgar millones de rows deja dead tuples.
578
+ Sin VACUUM, el espacio no se libera y los seq scan recorren páginas vacías.
579
+
580
+ VACUUM ANALYZE versions;
581
+
582
+ Item 5 del roadmap agrega `config.vacuum_after_purge` para automatizar esto.
583
+ Hasta v0.3.0, correr manualmente después de cada `Engine#call` en tablas
584
+ grandes no particionadas.
585
+
586
+ **NO usar `VACUUM FULL`** — bloquea la tabla entera (ACCESS EXCLUSIVE lock).
587
+
588
+ ## Diagnóstico de purga lenta
589
+
590
+ -- Plan del DELETE en lotes
591
+ EXPLAIN (ANALYZE, BUFFERS)
592
+ DELETE FROM versions
593
+ WHERE id IN (
594
+ SELECT id FROM versions
595
+ WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01'
596
+ LIMIT 5000
597
+ );
598
+
599
+ -- Sesiones activas sobre la tabla
600
+ SELECT pid, state, wait_event, query_start, query
601
+ FROM pg_stat_activity
602
+ WHERE query LIKE '%versions%'
603
+ AND state != 'idle';
604
+
605
+ -- Estadísticas de la tabla
606
+ SELECT relname, n_live_tup, n_dead_tup, last_vacuum, last_autovacuum
607
+ FROM pg_stat_user_tables
608
+ WHERE relname = 'versions';
609
+
610
+ -- Top queries lentas (requiere pg_stat_statements)
611
+ SELECT substring(query, 1, 100) AS query, calls, mean_exec_time, rows
612
+ FROM pg_stat_statements
613
+ WHERE query LIKE '%versions%'
614
+ ORDER BY mean_exec_time DESC
615
+ LIMIT 10;
616
+
617
+ ## Particionamiento declarativo (tablas > 100GB)
618
+
619
+ Migrar a tabla particionada cambia DataDrain de "DELETE masivo throttled" a
620
+ "DROP PARTITION instantáneo".
621
+
622
+ ### Setup
623
+
624
+ -- 1. Crear tabla particionada (vacía, misma estructura que versions)
625
+ CREATE TABLE versions_new (
626
+ id UUID PRIMARY KEY,
627
+ created_at TIMESTAMP NOT NULL,
628
+ ... -- resto de columnas
629
+ ) PARTITION BY RANGE (created_at);
630
+
631
+ -- 2. Crear partición por mes
632
+ CREATE TABLE versions_2026_03 PARTITION OF versions_new
633
+ FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
634
+
635
+ -- 3. Migrar datos (lotes, una partición por vez)
636
+ INSERT INTO versions_2026_03
637
+ SELECT * FROM versions
638
+ WHERE created_at >= '2026-03-01' AND created_at < '2026-04-01';
639
+
640
+ -- 4. Swap nombres (downtime mínimo)
641
+ BEGIN;
642
+ ALTER TABLE versions RENAME TO versions_old;
643
+ ALTER TABLE versions_new RENAME TO versions;
644
+ COMMIT;
645
+
646
+ ### Beneficio para DataDrain
647
+
648
+ -- v0.2.x: DELETE en lotes, VACUUM después, horas en TB
649
+ DataDrain::Engine.new(...).call
650
+
651
+ -- Con particiones: DataDrain sigue funcionando pero si el rango
652
+ -- coincide con una partición, el operador puede hacer:
653
+ DROP TABLE versions_2026_03; -- instantáneo, sin bloat
654
+
655
+ DataDrain no detecta particiones automáticamente (futuro item). Hoy el
656
+ operador decide.
657
+
658
+ ## Referencias
659
+
660
+ - Skill: `.agents/skills/postgresql-optimization/SKILL.md`
661
+ - PG docs: https://www.postgresql.org/docs/current/ddl-partitioning.html
662
+ - Item 5 roadmap (VACUUM automático): ../IMPROVEMENT_PLAN.md#item-5
663
+ - Item 11b roadmap (warning runtime): ../IMPROVEMENT_PLAN.md#item-11b
664
+ ```
665
+
666
+ ### 4.2 Cross-references
667
+
668
+ - [ ] Agregar link en `skill/SKILL.md` sección "Referencias":
669
+ ```markdown
670
+ - [Postgres Tuning](references/postgres-tuning.md) — Índices, VACUUM, particionamiento y diagnóstico
671
+ ```
672
+ - [ ] Agregar sección "Postgres tuning" en `CLAUDE.md` con link al doc completo (dejar resumen de 5-10 líneas en CLAUDE.md y link al doc extenso en `skill/references/`).
673
+ - [ ] Agregar en `README.md` sección "Performance" (o "Tuning") breve nota apuntando a la skill para detalles.
674
+
675
+ ### 4.3 Validación
676
+
677
+ - [ ] `bundle exec rspec` (no toca código, debería pasar igual)
678
+ - [ ] Verificar links relativos en markdown (abrir local en editor/GitHub preview)
679
+
680
+ ### 4.4 Commit
681
+
682
+ - [ ] Commit: `docs: postgres tuning por tamaño de tabla (item 11a)`
683
+
684
+ ### Checkpoint Fase 4
685
+
686
+ - [ ] `postgres-tuning.md` creado y linkeado
687
+ - [ ] CLAUDE.md y README actualizados
688
+ - [ ] SKILL.md sección Referencias actualizada
689
+
690
+ ---
691
+
692
+ ## Fase 4.5 — Cleanup del review de v0.2.0 PR (A2, A3, A4, B1)
693
+
694
+ **Contexto:** items detectados durante el review de v0.2.0 (PR #6) que quedaron abiertos. Cheap, limpian deuda mientras el contexto está fresco.
695
+
696
+ ### 4.5.1 A2 — Comment en `disconnect!` rescue
697
+
698
+ - [ ] Editar `lib/data_drain/record.rb`. Encontrar:
699
+ ```ruby
700
+ rescue StandardError # rubocop:disable Lint/SuppressedException
701
+ end
702
+ ```
703
+ Reemplazar por:
704
+ ```ruby
705
+ rescue StandardError
706
+ # Silenciamos para no romper el flujo del thread durante cleanup.
707
+ # disconnect! se invoca típicamente en middlewares (Sidekiq/Puma);
708
+ # una excepción acá propagaría a jobs no relacionados.
709
+ nil
710
+ end
711
+ ```
712
+ Nota: agregar `nil` explícito permite quitar el `rubocop:disable Lint/SuppressedException`.
713
+
714
+ ### 4.5.2 A3 — Rename test + agregar cobertura real string/symbol keys
715
+
716
+ - [ ] Editar `spec/data_drain/record_spec.rb`, bloque `describe ".build_query_path"`:
717
+ - Renombrar test actual:
718
+ ```ruby
719
+ it "interpola symbol values como strings" do
720
+ path = record_class.send(:build_query_path, { year: :integer, month: :integer })
721
+ expect(path).to include("year=integer")
722
+ end
723
+ ```
724
+ - Agregar test real de string keys:
725
+ ```ruby
726
+ it "acepta string keys en el hash de particiones" do
727
+ path = record_class.send(:build_query_path, { "year" => 2026, "month" => 3 })
728
+ expect(path).to include("year=2026")
729
+ expect(path).to include("month=3")
730
+ end
731
+
732
+ it "combina string keys y symbol keys en el mismo hash" do
733
+ path = record_class.send(:build_query_path, { "year" => 2026, month: 3 })
734
+ expect(path).to include("year=2026")
735
+ expect(path).to include("month=3")
736
+ end
737
+ ```
738
+ Razón: el código hace `partitions[k.to_sym] || partitions[k.to_s]`; sin estos tests la rama string-keys no está cubierta.
739
+
740
+ ### 4.5.3 A4 — Cerrar DuckDB en `record_spec.rb#before(:all)`
741
+
742
+ - [ ] Editar `spec/data_drain/record_spec.rb`, bloque `before(:all)`:
743
+ ```ruby
744
+ before(:all) do
745
+ path = "spec/fixtures/test_archive"
746
+ FileUtils.rm_rf(path)
747
+ db = DuckDB::Database.open(":memory:")
748
+ conn = db.connect
749
+ conn.query(<<~SQL)
750
+ COPY (...) TO '#{path}' (...);
751
+ SQL
752
+ conn.close # ← agregar
753
+ db.close # ← agregar
754
+ end
755
+ ```
756
+
757
+ ### 4.5.4 B1 — Reordenar `public`/`private` en `storage/s3.rb`
758
+
759
+ - [ ] Editar `lib/data_drain/storage/s3.rb`:
760
+ - Actualmente tiene toggling `private` → definiciones → `public` → `build_path` → `destroy_partitions` → `private` → `delete_in_batches`.
761
+ - Reordenar: todos los métodos públicos primero (`setup_duckdb`, `build_path`, `destroy_partitions`), luego `private` una sola vez, luego todos los privados (`create_s3_secret`, `escape_sql`, `delete_in_batches`).
762
+ - No cambia lógica, solo orden.
763
+ - [ ] Verificar que quita el `public` re-toggle.
764
+
765
+ ### 4.5.5 Validación Fase 4.5
766
+
767
+ - [ ] `bundle exec rspec` — todo verde
768
+ - [ ] `bundle exec rubocop lib/` — sin ofensas
769
+ - [ ] `bundle exec rubocop lib/data_drain/storage/s3.rb` específicamente (por el reorder)
770
+
771
+ ### 4.5.6 Commits
772
+
773
+ Hacer commits separados por cleanup (son independientes):
774
+ - [ ] `fix(record): comment en disconnect! rescue (cleanup A2)`
775
+ - [ ] `test(record): agregar cobertura string vs symbol keys (cleanup A3)`
776
+ - [ ] `test(record): cerrar DuckDB conn+db en before(:all) (cleanup A4)`
777
+ - [ ] `refactor(storage/s3): reordenar public/private (cleanup B1)`
778
+
779
+ ### Checkpoint Fase 4.5
780
+
781
+ - [ ] 4 commits cleanup (A2, A3, A4, B1)
782
+ - [ ] Coverage estable o sube (A3 agrega ramas cubiertas)
783
+ - [ ] Sin regresiones
784
+
785
+ ---
786
+
787
+ ## Fase 5 — Release
788
+
789
+ ### 5.1 Lint global
790
+
791
+ - [ ] `bundle exec rubocop lib/` sin ofensas
792
+ - [ ] `bundle exec rspec` pasa, coverage ≥ 80%
793
+
794
+ ### 5.2 CHANGELOG
795
+
796
+ - [ ] Editar `CHANGELOG.md`, agregar al tope:
797
+ ```markdown
798
+ ## [0.2.2] - 2026-XX-XX
799
+
800
+ ### Security
801
+ - `Observability#safe_log` filtra secretos con regex en lugar de claves exactas.
802
+ Ahora captura `db_password`, `aws_secret_access_key`, `bearer_token`, `private_key`,
803
+ `*credential*`, etc. (item 9)
804
+
805
+ ### Features
806
+ - `GlueRunner.run_and_wait` acepta `max_wait_seconds:` para evitar bloqueo
807
+ indefinido en jobs colgados. Default `nil` (sin límite, comportamiento previo).
808
+ Emite `glue_runner.timeout` y levanta `DataDrain::Error`. (item 7)
809
+ - `Configuration#validate!` y `Configuration#validate_for_engine!` invocados
810
+ automáticamente en `Engine`, `FileIngestor` y `GlueRunner`. Falla rápido con
811
+ errores descriptivos si falta configuración (ej. `aws_region` con `:s3`,
812
+ `db_*` con Engine). (item 8)
813
+
814
+ ### Docs
815
+ - `skill/references/postgres-tuning.md`: guía de tuning de Postgres por tamaño
816
+ de tabla (índices, VACUUM, particionamiento, diagnóstico). (item 11a)
817
+
818
+ ### Cleanups (review PR #6)
819
+ - Fix typo `依赖` en CHANGELOG v0.2.1 (A1).
820
+ - Comment explicativo en `Record.disconnect!` rescue (A2).
821
+ - Cobertura real string-keys vs symbol-keys en `Record.build_query_path` (A3).
822
+ - Cerrar conn+db en `record_spec.rb#before(:all)` para evitar memory leak en suite (A4).
823
+ - Reorder `public`/`private` en `storage/s3.rb` (B1).
824
+
825
+ ### BREAKING (preventivo)
826
+ - `Engine.new` / `FileIngestor.new` / `GlueRunner.run_and_wait` ahora levantan
827
+ `DataDrain::ConfigurationError` en el boot si la configuración está incompleta.
828
+ Antes fallaban tarde con errores oscuros (`NoMethodError`, `PG::ConnectionBad`).
829
+ ```
830
+
831
+ ### 5.3 Bump versión
832
+
833
+ - [ ] Editar `lib/data_drain/version.rb`: `VERSION = "0.2.2"`
834
+ - [ ] `bundle install` (actualiza `Gemfile.lock`)
835
+
836
+ ### 5.4 Actualizar roadmap
837
+
838
+ - [ ] Editar `docs/IMPROVEMENT_PLAN.md`:
839
+ - Items 7, 8, 9, 11a: `[ ]` → `[x]`
840
+ - Actualizar fecha "Última actualización"
841
+
842
+ ### 5.5 Commit release
843
+
844
+ - [ ] Commit: `chore: release v0.2.2 — items P1 (filtros, timeout Glue, config validate, docs PG)`
845
+
846
+ ### 5.6 Merge y tag
847
+
848
+ - [ ] `git push origin feature/v0.2.2`
849
+ - [ ] Abrir PR a `main` con cuerpo basado en CHANGELOG
850
+ - [ ] Esperar CI verde
851
+ - [ ] Mergear
852
+ - [ ] Tag: `git tag v0.2.2 && git push origin v0.2.2`
853
+
854
+ ### 5.7 Post-merge
855
+
856
+ - [ ] Archivar este plan: `git mv docs/execution/v0.2.2.md docs/execution/archive/v0.2.2.md`
857
+ - [ ] Commit: `chore: archive v0.2.2 plan, mark items 7/8/9/11a [x]`
858
+
859
+ ---
860
+
861
+ ## Validación final
862
+
863
+ - [ ] `bundle exec rspec` verde, coverage ≥ 80%
864
+ - [ ] `bundle exec rubocop lib/` sin ofensas
865
+ - [ ] CHANGELOG completo con fecha real
866
+ - [ ] Version = `0.2.2`
867
+ - [ ] Tag `v0.2.2` creado y pusheado
868
+ - [ ] Items 7, 8, 9, 11a marcados `[x]` en roadmap
869
+ - [ ] Plan archivado
870
+
871
+ ---
872
+
873
+ ## Plan B — si algún item se atasca
874
+
875
+ | Si... | Entonces... |
876
+ |-------|-------------|
877
+ | Item 8 rompe callers en monorepo Wispro (validate_for_engine!) | Relajar validación: log warning en lugar de raise. Re-habilitar raise en v0.3.0 tras coordinar. |
878
+ | Item 7 tests con mock de Process.clock_gettime son flakey | Usar `Timecop` o abstraer el monotonic clock a un método de clase stubeable. |
879
+ | Item 11a toma más de 1 día | Cortar a lo esencial: tabla decisión + checklist índice + diagnóstico SQL. Particionamiento puede ir a v0.3.0. |
880
+ | CI falla por coverage | Revisar SimpleCov — probablemente los nuevos branches de `validate!` (item 8) no están cubiertos. Agregar tests específicos. |
881
+
882
+ ---
883
+
884
+ ## Notas para el agente que ejecuta
885
+
886
+ - **Cada fase cierra con commit atómico.** No commit si tests rojos.
887
+ - **Antes de cada commit:** `bundle exec rspec` + `bundle exec rubocop lib/`.
888
+ - **Item 8 (validate!) es el más invasivo.** Hacer última revisión de callers del monorepo antes de mergear.
889
+ - **Items 9, 7, 11a pueden paralelizarse** si hay múltiples agentes. Item 8 debe ir solo (toca 3 archivos core).
890
+ - **Si CI agrega Ruby 3.2, 3.3 al matrix (item 14c pendiente)**, verificar que los cambios pasan en todas.
891
+ - **Actualizar `skill/` en cada fase** — no acumular deuda de docs para el final.