L’article Le lease en production : ce que gocron avale traite de l’échec de verrou silencieux dans gocron. Celui-ci porte sur la libération du bail, modifiée après deux mois de production, et sur une erreur de l’article Quand l’advisory lock ne suffit plus : passer à un lease.

Contexte : une plateforme SaaS, plusieurs services, chacun en plusieurs replicas. Un bail en table, posé par une bibliothèque interne, réserve chaque job à un replica.

Expirer le bail en place au lieu de le supprimer

La bibliothèque libérait le bail par un DELETE : chaque tick supprimait la ligne puis la réinsérait. Selon le commit du changement, ce cycle vide puis remplit le stockage de la table. Il provoque une troncature puis une ré-extension du fichier, donc de l’E/S physique. Le gain n’a pas été mesuré.

La documentation PostgreSQL décrit le mécanisme. UPDATE et DELETE laissent chacun une version morte de la ligne jusqu’au passage de VACUUM. VACUUM ne rend l’espace au système d’exploitation que si des pages en fin de table sont entièrement vides, et seulement s’il obtient un verrou exclusif[1]. La troncature dépend de vacuum_truncate, qui vaut true par défaut et demande un verrou ACCESS EXCLUSIVE. Depuis PostgreSQL 18, c’est aussi un paramètre serveur ; il existait déjà comme paramètre de stockage par table[2][3].

L’UPDATE crée aussi des versions mortes. L’hypothèse est que la table, qui conserve sa ligne, ne se vide plus entre deux ticks. Elle découle de la documentation, pas d’une mesure.

La purge des clés mortes

Conserver les lignes a une contrepartie : un service crée une clé par job, et les clés des jobs supprimés s’accumulent. En production, la migration de démarrage purge celles dont le bail a expiré depuis plus de 24 h. Le dépôt d’exemple fait de même dans Migrate, sous le verrou consultatif de la migration. Extrait réduit de internal/lease/schema.go :

const Retention = 24 * time.Hour

const purgeQuery = `
DELETE FROM lease
 WHERE expires_at < clock_timestamp() - make_interval(secs => $1)`

Les options écartées

Ligne pré-créée et claim en UPDATE pur

Sans ligne affectée, le claim ne distingue pas un bail détenu par un autre replica d’une ligne absente, et les deux finiraient en verrou déjà détenu.
Ce cas étant exclu du log (voir Le lease en production : ce que gocron avale), un job dont la ligne manque cesserait de tourner sans aucun signal.

Troncature par VACUUM désactivée

ALTER TABLE ... SET (vacuum_truncate = off)

ALTER TABLE exige d’être propriétaire de la table[4], ce que la bibliothèque n’est sur aucun environnement.

L’horloge du serveur

Le claim et le heartbeat de la bibliothèque utilisent clock_timestamp() plutôt que now(), comme le préconisait déjà l’article précédent. now() renvoie l’heure de début de transaction, alors que clock_timestamp() change au sein d’une même instruction[5]. Un INSERT attendu huit secondes posait, avec now(), un bail expirant huit secondes trop tôt.

Erratum : le fence n’identifiait pas le mandat

Erratum sur l'article précédent

Dans l'article précédent et dans son dépôt d'exemple, la colonne fence a DEFAULT 1 et la libération est un DELETE. Le fence repart à 1 après chaque libération propre : le fencing aval WHERE last_fence < $1 rejette alors le porteur légitime, puisque 1 < 1 est faux.

De plus, un claim réentrant du même holder gardait le même fence. Une libération tardive du mandat N, de même holder et de même fence, atteignait alors le bail du mandat N+1. L'affirmation selon laquelle le fence « ferme le dernier interstice » est fausse. Un test ajouté au dépôt échoue avec l'ancien code. Aucune probabilité n'est établie pour la production.

Deux corrections suivent. La réentrance de l’article précédent change : un claim réentrant fait désormais avancer le fence.

Libération tardive du mandat N après la revendication réentrante du mandat N+1 : fence k contre fence k+1Claim réentrant sans avancer le fencemandat N, fence kmandat N+1, fence k (claim réentrant)libération tardive de N : holder et fence k identiquesfait expirer le bail de N+1Claim qui avance le fencemandat N, fence kmandat N+1, fence k+1libération tardive de N : fence k, la ligne est à k+10 ligne affectée, le bail de N+1 est intact
La même libération tardive du mandat N, selon que le claim réentrant de N+1 garde le fence ou l'avance.

Libérer par expiration, sans supprimer la ligne

La libération fait expirer le bail au lieu de le supprimer. La ligne survit, et le claim suivant incrémente le fence. Extrait de internal/lease/lease.go dans le dépôt d’exemple :

const releaseQuery = `
UPDATE lease
   SET expires_at = clock_timestamp()
 WHERE key    = $1
   AND holder = $2
   AND fence  = $3
   AND expires_at > clock_timestamp()`

La requête n’ajoute pas - interval '1 second'. Avec le CHECK (expires_at > acquired_at), la libération d’un job de moins d’une seconde violerait la contrainte.

Avancer le fence à chaque revendication

Le claim fait avancer le fence, réentrance comprise. holder identifie le replica, fence identifie le mandat.

const claimQuery = `
INSERT INTO lease (key, holder, expires_at)
VALUES ($1, $2, clock_timestamp() + make_interval(secs => $3))
ON CONFLICT (key) DO UPDATE
    SET holder      = EXCLUDED.holder,
        expires_at  = EXCLUDED.expires_at,
        acquired_at = EXCLUDED.acquired_at,
        fence       = lease.fence + 1
WHERE lease.holder = EXCLUDED.holder OR lease.expires_at <= clock_timestamp()
RETURNING fence, expires_at`

acquired_at vient de la ligne proposée, évaluée avec expires_at avant toute attente du verrou de ligne. Avec clock_timestamp() dans le DO UPDATE, un claim qui attend plus longtemps que la durée du bail viole le CHECK (SQLSTATE 23514).

Le fence devient monotone par clé, condition du fencing aval[6]. Une libération ou un heartbeat du mandat N porte fence = k alors que la ligne est à k + 1 : aucune ligne n’est touchée. Ce test le démontre :

func TestLateReleaseOfPreviousMandateLeavesTheReclaimedLeaseUntouched(t *testing.T) {
	ctx, job := t.Context(), jobKey(t)
	db := migratedPool(t)
	locker := NewLocker(db, WithHolder("replica-1"))

	first := claim(t, locker, job)
	second := claim(t, locker, job)

	assert.ErrorIs(t, first.Unlock(ctx), ErrLost)
	assert.Equal(t, second.Fence(), currentFence(t, db, job))
	assert.True(t, isLive(t, db, job))
}

Avec l’ancien code, il échoue. Sortie réduite : les lignes Error Trace et Test sont retirées.

--- FAIL: TestLateReleaseOfPreviousMandateLeavesTheReclaimedLeaseUntouched (0.02s)
    lease_test.go:108: 
        	Error:      	Expected error with "lease lost to another holder" in chain but got nil.
    lease_test.go:110: 
        	Error:      	Should be true

Une clé purgée puis réutilisée repart à 1. Une monotonie globale demande une séquence (nextval) à l’insertion et dans le DO UPDATE.

Une identité par mandat quand le schéma est figé

La bibliothèque interne ne peut pas ajouter de colonne fence.
ALTER TABLE exige d’être propriétaire de la table.
Une table v2 suspendrait l’exclusion pendant le rolling deploy.

Elle forge donc un identifiant de porteur à chaque revendication : l’identité du replica, suivie d’un suffixe aléatoire. Le claim, le heartbeat et la libération de ce mandat utilisent tous cet identifiant. La colonne du porteur est un TEXT libre : aucun changement de schéma n’est nécessaire.

Le besoin est une égalité : « est-ce encore mon mandat ? ».
Aucun consommateur aval ne pose de question d’ordre. Depuis que la libération conserve la ligne, un heartbeat bloqué côté serveur plus longtemps qu’un mandat pourrait réarmer 60 s sur la clé relâchée par le mandat suivant.
Le risque se limite à un réarmement.

La bibliothèque, sans colonne fence, forge un holder par mandat.
Le dépôt, qui a une colonne fence, fait porter le mandat par le fence.
Son holder reste l’identité du replica.

Conclusion

La libération par expiration en place conserve la ligne, et avec elle le fence. Le claim incrémente le fence à chaque revendication, réentrance comprise. Une libération ou un heartbeat d’un mandat précédent ne touche alors plus aucune ligne.

La purge au démarrage limite l’accumulation des clés, mais le fence d’une clé purgée repart à 1. Quand le schéma ne peut pas évoluer, un identifiant de porteur par mandat répond au même besoin : une égalité, sans relation d’ordre.

Ressources

  1. PostgreSQL, Routine Vacuuming.
  2. PostgreSQL, Vacuuming, paramètre vacuum_truncate.
  3. PostgreSQL, notes de version 18 : ajout de la variable serveur vacuum_truncate.
  4. PostgreSQL, ALTER TABLE : « You must own the table to use ALTER TABLE ».
  5. PostgreSQL, Date/Time Functions, section sur clock_timestamp() et now().
  6. Martin Kleppmann, How to do distributed locking, 2016 : le jeton doit croître à chaque acquisition.