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
data/skill/SKILL.md
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# DataDrain Expert
|
|
2
|
+
|
|
3
|
+
Skill de conocimiento completo sobre DataDrain. Consultame para cualquier pregunta sobre integración, arquitectura, API, errores y antipatrones.
|
|
4
|
+
|
|
5
|
+
## Glosario
|
|
6
|
+
|
|
7
|
+
- **DataDrain** — Micro-framework Ruby para ETL: extraer datos históricos de PostgreSQL → Parquet (S3/Local) → verificar integridad → purgar origen.
|
|
8
|
+
- **Engine** — Motor principal que orquesta el flujo Conteo → Export → Verify → Purge.
|
|
9
|
+
- **FileIngestor** — Convierte archivos crudos (CSV/JSON/Parquet) a Parquet particionado en el Data Lake.
|
|
10
|
+
- **Record** — Clase base ORM analítico (tipo ActiveRecord) read-only sobre Parquet vía DuckDB.
|
|
11
|
+
- **GlueRunner** — Orquestador de AWS Glue Jobs para tablas de gran volumen (>500GB-1TB).
|
|
12
|
+
- **Storage Adapter** — Patrón Strategy con dos implementaciones: `Storage::Local` y `Storage::S3`. Cacheado en `Storage.adapter`.
|
|
13
|
+
- **Observability** — Módulo mixín (`include`/`extend`) con `safe_log` resiliente y logging KV estructurado.
|
|
14
|
+
- **Hive Partitioning** — Estructura de carpetas `key1=val1/key2=val2/...` que DuckDB genera y consume nativamente para prefix scans eficientes.
|
|
15
|
+
- **Semi-abierto** — Convención de rangos `[start, end)` con `<` (no `<=`) para evitar pérdida de microsegundos en límites de fecha.
|
|
16
|
+
- **skip_export** — Modo del Engine donde delega export a herramienta externa (Glue/EMR) y solo verifica + purga.
|
|
17
|
+
- **Heartbeat** — Log de progreso emitido cada 100 lotes en purgas masivas (tablas 1TB).
|
|
18
|
+
- **Wispro-Observability-Spec v1** — Estándar de logs KV: `component=` y `event=` primero, sufijo `_s` para tiempos float, `_count` para enteros, sin unidades en valores.
|
|
19
|
+
|
|
20
|
+
## Arquitectura
|
|
21
|
+
|
|
22
|
+
### Responsabilidad core
|
|
23
|
+
|
|
24
|
+
DataDrain resuelve el ciclo de vida de datos históricos en bases relacionales calientes: archivar a Data Lake con garantía matemática de integridad antes de purgar el origen.
|
|
25
|
+
|
|
26
|
+
### Componentes
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
|
30
|
+
│ PostgreSQL │───>│ Engine │───>│ Data Lake │
|
|
31
|
+
└──────────────┘ │ (DuckDB) │ │ (S3 / Local) │
|
|
32
|
+
▲ └──────────────┘ └──────────────┘
|
|
33
|
+
│ │ ▲
|
|
34
|
+
│ ▼ │
|
|
35
|
+
│ ┌──────────────┐ │
|
|
36
|
+
└────purga───│ Verify OK? │ │
|
|
37
|
+
└──────────────┘ │
|
|
38
|
+
│
|
|
39
|
+
┌──────────────┐ │
|
|
40
|
+
│ FileIngestor │────┘
|
|
41
|
+
└──────────────┘
|
|
42
|
+
│
|
|
43
|
+
┌──────────────┐ │
|
|
44
|
+
│ Record │<───┘
|
|
45
|
+
│ (consultas) │
|
|
46
|
+
└──────────────┘
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Flujo runtime de Engine
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
1. setup_duckdb → ATTACH Postgres + setup adapter (httpfs si S3)
|
|
53
|
+
2. get_postgres_count → si 0, return true (skip)
|
|
54
|
+
3. export_to_parquet → COPY ... TO ... PARTITION_BY (...) ZSTD [omitido si skip_export]
|
|
55
|
+
4. verify_integrity → COUNT(*) Parquet == COUNT(*) Postgres
|
|
56
|
+
5. purge_from_postgres → DELETE en lotes throttled + heartbeat
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Decisiones de diseño
|
|
60
|
+
|
|
61
|
+
- **DuckDB en memoria** procesa millones de registros sin cargar objetos en RAM Ruby. Usa `ATTACH POSTGRES READ_ONLY` para leer origen y `COPY ... TO` para escribir Parquet.
|
|
62
|
+
- **Conexión DuckDB thread-local** en `Record`: cada thread inicializa una conexión persistente que se cachea en `Thread.current[:data_drain_duckdb] = { db:, conn: }`. El hash retiene la `Database` para evitar GC prematuro de la conexión.
|
|
63
|
+
- **Verify es la única puerta de seguridad** antes de purgar. Si retorna `false` (incluyendo `DuckDB::Error` al leer Parquet), la purga se aborta.
|
|
64
|
+
- **Storage Adapter cacheado**: `Storage.adapter` memoiza la instancia. Si se cambia `storage_mode` en runtime, llamar `Storage.reset_adapter!`.
|
|
65
|
+
- **Rangos semi-abiertos**: `created_at >= start AND created_at < end_boundary` donde `end_boundary = end_date.next_day.beginning_of_day`. Nunca `<= end_of_day`.
|
|
66
|
+
|
|
67
|
+
### Stack y dependencias
|
|
68
|
+
|
|
69
|
+
- Ruby `>= 3.0.0`
|
|
70
|
+
- Runtime: `activemodel >= 6.0`, `duckdb ~> 1.4`, `pg >= 1.2`, `aws-sdk-s3 ~> 1.114`, `aws-sdk-glue ~> 1.0`
|
|
71
|
+
- Versión actual: `0.1.19`
|
|
72
|
+
|
|
73
|
+
## API Pública (resumen)
|
|
74
|
+
|
|
75
|
+
### Configuración global
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
DataDrain.configure do |config|
|
|
79
|
+
config.storage_mode = :local | :s3
|
|
80
|
+
config.aws_region, .aws_access_key_id, .aws_secret_access_key
|
|
81
|
+
config.db_host, .db_port, .db_user, .db_pass, .db_name
|
|
82
|
+
config.batch_size = 5000
|
|
83
|
+
config.throttle_delay = 0.5
|
|
84
|
+
config.idle_in_transaction_session_timeout = 0 # 0 = DESACTIVADO
|
|
85
|
+
config.limit_ram = "2GB"
|
|
86
|
+
config.tmp_directory = "/tmp/duckdb_work"
|
|
87
|
+
config.logger = Rails.logger
|
|
88
|
+
end
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Operaciones principales
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
# 1. ETL completo (Engine)
|
|
95
|
+
DataDrain::Engine.new(
|
|
96
|
+
bucket:, start_date:, end_date:, table_name:,
|
|
97
|
+
partition_keys: %w[isp_id year month],
|
|
98
|
+
primary_key: "id", # opcional
|
|
99
|
+
where_clause: nil, # opcional, SQL extra
|
|
100
|
+
skip_export: false, # true delega export a Glue
|
|
101
|
+
folder_name: nil, # default = table_name
|
|
102
|
+
select_sql: "*" # default
|
|
103
|
+
).call # => true (ok) | false (integrity fail)
|
|
104
|
+
|
|
105
|
+
# 2. Ingesta de archivos crudos
|
|
106
|
+
DataDrain::FileIngestor.new(
|
|
107
|
+
bucket:, source_path:, folder_name:,
|
|
108
|
+
partition_keys: [], # opcional
|
|
109
|
+
select_sql: "*", # opcional
|
|
110
|
+
delete_after_upload: true # opcional
|
|
111
|
+
).call
|
|
112
|
+
|
|
113
|
+
# 3. ORM analítico
|
|
114
|
+
class ArchivedX < DataDrain::Record
|
|
115
|
+
self.bucket = "..."
|
|
116
|
+
self.folder_name = "..."
|
|
117
|
+
self.partition_keys = [:isp_id, :year, :month] # ORDEN = jerarquía Hive
|
|
118
|
+
attribute :id, :string
|
|
119
|
+
end
|
|
120
|
+
ArchivedX.where(limit: 10, isp_id: 42, year: 2026, month: 3) # => Array
|
|
121
|
+
ArchivedX.find("uuid", isp_id: 42, year: 2026, month: 3) # => instance | nil
|
|
122
|
+
ArchivedX.destroy_all(isp_id: 42) # => Integer (particiones borradas)
|
|
123
|
+
|
|
124
|
+
# 4. Glue para tablas 1TB+
|
|
125
|
+
DataDrain::GlueRunner.run_and_wait("job-name", { "--key" => "val" }, polling_interval: 30)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Detalle completo de firmas, parámetros, retornos y comportamientos en [API Detallada](references/api-detallada.md).
|
|
129
|
+
|
|
130
|
+
## FAQ
|
|
131
|
+
|
|
132
|
+
### ¿Cuándo usar `Engine` directo vs `GlueRunner` + `Engine(skip_export: true)`?
|
|
133
|
+
|
|
134
|
+
`Engine` directo soporta hasta ~10-50GB cómodamente. Para tablas >500GB-1TB delegar el export a AWS Glue (Apache Spark distribuido) y usar `Engine(skip_export: true)` solo para verificar integridad y purgar Postgres. DataDrain en este modo solo lee Parquet (no exporta) y borra origen una vez confirmados los conteos.
|
|
135
|
+
|
|
136
|
+
### ¿Qué pasa si `verify_integrity` falla?
|
|
137
|
+
|
|
138
|
+
`Engine#call` retorna `false` y **no ejecuta la purga**. Emite log `engine.integrity_error`. Si la falla viene de no poder leer el Parquet (`DuckDB::Error`), emite `engine.parquet_read_error` y también retorna `false`. Es la única salvaguarda matemática del sistema.
|
|
139
|
+
|
|
140
|
+
### ¿Cómo cambiar `storage_mode` en runtime?
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
DataDrain.configure { |c| c.storage_mode = :s3 }
|
|
144
|
+
DataDrain::Storage.reset_adapter! # OBLIGATORIO, sino se sigue usando el adapter cacheado
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### ¿Por qué `idle_in_transaction_session_timeout = 0`?
|
|
148
|
+
|
|
149
|
+
`0` **desactiva** el timeout (sin límite de tiempo). Es mandatorio para purgas de gran volumen donde un lote puede tardar segundos. Internamente se valida con `!nil?` (no `.present?`) porque `0.present?` es `false` en Rails.
|
|
150
|
+
|
|
151
|
+
### ¿El orden de `partition_keys` importa?
|
|
152
|
+
|
|
153
|
+
Sí, **crítico**. Determina la jerarquía Hive en disco. El orden al **escribir** (Engine/FileIngestor) debe ser idéntico al declarado en el modelo `Record` que lee. Mismatch → DuckDB retorna vacío sin error. Convención canónica: `[dimension_principal, year, month]` (mayor cardinalidad o filtro más usado primero).
|
|
154
|
+
|
|
155
|
+
### ¿La conexión DuckDB es thread-safe?
|
|
156
|
+
|
|
157
|
+
Sí. `Record.connection` mantiene una conexión por thread vía `Thread.current`. En Puma/Sidekiq cada worker thread tiene la suya. La conexión nunca se cierra explícitamente (persiste mientras vive el thread). `Engine` y `FileIngestor` crean su propia conexión efímera por instancia y la cierran en `ensure`.
|
|
158
|
+
|
|
159
|
+
### ¿DataDrain valida los nombres de tabla?
|
|
160
|
+
|
|
161
|
+
No. `table_name`, `select_sql` y `where_clause` se interpolan directamente en SQL. La gema asume que estos valores vienen de código de aplicación (no de input de usuario). En `Record.find` el `id` sí se sanitiza (escape de comillas simples).
|
|
162
|
+
|
|
163
|
+
### ¿Cómo evito OOM con tablas grandes?
|
|
164
|
+
|
|
165
|
+
Setear `limit_ram` (ej. `"2GB"`) y `tmp_directory` (en SSD). DuckDB hará spill-to-disk automáticamente. Para tablas >500GB delegar a Glue.
|
|
166
|
+
|
|
167
|
+
### ¿Los logs incluyen `source=`?
|
|
168
|
+
|
|
169
|
+
No. La gema NO emite `source=` manualmente — lo inyecta automáticamente `exis_ray` (logger middleware externo) cuando está presente. Si no usás `exis_ray`, agregalo con un wrapper de logger.
|
|
170
|
+
|
|
171
|
+
### ¿Qué formato tienen los logs?
|
|
172
|
+
|
|
173
|
+
`component=data_drain event=<clase>.<suceso> [campos KV]`. Tiempos con sufijo `_s` y valor float. Contadores con `_count` y valor integer. Sin unidades en los valores. Detalle en [Eventos y Telemetría](references/eventos-telemetria.md).
|
|
174
|
+
|
|
175
|
+
## Errores
|
|
176
|
+
|
|
177
|
+
Catálogo top. Detalle completo y resolución en [API Detallada](references/api-detallada.md).
|
|
178
|
+
|
|
179
|
+
### `DataDrain::Error`
|
|
180
|
+
Clase base. Toda excepción del framework hereda de acá.
|
|
181
|
+
|
|
182
|
+
### `DataDrain::ConfigurationError`
|
|
183
|
+
Levantado cuando falta configuración obligatoria. **Causa típica:** olvidar `aws_*` con `storage_mode = :s3`. **Resolución:** completar el bloque `DataDrain.configure`.
|
|
184
|
+
|
|
185
|
+
### `DataDrain::IntegrityError`
|
|
186
|
+
Reservado para fallos matemáticos en verificación. Actualmente `Engine#call` retorna `false` en lugar de levantarlo. **Resolución:** investigar mismatch entre conteo Postgres y conteo Parquet.
|
|
187
|
+
|
|
188
|
+
### `DataDrain::StorageError`
|
|
189
|
+
Problemas interactuando con disco local, S3 o DuckDB. **Causa típica:** credenciales AWS inválidas, bucket inexistente, permisos S3 insuficientes.
|
|
190
|
+
|
|
191
|
+
### `DataDrain::Storage::InvalidAdapterError`
|
|
192
|
+
`storage_mode` no reconocido. **Causa:** valor distinto de `:local` o `:s3`. **Resolución:** corregir configuración.
|
|
193
|
+
|
|
194
|
+
### `DuckDB::Error` (no envuelto)
|
|
195
|
+
Errores de query DuckDB. En `Engine#verify_integrity` se captura y se loguea como `engine.parquet_read_error` retornando `false`. En `FileIngestor#call` se captura y se loguea como `file_ingestor.duckdb_error` retornando `false`. En `Record` se captura en `execute_and_instantiate` y retorna `[]`.
|
|
196
|
+
|
|
197
|
+
### `RuntimeError` desde `GlueRunner`
|
|
198
|
+
Levantado cuando un Job de Glue termina con estado `FAILED`, `STOPPED` o `TIMEOUT`. **Mensaje:** `"Glue Job <name> (Run ID: <id>) falló con estado <status>."`
|
|
199
|
+
|
|
200
|
+
## Antipatrones
|
|
201
|
+
|
|
202
|
+
Catálogo completo en [Antipatrones](references/antipatrones.md). Resumen de los más críticos:
|
|
203
|
+
|
|
204
|
+
1. **Bypassear `verify_integrity`** llamando `purge_from_postgres` directo — rompe la única garantía de seguridad.
|
|
205
|
+
2. **Mismatch en orden de `partition_keys`** entre escritura y lectura — DuckDB devuelve vacío sin error.
|
|
206
|
+
3. **`storage_mode` cambiado sin `reset_adapter!`** — sigue usando el adapter viejo cacheado.
|
|
207
|
+
4. **Validar `idle_in_transaction_session_timeout` con `.present?`** — `0.present?` es `false`, ignora la config.
|
|
208
|
+
5. **Usar `<= end_of_day`** en rangos de fecha — pierde registros con microsegundos.
|
|
209
|
+
6. **Loguear `source=`** manualmente — duplica el campo que inyecta `exis_ray`.
|
|
210
|
+
|
|
211
|
+
## Referencias
|
|
212
|
+
|
|
213
|
+
- [API Detallada](references/api-detallada.md) — Firmas completas, parámetros, retornos y comportamientos de cada clase pública.
|
|
214
|
+
- [Eventos y Telemetría](references/eventos-telemetria.md) — Catálogo completo de eventos KV emitidos por la gema.
|
|
215
|
+
- [Antipatrones](references/antipatrones.md) — Qué NO hacer y alternativas correctas.
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# Antipatrones
|
|
2
|
+
|
|
3
|
+
Qué NO hacer en DataDrain. Cada antipatrón incluye código incorrecto, razón y alternativa correcta.
|
|
4
|
+
|
|
5
|
+
## 1. Bypassear `verify_integrity` para purgar más rápido
|
|
6
|
+
|
|
7
|
+
**Incorrecto:**
|
|
8
|
+
```ruby
|
|
9
|
+
engine = DataDrain::Engine.new(...)
|
|
10
|
+
engine.send(:setup_duckdb)
|
|
11
|
+
engine.send(:purge_from_postgres) # SIN verificar antes
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
**Razón:** `verify_integrity` es la **única salvaguarda matemática** entre la exportación y el `DELETE` definitivo. Si se omite, podés borrar datos que no fueron archivados (corrupción silenciosa, archivo Parquet vacío, mismatch de fechas, etc.).
|
|
15
|
+
|
|
16
|
+
**Alternativa:** Siempre usar `Engine#call`. Si necesitás solo verificar+purgar (porque el export lo hizo Glue/EMR), usar `skip_export: true` — el verify sigue siendo obligatorio dentro del flujo.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## 2. Mismatch en orden de `partition_keys` entre escritura y lectura
|
|
21
|
+
|
|
22
|
+
**Incorrecto:**
|
|
23
|
+
```ruby
|
|
24
|
+
# Engine escribe con orden A
|
|
25
|
+
Engine.new(partition_keys: %w[year month isp_id], ...).call
|
|
26
|
+
|
|
27
|
+
# Record lee con orden B
|
|
28
|
+
class ArchivedX < DataDrain::Record
|
|
29
|
+
self.partition_keys = [:isp_id, :year, :month] # MISMATCH
|
|
30
|
+
end
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**Razón:** El orden de `partition_keys` determina la jerarquía Hive en disco (`year=X/month=Y/isp_id=Z`). Si Record lee con otro orden, el path generado no coincide y **DuckDB devuelve `[]` sin error**. La falla es silenciosa.
|
|
34
|
+
|
|
35
|
+
**Alternativa:** Mantener orden idéntico en escritura (Engine/FileIngestor) y lectura (Record). Convención canónica: `[dimension_principal, year, month]` (mayor cardinalidad o filtro más usado primero).
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## 3. Cambiar `storage_mode` sin resetear el adapter
|
|
40
|
+
|
|
41
|
+
**Incorrecto:**
|
|
42
|
+
```ruby
|
|
43
|
+
DataDrain.configure { |c| c.storage_mode = :s3 }
|
|
44
|
+
DataDrain::Engine.new(...).call # Sigue usando Local cacheado si ya se inicializó
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**Razón:** `Storage.adapter` es memoizado (`@adapter ||= ...`). Cambiar `storage_mode` después de la primera invocación no tiene efecto.
|
|
48
|
+
|
|
49
|
+
**Alternativa:**
|
|
50
|
+
```ruby
|
|
51
|
+
DataDrain.configure { |c| c.storage_mode = :s3 }
|
|
52
|
+
DataDrain::Storage.reset_adapter!
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## 4. Validar `idle_in_transaction_session_timeout` con `.present?`
|
|
58
|
+
|
|
59
|
+
**Incorrecto:**
|
|
60
|
+
```ruby
|
|
61
|
+
if @config.idle_in_transaction_session_timeout.present? # 0.present? == false
|
|
62
|
+
conn.exec("SET ... = #{...};")
|
|
63
|
+
end
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**Razón:** El valor `0` significa **timeout desactivado** (sin límite), que es exactamente lo que querés en purgas masivas. `0.present?` es `false` en Rails, así que `0` se ignora silenciosamente y Postgres aplica el timeout default.
|
|
67
|
+
|
|
68
|
+
**Alternativa:** Usar `!nil?`:
|
|
69
|
+
```ruby
|
|
70
|
+
unless @config.idle_in_transaction_session_timeout.nil?
|
|
71
|
+
conn.exec("SET ... = #{@config.idle_in_transaction_session_timeout};")
|
|
72
|
+
end
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## 5. Usar `<= end_of_day` en rangos de fecha
|
|
78
|
+
|
|
79
|
+
**Incorrecto:**
|
|
80
|
+
```ruby
|
|
81
|
+
"created_at >= '#{start.beginning_of_day}' AND created_at <= '#{end_date.end_of_day}'"
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
**Razón:** `end_of_day` retorna `23:59:59.999999`. Registros con timestamps en los microsegundos siguientes (`23:59:59.9999995`) quedan fuera o cruzados según floor/ceil del cliente. Con `BETWEEN` o `<=` la pérdida de filas es silenciosa.
|
|
85
|
+
|
|
86
|
+
**Alternativa:** Rango semi-abierto con `<` y boundary del próximo periodo:
|
|
87
|
+
```ruby
|
|
88
|
+
"created_at >= '#{start.beginning_of_day}' AND created_at < '#{end_date.next_day.beginning_of_day}'"
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## 6. Loguear `source=` manualmente
|
|
94
|
+
|
|
95
|
+
**Incorrecto:**
|
|
96
|
+
```ruby
|
|
97
|
+
safe_log(:info, "engine.start", { source: "data_drain", table: @table_name })
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
**Razón:** El campo `source=` lo inyecta automáticamente el middleware `exis_ray` (identifica el entrypoint: `http`, `sidekiq`, `task`, `system`). Emitirlo manualmente lo duplica o lo sobrescribe con un valor incorrecto.
|
|
101
|
+
|
|
102
|
+
**Alternativa:** Nunca incluir `source` en metadata. Solo `component` (automático vía `observability_name`) + `event` + campos de negocio.
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## 7. Olvidar `private_class_method` al usar `extend Observability`
|
|
107
|
+
|
|
108
|
+
**Incorrecto:**
|
|
109
|
+
```ruby
|
|
110
|
+
class GlueRunner
|
|
111
|
+
extend Observability
|
|
112
|
+
# safe_log queda público — cualquiera puede llamar GlueRunner.safe_log(...)
|
|
113
|
+
end
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
**Razón:** `extend` hace los métodos del módulo accesibles como métodos de clase **públicos**. Eso filtra una API interna y rompe encapsulación.
|
|
117
|
+
|
|
118
|
+
**Alternativa:**
|
|
119
|
+
```ruby
|
|
120
|
+
class GlueRunner
|
|
121
|
+
extend Observability
|
|
122
|
+
private_class_method :safe_log, :exception_metadata, :observability_name
|
|
123
|
+
end
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## 8. Olvidar `include Observability` en clases de instancia
|
|
129
|
+
|
|
130
|
+
**Incorrecto:**
|
|
131
|
+
```ruby
|
|
132
|
+
class Engine
|
|
133
|
+
# falta include
|
|
134
|
+
def call
|
|
135
|
+
safe_log(:info, "engine.start", {}) # NoMethodError
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
**Razón:** Sin `include`, `safe_log` no existe en la clase. Falla en runtime al primer evento.
|
|
141
|
+
|
|
142
|
+
**Alternativa:**
|
|
143
|
+
```ruby
|
|
144
|
+
class Engine
|
|
145
|
+
include Observability
|
|
146
|
+
def call
|
|
147
|
+
safe_log(:info, "engine.start", {})
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## 9. Agregar lógica de infraestructura en `Observability`
|
|
155
|
+
|
|
156
|
+
**Incorrecto:** Agregar al módulo `Observability` métodos como `current_memory_mb` que usen backticks (`` `ps` ``) o `Process` para inferir métricas del sistema.
|
|
157
|
+
|
|
158
|
+
**Razón:** `Observability` es un **módulo de logging genérico**, reusable en otras gemas. Mezclarle lógica de infraestructura lo acopla al runtime específico y rompe portabilidad.
|
|
159
|
+
|
|
160
|
+
**Alternativa:** Métricas de infraestructura van en otro módulo (ej. `Telemetry::Process`) o en el caller. `Observability` solo formatea y emite logs.
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## 10. Usar `Time.now` para medir duraciones
|
|
165
|
+
|
|
166
|
+
**Incorrecto:**
|
|
167
|
+
```ruby
|
|
168
|
+
start = Time.now
|
|
169
|
+
do_work
|
|
170
|
+
duration = Time.now - start
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
**Razón:** `Time.now` es wall clock — cambia con NTP, cambios de zona horaria, leap seconds. Mide tiempos negativos o saltos. No es apto para latencia.
|
|
174
|
+
|
|
175
|
+
**Alternativa:**
|
|
176
|
+
```ruby
|
|
177
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
178
|
+
do_work
|
|
179
|
+
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## 11. Loguear DEBUG sin bloque
|
|
185
|
+
|
|
186
|
+
**Incorrecto:**
|
|
187
|
+
```ruby
|
|
188
|
+
logger.debug("query=#{expensive_serialize(obj)}") # Siempre evalúa, incluso si DEBUG off
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
**Razón:** Sin bloque, el string se construye siempre, incluso cuando el nivel DEBUG está desactivado en producción. Costo invisible.
|
|
192
|
+
|
|
193
|
+
**Alternativa:**
|
|
194
|
+
```ruby
|
|
195
|
+
logger.debug { "query=#{expensive_serialize(obj)}" }
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## 12. Asumir que `Record.connection` se puede cerrar manualmente
|
|
201
|
+
|
|
202
|
+
**Incorrecto:**
|
|
203
|
+
```ruby
|
|
204
|
+
ArchivedX.where(...)
|
|
205
|
+
ArchivedX.connection.close # Rompe la siguiente query del mismo thread
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
**Razón:** `Record.connection` es thread-local y persistente — diseñada para amortizar el costo de cargar `httpfs` y credenciales. Cerrarla obliga a reconectar todo en la próxima query y puede dejar el `Thread.current` apuntando a una conexión muerta (`Database` GC'd).
|
|
209
|
+
|
|
210
|
+
**Alternativa:** No usar `Record.connection.close` directamente. Si necesitás cerrar (Sidekiq/Puma middleware), usar `Record.disconnect!` que cierra `db` + `conn` y limpia `Thread.current` atómicamente. En threads de larga vida, esto previene memory leak.
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## 13. Pasar input de usuario a `select_sql` o `where_clause`
|
|
215
|
+
|
|
216
|
+
**Incorrecto:**
|
|
217
|
+
```ruby
|
|
218
|
+
DataDrain::Engine.new(
|
|
219
|
+
table_name: params[:table], # input usuario interpolado en SQL
|
|
220
|
+
where_clause: params[:filter],
|
|
221
|
+
...
|
|
222
|
+
).call
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
**Razón:** `select_sql` y `where_clause` se interpolan **directamente en SQL** (no son prepared statements). Input de usuario abre vector de SQL injection.
|
|
226
|
+
|
|
227
|
+
**Nota:** `table_name` y `primary_key` se validan con regex `\A[a-zA-Z_][a-zA-Z0-9_]*\z` en `Engine#initialize`. Si el valor no matchea, levantan `DataDrain::ConfigurationError`. `select_sql` y `where_clause` siguen siendo trusted (no se validan).
|
|
228
|
+
|
|
229
|
+
**Alternativa:** `table_name` y `primary_key` ahora están protegidos contra injection trivial. `select_sql` y `where_clause` deben venir de código de aplicación (constantes, configuración, jobs con valores fijos).
|
|
230
|
+
|
|
231
|
+
---
|
|
232
|
+
|
|
233
|
+
## 14. Confiar en que `GlueRunner` tiene timeout máximo
|
|
234
|
+
|
|
235
|
+
**Incorrecto:**
|
|
236
|
+
```ruby
|
|
237
|
+
DataDrain::GlueRunner.run_and_wait("job", args) # Asumir que retorna en X minutos
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
**Razón:** El loop de polling no tiene timeout máximo. Si Glue queda colgado en `RUNNING` indefinidamente, `run_and_wait` bloquea para siempre.
|
|
241
|
+
|
|
242
|
+
**Alternativa:** Envolver en `Timeout.timeout(N)` en el caller, o monitorear el job desde fuera (CloudWatch alarm). Mejor aún: futura mejora de la gema agregar `max_wait_seconds`.
|