Aller au contenu

Débloquer et relancer des jobs Graphile Worker

Workflow pour diagnostiquer et réparer les crons Graphile Worker en prod. Commencer impérativement par l'étape 0 : demander à l'user quel accès utiliser et ce qu'il autorise (lecture / mutations). Diagnostic = read-only ; toute mutation (DELETE / UPDATE lock / add_job) exige une validation humaine explicite — plusieurs crons déplacent de l'argent réel (capital repayment, management fees, payouts).

Contexte technique

  • Un seul worker (graphile-cron, service Railway, env prod, concurrency 20) exécute tous les crons. Une task qui crashe le process (ex : OOM) le fait crash-looper → plus aucun cron ne tourne jusqu'au redéploiement.
  • backfillPeriod: 0 sur tous les crons → un run manqué n'est jamais rejoué automatiquement. Il faut le ré-enfiler à la main (add_job).
  • Source de vérité des crons (identifiers, queues, schedules UTC) : projects/api/worker/graphile-cron.worker.ts. Config connexion/concurrency : projects/api/src/__new/lib/graphile-worker/graphile-worker.cron-config.ts.
  • Un cron désactivé dans le worker (task retirée du taskList, ex : taxation-monthly-summary) n'a plus de handler → add_job créerait un job que personne ne consomme. Ne pas rejouer un cron désactivé.

Tables internes (schéma graphile_worker)

Objet Rôle
jobs (vue) Lecture : jobs en attente/lockés/échoués (task_identifier, queue_name, locked_by, attempts, last_error)
_private_jobs Table réelle des jobs — cible des mutations (DELETE / release lock)
_private_job_queues Locks par queue — à nettoyer avec le job
_private_tasks Map task_identifiertask_id (jointure pour muter par identifier)
_private_known_crontabs last_execution par cron item — sert à repérer les crons manqués

Correspondance : task_identifier (dans jobs) = valeur de l'enum (ex funding-auto-close) · queue_name = <task_identifier>-queue · l'identifier dans _private_known_crontabs = <task_identifier>-task.

Étape 0 — Demander les autorisations à l'user (obligatoire, avant tout accès prod)

Ne rien lancer sur la prod sans avoir demandé explicitement à l'user (via AskQuestion) :

  1. Accès en lecture — quelle méthode/connexion utiliser pour lire la prod ? MCP prod readonly, une config Doppler qu'il nomme, une variable DATABASE_URL, ou une autre connexion psql qu'il fournit. Ne jamais présumer d'une config ou d'une identité perso, ni en réutiliser une vue dans un run précédent.
  2. Périmètre des mutations — quelles actions sont autorisées, s'il y en a ? Par défaut : aucune. Faire valider chaque type d'action séparément : redéploiement worker · release lock · kill job · re-enqueue (add_job) · crons 💰.

Contrainte à signaler à l'user : le rôle bricks_ro (MCP prod readonly) n'a pas les droits sur le schéma graphile_worker. Pour lire graphile_worker.*, il faut un accès avec des droits suffisants (creds master) — demander à l'user quelle méthode fournir.

Règle absolue : aucune mutation sans accord explicite, action par action — a fortiori sur un cron 💰 (argent réel). Re-demander avant chaque DELETE / UPDATE de lock / add_job, même si un accord global a déjà été donné.

Accès prod (selon la méthode validée à l'étape 0)

Adapter la connexion à la méthode que l'user a autorisée. Exemple si c'est une config Doppler (remplacer <doppler_config> par celle fournie par l'user — ne jamais hardcoder de config perso) :

cd projects/api && doppler run --project api --config <doppler_config> -- bash -c \
  'PGPASSWORD="$DATABASE_PASS" psql "host=$DATABASE_HOST port=$DATABASE_PORT dbname=$DATABASE_NAME user=$DATABASE_USER sslmode=require" -f /tmp/gw_query.sql'

Gotcha shell : psql -c "..." fait interpréter les $$ (ex add_job(... $$...$$) ou to_char(..., $$...$$)) par bash (substitution de PID) → SQL corrompu. Toujours écrire le SQL dans un fichier /tmp/*.sql puis psql -f.

Pour un diagnostic (lecture seule), mettre en tête du .sql la ceinture de sécurité :

SET default_transaction_read_only = on;

Logs worker (buffer récent uniquement → capturer dans un fichier avec kill temporisé) :

( railway logs -e prod -s graphile-cron & LP=$!; sleep 15; kill $LP 2>/dev/null ) > /tmp/gc_logs.txt 2>&1
grep -iE "finished in|success=|ERRO|25006|<task_identifier>" /tmp/gc_logs.txt

Étape 1 — Diagnostiquer

1a. Crons manqués (dernière exécution)

SELECT identifier,
       last_execution AT TIME ZONE 'UTC' AS last_execution_utc
FROM graphile_worker._private_known_crontabs
ORDER BY last_execution DESC NULLS LAST;

Comparer last_execution_utc au schedule attendu (table de référence en bas). Un last_execution figé sur tous les crons après une heure T = worker mort/figé depuis T.

1b. Jobs lockés / bloqués / en échec

SELECT id, task_identifier, queue_name,
       run_at     AT TIME ZONE 'UTC' AS run_at_utc,
       locked_at  AT TIME ZONE 'UTC' AS locked_at_utc,
       locked_by, attempts, max_attempts, last_error
FROM graphile_worker.jobs
ORDER BY locked_at NULLS LAST, run_at;
  • locked_by renseigné + locked_at ancien + le worker n'apparaît plus dans les logs → lock orphelin (worker mort). Le lock expire seul (~4h) mais on peut le libérer.
  • attempts > 0 + last_error → job en échec, retry auto avec backoff (jusqu'à max_attempts, souvent 25).
  • Un même job relocké en boucle à intervalle court = task poison qui crashe le worker à chaque tentative.

1c. État read/write de la base (blip Neon)

Une erreur 25006 / PreventCommandIfReadOnly (« cannot execute … in a read-only transaction ») dans les logs = la base était en read-only transitoire (autoscale/failover Neon). Le job peut avoir réussi sa logique métier puis échoué la complétion (UPDATE de graphile) → il reste locké alors que le travail est fait.

SELECT pg_is_in_recovery() AS is_replica,
       current_setting('transaction_read_only')          AS txn_ro,
       current_setting('default_transaction_read_only')  AS default_ro;
-- vérifier qu'aucun rôle/base ne force le read-only de façon persistante :
SELECT r.rolname AS role, d.datname AS db, s.setconfig
FROM pg_db_role_setting s
LEFT JOIN pg_roles r    ON r.oid = s.setrole
LEFT JOIN pg_database d ON d.oid = s.setdatabase;

Tout à off/absent = blip transitoire déjà résorbé (pas un réglage persistant).

Causes racines fréquentes

Symptôme Cause Remède
Aucun cron ne tourne depuis une heure T, un job relocké en boucle Task poison (OOM, throw fatal) qui crashe le worker Neutraliser le job poison (2b) + redéployer (2a) + désactiver la task côté code
Job locké mais son travail est « finished/success » dans les logs, 25006 juste après Blip read-only Neon pendant la complétion Libérer le lock (2c) — il se recomplète au retry (idempotent)
Job avec last_error, attempts qui monte Erreur métier/transiente Laisser le retry auto ; investiguer last_error ; forcer un retry immédiat (2c) si besoin
Cron manqué (worker était down à l'heure du run) backfillPeriod: 0, pas de rattrapage Ré-enfiler avec add_job (étape 3)

Étape 2 — Débloquer

Vérifier d'abord que le worker est remonté et sain (logs récents avec finished in / success=true), sinon les jobs relancés se reBloqueront.

2a. Redémarrer / redéployer le worker

Restart ou redeploy du service graphile-cron (env prod) via Railway. Un restart réinitialise les locks in-process : les jobs lockés par l'ancien process redeviennent disponibles.

2b. Neutraliser un job poison (DELETE + release lock)

Pour un job qui crashe le worker et qu'on ne veut pas rejouer (ex : cron déjà désactivé côté code). Écrire dans /tmp/gw_kill.sql puis psql -f :

\set ON_ERROR_STOP on
BEGIN;
DELETE FROM graphile_worker._private_jobs j
USING graphile_worker._private_tasks t
WHERE j.task_id = t.id AND t.identifier = '<task_identifier>'
RETURNING j.id, t.identifier, j.locked_by, j.attempts;

UPDATE graphile_worker._private_job_queues
SET locked_at = NULL, locked_by = NULL
WHERE queue_name = '<task_identifier>-queue'
RETURNING queue_name;
COMMIT;

2c. Libérer un lock bloqué (retry, sans supprimer)

Pour un job dont le travail doit se terminer/reprendre (lock orphelin ou blocage post-25006). Le rend disponible immédiatement :

\set ON_ERROR_STOP on
BEGIN;
UPDATE graphile_worker._private_jobs j
SET locked_at = NULL, locked_by = NULL, run_at = now()
FROM graphile_worker._private_tasks t
WHERE j.task_id = t.id
  AND t.identifier = '<task_identifier>'
  AND j.locked_by IS NOT NULL
RETURNING j.id, t.identifier, j.attempts;

UPDATE graphile_worker._private_job_queues
SET locked_at = NULL, locked_by = NULL
WHERE queue_name = '<task_identifier>-queue'
RETURNING queue_name;
COMMIT;

Vérifier l'idempotence avant de relâcher un cron money : s'il a déjà bougé de l'argent, un rerun peut doubler. Un run finished in 00:00 = no-op, sûr à relâcher.

Étape 3 — Rejouer un cron manqué (add_job)

Nécessaire car backfillPeriod: 0. Uniquement pour une task encore enregistrée dans le worker. Payload {} (le wrapper createCronTask ignore le payload). Écrire dans /tmp/gw_replay.sql puis psql -f :

\set ON_ERROR_STOP on
SELECT (graphile_worker.add_job(
  '<task_identifier>',
  payload := '{}'::json,
  queue_name := '<task_identifier>-queue'
)).id;

Avant de rejouer un cron qui déplace de l'argent (investor-payout-capital-repayment, echeancier-transfer-management-fees-of-past-echeances, investor-boosted-balance-gain-payout, referral-primary-purchase-auto-payout, funding-auto-close) : confirmer explicitement l'impact avec un humain et vérifier que la task est idempotente (skip des lignes déjà traitées).

Étape 4 — Vérifier

SELECT id, task_identifier,
       locked_at AT TIME ZONE 'UTC' AS locked_utc,
       attempts, last_error
FROM graphile_worker.jobs
WHERE task_identifier = ANY (ARRAY['<task_1>', '<task_2>'])
ORDER BY id;
  • Ligne disparue = job terminé (graphile supprime les jobs réussis).
  • Ligne toujours lockée = en cours (jobs money longs : capital-repayment ~min).
  • Confirmer côté logs : <task> finished in mm:ss + success=true.

Référence des crons (UTC)

Tous : queue_name = <task_identifier>-queue, identifier (known_crontabs) = <task_identifier>-task, backfillPeriod: 0. 💰 = déplace de l'argent réel.

task_identifier Schedule (cron UTC)
sync-lw-wires 0 */3 * * *
sync-lw-card-waiting 0 */3 * * *
sync-lw-investor-iban-status 0 3 * * *
sync-lw-investor-identity-documents 0 3 * * *
sync-lw-investor-status 0 5 * * *
sync-lw-onboarding-v2-status 0 4 * * *
sync-lw-withdraws-declined 0 3 * * *
sync-lw-create-waiting-withdraw-at-lw */30 * * * *
echeancier-bank-debit-status-update 0 11 * * *
echeancier-program-echeances-of-month 30 0 15 * *
echeancier-send-echeance-reminder-email-to-pdp 0 8 1 * *
💰 echeancier-transfer-management-fees-of-past-echeances 0 8 * * *
echeancier-duration-extension 0 7 * * *
💰 investor-boosted-balance-gain-payout 0 0 * * *
💰 investor-payout-capital-repayment 0 8 * * *
investor-automatic-funding-cancel-expired-purchase 30 0 * * *
referrals-waiting-invoice-alert 0 8 1 1,4,7,10 *
💰 referral-primary-purchase-auto-payout 0 7 * * *
neondb-scale-for-funding-upscale 30 9 * * *
neondb-scale-for-funding-downscale-1 30 14 * * *
neondb-scale-for-funding-downscale-2 30 21 * * *
neondb-weekly-compute-restart 0 1 * * 1
sync-checkout-topup-report 0 */6 * * *
sync-customers-blocked-at-lemonway 0 6 * * *
💰 funding-auto-close 30 7 * * *
refresh-leaderboard 0 3 * * *

Toujours re-vérifier cette liste contre projects/api/worker/graphile-cron.worker.ts — elle est la source de vérité et peut évoluer.