Sincronizar sin romper nada: cómo unificamos Google Contacts, LDAP y Chatwoot en producción

 

Infraestructura & Automatización

Sincronizar sin romper nada: cómo unificamos Google Contacts, LDAP y Chatwoot en producción

Un repaso técnico, con código real, de cómo diseñamos —y depuramos en vivo— un pipeline de sincronización de contactos multi-cuenta sin interrumpir ni un solo minuto la atención a clientes.

Categoría: Infraestructura & Sistemas
Tiempo de lectura: 8 minutos
Stack: n8n · PostgreSQL · Docker

Todo equipo que crece termina con el mismo problema: los mismos contactos (clientes, proveedores, revendedores) viven repartidos en varios sistemas que no se hablan entre sí. En CAB Group encaramos ese problema de frente, automatizando la sincronización entre nuestras libretas de Google Contacts, el directorio LDAP que usa nuestra telefonía IP, y Chatwoot, la herramienta que usamos para la atención al cliente. Lo que sigue es el relato técnico de cómo lo construimos, qué se rompió en el camino, y cómo lo arreglamos sin que nuestros clientes lo notaran.

01El problema: tres libretas, un solo directorio real

Manteníamos distintas categorías de contactos (equipo interno, canal de revendedores, y clientes finales) en libretas separadas de Google Contacts. Cada una necesitaba llegar a dos lugares más: al LDAP corporativo, para que los teléfonos IP muestren el nombre correcto en cada llamada, y a Chatwoot, para que el equipo de soporte tenga el contexto completo de con quién está hablando. Necesitábamos un solo proceso automático, corriendo sobre n8n, que tomara Google Contacts como única fuente de verdad y empujara los cambios hacia los otros dos sistemas de forma confiable.

Google Contacts (3 libretas)
┌─────────────┬─────────────┬─────────────┐
│Administracion│ Resellers │ Clientes │
└──────┬──────┴──────┬──────┴──────┬──────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────┐
│ n8n (orquestador de sincronización) │
└───────┬───────────────────┬──────────────┘
│ │
▼ ▼
┌───────────────┐ ┌───────────────┐
│ LDAP (voip) │ │ Chatwoot │
└───────────────┘ └───────┬───────┘


┌───────────────┐
│ PostgreSQL │
│ (directorio │
│ interno) │
└───────────────┘

Diagrama simplificado del flujo: una sola fuente de verdad, tres destinos.

02La restricción real: no hay entorno de pruebas para «producción»

El orquestador que corre esta sincronización es el mismo que atiende, en tiempo real, las conversaciones de nuestros bots de soporte. Eso significa que cualquier proceso pesado —traer y procesar cientos de contactos de una sola vez— compite por memoria y CPU con el tráfico real de clientes. No podíamos «probar en productivo y ver qué pasa»: cualquier corrida que se pasara de recursos podía tirar abajo el proceso que sostiene la atención al cliente.

Regla de oro que nos impusimos
Ningún cambio de capacidad se resuelve editando algo dentro de la imagen de Docker de la aplicación (eso se pierde en la próxima actualización). Todo ajuste de recursos se hace por configuración externa versionada, y cada cambio de infraestructura se prueba primero de a un contenedor por vez, verificando salud antes de seguir con el siguiente.
03Encontrar el techo real (no el que sospechábamos)

La primera hipótesis frente a los cuelgues de memoria fue ajustar una variable de entorno pensada justamente para eso. La subimos varias veces, sin resultado — el límite de memoria real no se movía un milímetro. Investigando el proceso de arranque interno del motor de ejecución, encontramos la causa real: esa variable ni siquiera llegaba a aplicarse (el lanzador interno filtra qué variables reenvía al proceso, y esa no estaba en la lista). El techo real lo pone el límite de memoria del propio contenedor Docker, porque el motor de JavaScript autoajusta su heap a un porcentaje de esa memoria disponible.

La solución no fue «una variable mágica», sino tocar el límite real, por configuración externa, con un patrón que evita repetir la misma configuración en cada servicio:

docker-compose.yml
# Un solo bloque de recursos, compartido por todos los workers.
# Subir la memoria acá alcanza para todos los servicios que lo referencian.
x-worker-common: &worker-common
  deploy:
    resources:
      limits:
        memory: 1536M   # antes

services:
  worker-1:
    <<: *worker-common
  worker-2:
    <<: *worker-common
  worker-3:
    <<: *worker-common
  worker-4:
    <<: *worker-common

Con este patrón, subir el límite es cambiar una sola línea del ancla compartida — y como cada cambio de infraestructura se probó reiniciando los contenedores de a uno (con chequeo de salud entre cada reinicio), pudimos aplicarlo sin ningún corte de servicio.

04Paginar en vez de «traer todo de una»

La cuenta más grande de contactos seguía sin poder sincronizarse de punta a punta, incluso con más memoria disponible: pedía toda su página de contactos (hasta mil registros) en un solo pedido a la API, y esos datos crudos —más su versión ya procesada— convivían en memoria durante toda la ejecución. La solución de fondo no fue «más memoria», fue rediseñar el pedido para que nunca sostenga más de un puñado de contactos a la vez.

Intentamos primero resolverlo con un loop interno dentro de un único nodo de código, pero el motor de automatización no permite hacer llamadas autenticadas a APIs externas desde ese tipo de nodo (una limitación real que descubrimos con una prueba aislada, sin tocar nada productivo). Así que en vez de paginar dentro de una ejecución, pasamos a paginar entre corridas programadas: cada corrida trae un lote acotado y guarda en base dónde se quedó, para que la próxima retome sola.

construir-pedido-pagina.js
// Se arma el pedido combinando: tamaño de página acotado,
// más el token de sincronización o de página, si ya existen.
JSON.stringify(Object.assign(
  {
    personFields: 'names,phoneNumbers,emailAddresses,organizations',
    pageSize: 500,          // nunca más de esto por corrida
    requestSyncToken: true
  },
  (syncToken ? { syncToken } : {}),
  (pageToken ? { pageToken } : {})
));

Fragmento simplificado de la construcción del pedido a la API de contactos.

syncTokenIdentifica un conjunto de cambios ya sincronizado. Se guarda solo al llegar a la última página.pageTokenMarca dónde quedó una recorrida en curso. Se guarda entre corridas, hasta terminar el recorrido inicial.

El resultado: cada corrida programada procesa un lote chico y acotado, nunca compite en memoria consigo misma, y una cuenta que antes no lograba sincronizar ni una vez completó su primer recorrido en un puñado de corridas sucesivas — sin tocar el límite de memoria más allá del ajuste moderado del punto anterior.

05El bug de una sola línea que se comía el 90% de los datos

Ya con el pipeline corriendo de punta a punta, hicimos una verificación cruzada contra los tres sistemas (no nos alcanzó con ver «ejecución exitosa»). Encontramos que el directorio interno y el LDAP quedaban perfectos, pero Chatwoot solo recibía 1 de cada 10 contactos nuevos por corrida. La causa: un nodo de código que decide «¿este contacto ya existe o hay que crearlo?» corría en el modo por defecto del motor de automatización — que ejecuta el código una sola vez para todo el lote — pero el código adentro solo miraba el primer ítem del lote, descartando el resto en silencio.

antes.js — el bug
// $json en este modo apunta SOLO al primer ítem de entrada.
// Si llegan 10 contactos, los otros 9 nunca se evalúan.
const searchResp = $json;
const orig = $('Preparar Contacto').item.json;
// ... arma UN solo resultado ...
return [{ json: { existeMatch, matchedContactId } }];
despues.js — el fix
// Se recorre explícitamente CADA ítem de entrada,
// devolviendo un resultado por cada uno, en el mismo orden.
const origItems = $('Preparar Contacto').all();
const searchItems = $input.all();
const out = [];
for (let i = 0; i < searchItems.length; i++) {
  const searchResp = searchItems[i].json;
  const orig = origItems[i].json;
  // ... misma lógica de siempre, ahora aplicada a cada uno ...
  out.push({ json: { existeMatch, matchedContactId } });
}
return out;
La lección detrás del bug
No es un error exótico: es el gotcha más común de los nodos de código en motores de automatización low-code. Si el nodo corre «una vez para todo el lote», $json no es «todos los ítems» — es el primero. Verificar esto con datos reales de más de un ítem por lote debería ser parte de cualquier checklist de pruebas antes de dar por buena una automatización.
06Resultados
Indicador Antes Después
Contactos sincronizados a Chatwoot por lote de 10 pendientes 1 10
Cuentas de contactos sincronizando de punta a punta 2 de 3 3 de 3
Incidentes visibles para clientes durante todo el trabajo 0
Cambios de infraestructura aplicados en caliente sin caídas Varios, de a uno por vez
07Lo que nos llevamos
  • Un «success» en el log no confirma que los tres sistemas de destino recibieron los datos — hay que verificar cruzado, contra la base real, no solo contra el estado de la ejecución.
  • Los techos de memoria «misteriosos» casi siempre tienen una causa aburrida y verificable: leer cómo arranca el proceso, no solo ajustar variables por prueba y error.
  • Paginar en origen (pedir de a poco) resuelve problemas de memoria de raíz, mejor que escalar recursos como primera respuesta.
  • Todo cambio de infraestructura compartida con producción se prueba de a un componente por vez, con verificación de salud entre cada paso.
  • Los nodos de código en plataformas low-code esconden un modo de ejecución «por lote completo» que es fácil de usar mal — probarlo siempre con más de un ítem antes de confiar en él.

Si tu organización enfrenta un desafío parecido —sistemas que deberían hablar entre sí pero no lo hacen, o automatizaciones que «andan pero no confiás en ellas»— en CAB Group diseñamos e implementamos este tipo de integraciones a medida, con foco en no arriesgar la operación en el camino.