data_drain 0.1.19 → 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.
- checksums.yaml +4 -4
- data/.rubocop.yml +2 -0
- data/CHANGELOG.md +25 -0
- data/CLAUDE.md +4 -0
- data/README.md +66 -171
- data/docs/IMPROVEMENT_PLAN.md +1162 -0
- data/docs/execution/archive/v0.2.0.agente-review.md +125 -0
- data/docs/execution/archive/v0.2.0.md +812 -0
- data/docs/glue_pyspark_example.py +60 -0
- data/lib/data_drain/engine.rb +53 -40
- data/lib/data_drain/file_ingestor.rb +40 -25
- data/lib/data_drain/record.rb +24 -3
- data/lib/data_drain/storage/s3.rb +48 -6
- data/lib/data_drain/validations.rb +17 -0
- data/lib/data_drain/version.rb +1 -1
- data/lib/data_drain.rb +2 -0
- data/skill/SKILL.md +215 -0
- data/skill/references/antipatrones.md +242 -0
- data/skill/references/api-detallada.md +257 -0
- data/skill/references/eventos-telemetria.md +154 -0
- metadata +11 -2
|
@@ -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`.
|