Plataforma técnica · Orchestrator
Repository Decorators
Orchestrator Common Retry
wrapStaticRepository envuelve una clase de repositorio (con métodos estáticos async) en un Proxy que añade dos comportamientos transversales sin requerir cambiar el código del repository:
- Slow query log — si un método tarda más que
slowQueryMs, se loguea un⚠️ [SLOW QUERY]. - Retry automático — en errores transientes de PostgreSQL (
40001 serialization_failureo40P01 deadlock_detected), reintenta hastamaxRetriesveces con backoff lineal.
Implementado con Proxy<T> — funciona en CUALQUIER método async sin exigir interfaces específicas. La forma del repository no cambia.
Patrón de uso
Section titled “Patrón de uso”// CapitalRepositoryImpl: la clase "real" con métodos estáticosclass CapitalRepositoryImpl {static async list(pool: Pool, filters: CapitalFilters) { ... }static async findById(pool: Pool, id: string) { ... }// ...}
// Export: el wrapexport const CapitalRepository = wrapStaticRepository(CapitalRepositoryImpl, {label: "CapitalRepository",slowQueryMs: 500,});
// Type re-export para que los callers tengan los mismos tiposexport type CapitalRepository = typeof CapitalRepositoryImpl;El consumidor importa CapitalRepository (el Proxy). Cada llamada como CapitalRepository.list(...) pasa por el Proxy → observability → método real.
Opciones
Section titled “Opciones”WrapRepositoryOptions:
| Opción | Default | Notas |
|---|---|---|
slowQueryMs | 500 | 0 = desactiva el slow log. |
retry | true | false = no reintenta nunca. |
maxRetries | 3 | Total de intentos (no reintentos adicionales). |
retryDelayMs | 100 | Multiplicado por número de intento (backoff lineal). |
label | Nombre de la clase, o 'Repository' si no se puede inferir. | Aparece en los logs. |
Slow Query Log
Section titled “Slow Query Log”sequenceDiagram
autonumber
participant C as Caller
participant P as Proxy
participant R as Repository real
C->>P: Repository.method(args)
Note over P: start = Date.now()
P->>R: invoke method
R-->>P: result
Note over P: elapsed = Date.now() - start
alt elapsed > slowQueryMs
P->>P: console.warn("⚠️ [SLOW QUERY]<br/>label.method took Xms")
end
P-->>C: result El mensaje:
⚠️ [SLOW QUERY] CapitalRepository.list took 723msSi hubo retries, lo indica:
⚠️ [SLOW QUERY] CapitalRepository.update took 1240ms (after 2 attempts)El umbral se mide al final del proceso completo (incluyendo retries). No genera un warn por intento.
Retry Automático
Section titled “Retry Automático”flowchart TB
ATT["intento N (await fn())"]
OK["return result"]
ERR["catch err"]
ISTR{"isPgError(err) AND<br/>code IN {40001, 40P01} AND<br/>retry habilitado?"}
MAX{"N < maxRetries?"}
WAIT["sleep(retryDelayMs * N)"]
THROW["throw err"]
ATT --> OK
ATT -. error .-> ERR --> ISTR
ISTR -- no --> THROW
ISTR -- sí --> MAX
MAX -- no --> THROW
MAX -- sí --> WAIT --> ATT Códigos transientes cubiertos:
| Código PG | Significado |
|---|---|
40001 | serialization_failure. Típico en ISOLATION LEVEL SERIALIZABLE. |
40P01 | deadlock_detected. Dos TXs bloqueándose mutuamente. |
Otros errores (constraint violations, syntax, timeouts) no reintentan — propagan al primer fallo.
Detalle del backoff
Section titled “Detalle del backoff”sleep(retryDelayMs * attempt):
| Intento | Espera previa (default retryDelayMs=100) |
|---|---|
| 1 | 0 ms |
| 2 | 100 ms |
| 3 | 200 ms |
Lineal — no exponencial. Total worst-case con defaults: 300ms de espera distribuida en 3 intentos.
Cache de wrappers por método
Section titled “Cache de wrappers por método”getMethodCache memoiza el método wrapped por prop key — así que Repo.method === Repo.method permanece estable entre accesos (útil cuando algún caller compara referencias de método). El cache vive en un WeakMap<target, Map<key, ...>> y se libera con GC del target.
isPgError narrowing
Section titled “isPgError narrowing”El check isPgError(err) viene de BaseService.ts:
export function isPgError(e: unknown): e is PgError { return e instanceof Error && 'code' in e;}Es laxo (cualquier Error con code), no verifica que code sea PG. En la práctica solo node-postgres pone code en sus errores, así que el falso positivo es raro.
Comportamiento con métodos no-async
Section titled “Comportamiento con métodos no-async”get(target, prop) chequea typeof original === 'function'. Si no lo es (e.g. campo estático), se pasa tal cual sin wrapping. Si es función pero síncrona, el wrap aún funciona pero await fn() la trata como Promise<value> — no afecta el resultado.
No es para servicios
Section titled “No es para servicios”Solo repositories. Los servicios (BaseService subclasses) tienen su propio control de TX y logging. Envolverlos con wrapStaticRepository sería redundante y podría duplicar warnings.
Cuándo usar retry: false
Section titled “Cuándo usar retry: false”Casos en que NO querés retry automático:
- Operaciones idempotentes con efectos externos (e.g. enviar email, llamar API externa) — un retry podría duplicar el efecto.
- Tests — los retries enmascaran flakiness intencional.
- Análisis de contención — quieres ver el primer error tal cual.
export const Repo = wrapStaticRepository(RepoImpl, { retry: false });Adopción actual
Section titled “Adopción actual”| Repository | Wrap |
|---|---|
CapitalRepository | slowQueryMs: 500. |
| Resto de repositories | Mayoría sin wrap todavía — adopción incremental. |
La mayoría de repositories del proyecto no están envueltos aún. Adoptarlos es una tarea de cleanup mecánica (rename → impl, export wrap). El bus de eventos y BaseService ya están listos para coexistir.