Skip to content

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:

  1. Slow query log — si un método tarda más que slowQueryMs, se loguea un ⚠️ [SLOW QUERY].
  2. Retry automático — en errores transientes de PostgreSQL (40001 serialization_failure o 40P01 deadlock_detected), reintenta hasta maxRetries veces 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 completo
// CapitalRepositoryImpl: la clase "real" con métodos estáticos
class CapitalRepositoryImpl {
static async list(pool: Pool, filters: CapitalFilters) { ... }
static async findById(pool: Pool, id: string) { ... }
// ...
}
// Export: el wrap
export const CapitalRepository = wrapStaticRepository(CapitalRepositoryImpl, {
label: "CapitalRepository",
slowQueryMs: 500,
});
// Type re-export para que los callers tengan los mismos tipos
export type CapitalRepository = typeof CapitalRepositoryImpl;

El consumidor importa CapitalRepository (el Proxy). Cada llamada como CapitalRepository.list(...) pasa por el Proxy → observability → método real.

WrapRepositoryOptions:

OpciónDefaultNotas
slowQueryMs5000 = desactiva el slow log.
retrytruefalse = no reintenta nunca.
maxRetries3Total de intentos (no reintentos adicionales).
retryDelayMs100Multiplicado por número de intento (backoff lineal).
labelNombre de la clase, o 'Repository' si no se puede inferir.Aparece en los logs.
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 723ms

Si 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.

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 PGSignificado
40001serialization_failure. Típico en ISOLATION LEVEL SERIALIZABLE.
40P01deadlock_detected. Dos TXs bloqueándose mutuamente.

Otros errores (constraint violations, syntax, timeouts) no reintentan — propagan al primer fallo.

sleep(retryDelayMs * attempt):

IntentoEspera previa (default retryDelayMs=100)
10 ms
2100 ms
3200 ms

Lineal — no exponencial. Total worst-case con defaults: 300ms de espera distribuida en 3 intentos.

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.

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.

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.

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.

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 });
RepositoryWrap
CapitalRepositoryslowQueryMs: 500.
Resto de repositoriesMayorí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.