Block Image

Spring Batch è un potente modulo di Spring che viene usato per eseguire job.
Un problema abbastanza comune è gestire istanze multiple di uno stesso job poiché Spring Batch non ha una gestione di lock di default.
Ci sono vari modi per gestire questa situazione, tutti però fanno più o meno la stessa cosa:
le istanze multiple condividono uno stesso database che gestisce la sincronizzazione tra i nodi/istanze.
Più precisamente, quando deve essere eseguito un job (magari al trigger di una cron), l'istanza controlla nel database se quel job è già stato "bloccato" da un'altra istanza; se si, allora l'istanza non eseguirà il job.

Ci sono più librerie che risolvono il problema della gestione di istanze multiple di un job:
se già all'interno del progetto viene usato lo scheduler di Quarz, allora si può usare questa stessa libreria; altrimenti Shedlock, che fa prettamente locking di job, potrebbe fare al caso nostro.

Il tutorial farà riferimento ad un batch che leggerà un file csv e scriverà il contenuto in una tabella USER di un database MySQL.

Prerequisiti

  1. Aver installato una jdk (useremo la versione 8 ma va bene anche una successiva).
  2. Aver installato maven (https://maven.apache.org/install.html).
  3. Aver installato un DB che sincronizzerà 2 istanze della stessa app (noi useremo un'immagine docker di MySQL).
  4. I nodi devono raggiungere lo stesso DB (nel tutorial eseguiremo 2 istanze in localhost).

Primo passo: Creare le tabelle USER e SHEDLOCK nel database:

CREATE TABLE USER (
  id BIGINT AUTO_INCREMENT  PRIMARY KEY,
  name VARCHAR(250) NOT NULL,
  surname VARCHAR(250) NOT NULL,
  address VARCHAR(250) DEFAULT NULL
);

CREATE TABLE SHEDLOCK (
  name VARCHAR(64),
  lock_until TIMESTAMP(3) NULL,
  locked_at TIMESTAMP(3) NULL,
  locked_by VARCHAR(255),
  PRIMARY KEY (name)
)

Secondo passo: andare sul sito Spring Initializr

Questo sito creerà per noi uno scheletro di un'app Spring Boot con tutto quello che ci serve (basta cercare le dipendenze che ci servono nella sezione 'Dependencies'). Clicchiamo su 'ADD DEPENDENCIES' ed aggiungiamo le dipendenze riportate dall'immagine.

Block Image

Cliccate su 'Generate' e unzippate il progetto.

Terzo passo: importiamo le dipendenze di Shedlock

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

Quarto passo: creiamo un model che mapperà una tabella del Database

Creiamo un sottopackage models e lì creiamo una classe Java che mapperà una tabella USER:

public class User implements Serializable {

    private Long id;
    private String name;
    private String surname;
    private String address;
    
    //getter, setter, equals and hashcode
}

Quinto passo: creiamo la configurazione di Spring Batch

Abbiamo detto che il batch leggerà da un file csv e scriverà ogni riga letta su una tabella USER del DB.
Scriviamo un job molto banale all'interno del sottopackage config:

@Configuration
@EnableBatchProcessing
@EnableScheduling
public class BatchConfig {

    private StepBuilderFactory stepBuilderFactory;

    private JobBuilderFactory jobBuilderFactory;

    private DataSource dataSource;
    
    public BatchConfig(StepBuilderFactory stepBuilderFactory, JobBuilderFactory jobBuilderFactory, DataSource dataSource) {
        this.stepBuilderFactory = stepBuilderFactory;
        this.jobBuilderFactory = jobBuilderFactory;
        this.dataSource = dataSource;
    }


    @Bean
    FlatFileItemReader<User> itemReader() {

        return new FlatFileItemReaderBuilder<User>()
                .name("userItemReader")
                .resource(new ClassPathResource("test-data.csv"))
                .delimited()
                .delimiter(";")
                .names("name", "surname", "address")
                .fieldSetMapper(new BeanWrapperFieldSetMapper<User>(){{
                    setTargetType(User.class);
                }})
                .build();
    }

    @Bean
    JdbcBatchItemWriter<User> itemWriter() {
        return new JdbcBatchItemWriterBuilder<User>()
                .dataSource(dataSource)
                .itemSqlParameterSourceProvider(new BeanPropertyItemSqlParameterSourceProvider<>())
                .sql("INSERT INTO USER (name, surname, address) VALUES (:name, :surname, :address)")
                .build();
    }

    @Bean
    Step step() {
        return stepBuilderFactory.get("step")
                .<User, User>chunk(10)
                .reader(itemReader())
                .writer(itemWriter())
                .build();
    }

    @Bean
    Job job() {
        return jobBuilderFactory.get("job")
                .start(step())
                .build();
    }
}

Analizziamo il codice:

  1. @EnableBatchProcessing ci permette di importare delle configurazioni già pronte di Spring Batch (come i bean di JobBuilderFactory e JobBuilderFactory).
  2. @EnableScheduling ci permette di usare l'annotation @Scheduler dove verrà fornita una cron.
  3. Come implementazione di ItemReader usiamo FlatFileItemReader per leggere il file csv.
  4. Come implementazione di ItemWriter usiamo JdbcBatchItemWriter per scrivere su una tabella del DB.

Per ora nulla di nuovo; passiamo a configurare Shedlock.

Nota 1: Il job banalmente leggerà all'infinito il file csv e scriverà a DB sempre lo stesso record. Per l'esempio va bene perché non implica violazioni di vincoli ma ovviamente in un caso reale avrebbe poco senso.

Sesto passo: configuriamo Shedlock

Creiamo la classe di configurazione di Shedlock nel sottopackage config:

@Configuration
@EnableSchedulerLock(defaultLockAtMostFor = "1m")
public class ShedLockConfig {
    

    @Bean
    public LockProvider lockProvider(DataSource dataSource) {
        return new JdbcTemplateLockProvider(
                JdbcTemplateLockProvider.Configuration.builder()
                        .withJdbcTemplate(new JdbcTemplate(dataSource))
                        .withTableName("SHEDLOCK") //for mysql linux case-sensitive
                        .usingDbTime() // Works on Postgres, MySQL, MariaDb, MS SQL, Oracle, DB2, HSQL and H2
                        .build()
        );
    }
}

La configurazione mostrata è molto semplice; la classe da configurare è LockProvider:

  1. @EnableSchedulerLock(defaultLockAtMostFor = "1m") abilita lo scanning di tutte le annotation @SchedulerLock di Shedlock (spiegheremo dopo la funzione di defaultLockAtMostFor).
  2. forniamo il datasource dove Shedlock cercherà la tabella dedicata al locking dei Job.
  3. .withTableName("SHEDLOCK") specifica il nome della tabella di Shedlock dove la libreria inserirà i dati dei job(di default cerca una table chiamata "shedlock", in minuscolo).
  4. .usingDbTime() Shedlock userà il time del DB, senza questa opzione di default verrà usato il time del client dell'app. Questo è rischioso perché i client potrebbero avere time diversi.

Settimo passo: creiamo una classe Runner che eseguirà il Job ogni 2 minuti

@Component
public class BatchRunner {

    private Job job;
    
    private JobLauncher jobLauncher;

    public BatchRunner(Job job, JobLauncher jobLauncher) {
        this.job = job;
        this.jobLauncher = jobLauncher;
    }


    @Scheduled(cron = "0 */2 * * * *")
    @SchedulerLock(name = "TaskScheduler_scheduledTask",
            lockAtLeastFor = "1m", lockAtMostFor = "1m")
    public void run() throws Exception {
        jobLauncher.run(job, new JobParametersBuilder().addDate("date", new Date()).toJobParameters());
    }
}

Annotiamo il metodo run con @SchedulerLock. Quindi Shedlock controllerà il lock di questo metodo per ogni istanza.

Analizziamo i parametri lockAtMostFor e lockAtLeastFor:

  1. lockAtMostFor specifica il tempo massimo per cui deve essere mantenuto il lock. Questo è solo un fallback, quindi è utile quando un nodo muore, poiché di default quando il job termina, Shedlock rilascia il lock. Se non specifichiamo questo parametro nell'annotation @SchedulerLock, allora verrà usato il parametro di default (defaultLockAtMostFor = "1m" all'interno di @EnableSchedulerLock).
  2. lockAtLeastFor specifica il tempo minimo per cui deve essere mantenuto il lock. Ha lo scopo di impedire l'esecuzioni di job da più nodi in parallelo.

Ottavo passo: modifichiamo l'application.properties e creiamo un csv

spring.datasource.url=jdbc:mysql://localhost:3306/shedlock_DB?useSSL=false&serverTimezone=Europe/Rome
spring.datasource.username=root
spring.datasource.password=root
spring.datasource.platform=mysql

spring.batch.job.enabled=false
spring.batch.initialize-schema=always

Creiamo all'interno di resources test-data.csv con questo contenuto:

Vincenzo;Racca;via Napoli
Pippo;Pluto;via Roma

Finito! Eseguiamo l'app con 2 istanze diverse

Buildiamo il progetto e copiamo il jar in una cartella chiamata "batch1" fuori dal progetto. Copiamo poi il jar anche in una seconda cartella chiamata "batch2". All'interno di batch2 copiamo anche l'application.properties e modifichiamo la property:
spring.batch.initialize-schema=never

Avviamo i jar di batch1 e batch2 e seguiamo i log:

Block Image

Nota 2: L'application.propeties all'interno della cartella batch2 sovrascriverà quello internamente del jar.
Come specificato nella documentazione di Shedlock, questa libreria può essere usata anche senza l'ausilio di un framework come Spring Batch.

Conclusioni

Come dimostra l'immagine, i job non vengono eseguiti in parallelo. Infatti nei primi 2 minuti è stato eseguito dall'istanza 1 mentre nei successivi 2, dall'istanza 2. Se si provasse a stoppare un'istanza, il job continuerebbe a girare perché c'è ancora in funzione la seconda istanza.

Potete trovare il progetto completo sul mio github a questo link: Shedlock
Inoltre nel README.md del progetto github spiego come creare un container MySQL da un'immagine Docker.

Documentazione del progetto Shedlock: Shedlock Documentation

Articoli su Spring: Spring

Libri consigliati su Spring: