ShedLock : l’allié de choix pour vos tâches planifiées Spring

Dès lors que l’on commence à déployer une application sur plusieurs instances afin d’assurer une haute disponibilité ou d’affronter des pics de charge, il faut s’assurer que l’architecture de celle-ci ne pose pas de problème. Il est courant que les applications embarquent des tâches planifiées, annotées @Scheduled dans une application Spring. Celles-ci risquent alors de rentrer en conflit si elles sont exécutées sur plusieurs nœuds en parallèle. En effet, la plupart du temps, on veut seulement que le traitement s’exécute sur une seule de ces instances. Pour cela, la librairie ShedLock est un allié de choix.

Contexte d’utilisation

Des tâches planifiées typiques peuvent être :

  • Détecter des fichiers afin de les intégrer dans une base de données ou un moteur de recherche.
  • Calculer des indicateurs.
  • Purger des anciennes données.
  • Effectuer des synchronisations avec d’autres systèmes.
  • Mettre à jour des caches de données.

Prenons l’exemple du déploiement d’une telle application, illustré par le schéma suivant :

Si l’on ne peut pas s’assurer que seulement une des instances effectue le traitement planifié, alors nos traitements planifiés s’exécutent simultanément. Il y a donc un risque de conflit et de corruption de données.

Spring ne fournit pas en standard de mécanisme permettant de limiter à une seule exécution le traitement, c’est donc là qu’intervient ShedLock.

Principe de fonctionnement

ShedLock s’appuie sur la présence d’une solution de stockage de données qui peut être par exemple une base de données, un cache ou un moteur de recherche et qui va servir d’outil de synchronisation sur lequel on va déposer des verrous. Dès lors qu’une des instances a acquis le verrou, les autres instances vont simplement passer leur tour et retenter d’acquérir le verrou lors de la prochaine planification de la tâche. Le traitement planifié se déroule ainsi à chaque fois sur une instance choisie “aléatoirement” (celle qui a réussi à acquérir le verrou). Cela permet de pallier l’éventuelle indisponibilité d’une instance, assurant la haute disponibilité et la tolérance à la panne de ce traitement.

Ce mécanisme ressemble beaucoup à celui utilisé par les outils de gestion de bases de données comme Liquibase ou Flyway qui s’assurent avant d’effectuer leurs changements qu’il n’y a pas d’autres changements en cours. ShedLock permet de disposer de ce fonctionnement et de l’appliquer à nos propres applications très facilement.

ShedLock est par ailleurs conçu de manière extensible afin de pouvoir s’adapter aux différents contextes d’utilisation. Ainsi, il est découpé en 3 parties :

  • Core : fournit les bases du mécanisme de verrouillage.
  • Integration : permet de s’intégrer avec Spring et Micronaut grâce à leurs mécanismes d’AOP ou encore manuellement.
  • Providers : un ensemble de connecteurs s’interfaçant avec les différentes solutions de stockage de données.

Une quinzaine de connecteurs sont fournis de base, dont le plus classique permettant d’utiliser un driver JDBC pour s’interfacer avec notre base de données relationnelle favorite. Un connecteur implémente l’interface LockProvider permettant d’implémenter l’obtention d’un verrou auprès notre système de stockage de données.

Mise en place

Dans une application Spring Boot utilisant PostgreSQL, la mise en place est extrêmement simple et se déroule en 4 temps :

  1. Ajout de la librairie à l’application
  2. Création de la table des verrous
  3. Activation de ShedLock
  4. Utilisation au niveau d’une tâche planifiée

1. Ajout de la dépendance dans le pom.xml pour un projet maven

Transitivement, les dépendances spring-jdbc et shedlock-core sont également importées.

<dependency>
    <groupId>net.javacrumbs.shedlock</groupId>
    <artifactId>shedlock-provider-jdbc-template</artifactId>
    <version>4.30.0</version>
</dependency>

2. Création de la table des verrous

En utilisant soit un outil de gestion de base de données comme Liquibase.

<?xml version="1.0" encoding="utf-8"?>
<databaseChangeLog
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
       xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.6.xsd">
 
   <changeSet id="1" author="">
       <createTable tableName="shedlock">
           <column name="name" type="VARCHAR(64)">
               <constraints nullable="false" primarykey="true"/>
           </column>
           <column name="lock_until" type="TIMESTAMP">
               <constraints nullable="false"/>
           </column>
           <column name="locked_at" type="TIMESTAMP">
               <constraints nullable="false"/>
           </column>
           <column name="locked_by" type="VARCHAR(255)">
               <constraints nullable="false"/>
           </column>
       </createTable>
   </changeSet>
</databaseChangeLog>

Soit en créant manuellement la table.

CREATE TABLE shedlock (
  name varchar(64) NOT NULL,
  lock_until timestamp NOT NULL,
  locked_at timestamp NOT NULL,
  locked_by varchar(255) NOT NULL,
  PRIMARY KEY (name)
);

3. Activation de ShedLock

Pour activer ShedLock on utilise l’annotation @EnableSchedulerLock pour laquelle on définit un temps maximum par défaut de verrouillage. Cela permet de ne pas avoir à subir un problème qui survient parfois lors de l’utilisation de Liquibase, à savoir le fait d’empêcher indéfiniment le démarrage d’une application, car un verrou est posé et jamais relâché, suite à l’arrêt imprévu de l’application.

@Configuration
@EnableSchedulerLock(defaultLockAtMostFor = "PT15M")
public class ShedlockConfiguration {
 
   @Bean
   public LockProvider lockProvider(DataSource dataSource) {
       return new JdbcTemplateLockProvider(dataSource);
   }
}

Les formats de durée supportés sont les suivants :

  • Durée+unité : 1s, 5ms, 5m, 1d
  • En millisecondes : 100
  • Standard ISO-8601 : PT15M (15 minutes, voir la documentation de Duration.parse())

On définit également le LockProvider, ici un JdbcTemplateLockProvider qui a uniquement besoin d’une DataSource pour être instancié. Ce LockProvider permet de définir comment les verrous vont être gérés : dans notre cas, en utilisant un driver JDBC pour accéder à une base de données relationnelle PostgreSQL.

4. Utilisation au niveau d’une tâche planifiée

Il ne reste plus qu’à modifier les tâches planifiées existantes en ajoutant l’annotation @SchedulerLock. Il faut choisir un nom au verrou utilisé pour notre tâche. Si vous êtes en manque d’inspiration, le nommage le plus classique consiste à prendre le nom de la méthode accolé au nom de la classe afin de générer un identifiant unique. Cet identifiant n’est pas généré, car en cas de renommage de la méthode et d’un déploiement sur une partie du cluster, il pourrait alors y avoir 2 noms utilisés en même temps.

@Scheduled(fixedDelay = 300_000)
@SchedulerLock(name = "Tasks.importFiles", lockAtMostFor = "PT15M")
public void importFiles() {
       LockAssert.assertLocked();
       // …
       // Traitement de la tâche
       // …
}

Il est recommandé d’appeler LockAssert.assertLocked(); avant de lancer le traitement. Cela permet de s’assurer que ShedLock est bien configuré et qu’un verrou est effectivement en cours d’utilisation, ce qui a pour but de parer aux éventuelles erreurs de configuration.

Table de verrouillage

Nous allons considérer l’utilisation du connecteur JDBC pour obtenir des verrous auprès d’une instance PostgreSQL.

La table de verrouillage standard de ShedLock possède les colonnes suivantes :

  • name : Le nom du verrou. Celui-ci doit être unique pour chaque traitement planifié.
  • lock_until : Le timestamp indiquant jusqu’à quand, au maximum, le verrou peut rester acquis.
  • locked_at : Le timestamp d’acquisition du verrou.
  • locked_by : Un identifiant du nœud qui a acquis le verrou.


Lors de la première utilisation, ShedLock va insérer une ligne pour chaque tâche planifiée dans cette table de verrouillage. Ces lignes ne seront par la suite jamais supprimées.

Pour verrouiller un traitement, une valeur dans le futur va être positionnée dans la colonne lock_until. Lorsqu’une tâche planifiée veut acquérir le verrou, celle-ci va tenter de mettre à jour la ligne en base de données si lock_until <= now() (où now() est le timestamp correspondant à l’instant présent pour lequel on teste la disponibilité du verrou). La nouvelle valeur de lock_until est alors now() + lockAtMostFor. Au déverrouillage, lock_until est positionné à now() ce qui permet de rendre le verrou à nouveau disponible.


Dans le cas de tâches planifiées très courtes, l’option lockAtLeastFor sera très utile. Elle permet de s’assurer que le verrou ne sera pas relâché trop vite. En effet, ShedLock s’appuyant sur des timestamps, si les nœuds ont une différence d’horloge plus importante que le temps de traitement de la tâche alors il y a un risque d’avoir plusieurs exécutions de la même tâche. Avec l’utilisation de lockAtLeastFor, la valeur de lock_until en fin de traitement est positionnée à locked_at + lockAtLeastFor quand la tâche a pris moins de temps que lockAtLeastFor.


Prenons un exemple d’une tâche qui démarre à 11 h 00 avec la configuration suivante.

@Scheduled(delay = 300_000)
@SchedulerLock(name = "Tasks.shortTask", lockAtMostFor = "30s", lockAtLeastFor = "10s")
public void shortTask() {
  System.out.println("Starting short task");
}

Cas d’une tâche très rapide durant 299 ms :

  • La tâche démarre à 11:00:00.000
  • La tâche se finit à 11:00:00.299
  • Lors du déverrouillage, ShedLock positionne lock_until à 11:00:10.000 plutôt qu’à now() qui est l’heure de fin de traitement.


Cas d’une tâche “normale” durant 12 s :

  • La tâche démarre à 11:00:00.000
  • La tâche se finit à 11:00:12.000
  • Lors du déverrouillage, ShedLock positionne lock_until à 11:00:12.000 (now()) car le traitement a pris plus de temps que lockAtLeastFor.

Points d’attention

Il est important de disposer de sources de temps correctement synchronisées (comme souvent dans les systèmes distribués). Dans le cas contraire, une différence de temps trop importante empêchera ShedLock de remplir son rôle. Si l’horloge de nos serveurs n’est pas fiable, il est possible de le configurer pour qu’il utilise l’horloge de la base de données à la place de celle des serveurs de l’application.

@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "PT15M")
public class ShedLockConfig {
 
  @Bean
  public LockProvider lockProvider(DataSource dataSource) {
    return new JdbcTemplateLockProvider(
      JdbcTemplateLockProvider.Configuration.builder()
        .withJdbcTemplate(new JdbcTemplate(dataSource))
        .usingDbTime()
        .build()
    );
  }
}

Par ailleurs, ShedLock n’est pas magique, il faut bien s’assurer avant de rendre la main en fin de traitement en n’ayant plus de traitements en cours. Par exemple : si la tâche planifiée répartit son travail en le confiant à un pool de threads, il faudra bien s’assurer que le pool de threads n’ait plus de tâche en cours avant de rendre la main. Il faut également assurer une gestion des exceptions du traitement planifié sous peine de ne pas déverrouiller celui-ci.

Il convient également de noter que ShedLock n’est pas indiqué si on cherche à distribuer un traitement sur plusieurs nœuds. C’est un point indiqué clairement dans la documentation. Il faudra alors se tourner vers d’autres solutions : utiliser un autre outil ou changer l’architecture du traitement.

En bref

Nous avons vu que ShedLock répondait à un besoin courant d’exécuter une tâche planifiée sur un seul nœud à la fois en tirant parti du système de stockage de données déjà présent au sein d’une application. ShedLock fournissant un nombre important de connecteurs, il peut être intégré à une grande variété d’applications et donc certainement à la vôtre. Nous avons également pu voir qu’il s’intégrait très facilement dans une application utilisant Spring Boot et qu’il permettait un paramétrage simple, mais adapté à la plupart des besoins au travers des options lockAtMostFor et lockAtLeastFor.

C’est donc une librairie intéressante à intégrer à sa boîte à outils et à utiliser sans modération dès que l’on rencontre un cas se prêtant à son utilisation.