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, envprod, 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: 0sur 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_jobcré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_identifier → task_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) :
- 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 connexionpsqlqu'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. - 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é :
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_byrenseigné +locked_atancien + 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.