Back to projects
Apr 15, 2026 • Project

SpringBatch-ETL: migrating 73 million rows in 40 minutes

Java 21 / Spring Batch ETL for the PharmaGDD v1 to v2 migration: 73M MySQL rows at ~1.8M rows/min, PHP-serialized columns decoded by a Swoole daemon over TCP, replayed nightly by GitHub Actions.

Java 21Spring BatchSwooleMySQLDockerGitHub Actions
Context
The PharmaGDD rebuild (v1 Symfony to v2 Sylius) requires migrating the whole database, including columns stored in PHP's serialized format.
My role
Designed and built the ETL, then its pipeline: Docker images, nightly runs on a cloud VM, integrity checks.
Outcome
73 million rows migrated in 40 minutes (~1.8M rows/min), replayed every night on the production dump.

The context

PharmaGDD is moving from its v1 (legacy Symfony) to a v2 built on Sylius. The schemas differ, and part of the data is stored in columns serialized with PHP’s serialize(), which Java cannot read as is.

We needed a tool able to migrate 73 million rows in a reasonable time, and to do it again as many times as needed before the cutover.

Decoding PHP from Java

This constraint drove the architecture.

The pipeline

The ETL is written in Java 21 with Spring Batch, which splits the work into transactional chunks.

The result: about 1.8 million rows per minute, so 40 minutes for the whole run.

Replayed every night

A migration is not something you run once. To reach the cutover with a proven tool, the ETL runs every night on a cloud VM, orchestrated by GitHub Actions on a self-hosted runner:

  1. Download the day’s production dump from an OVH S3 bucket, then import it into a source MySQL.
  2. Run the ETL from Docker images (Java ETL and Swoole daemon), built by CI and pushed to the OVH registry.
  3. Integrity checks on the target database: queries count orphaned rows (foreign keys without a parent). A non-zero result fails the run.
  4. Clean up containers and data.

These checks catch, for example, a parent table missing from the configuration: the children are migrated, the parent is not, and the error shows up the next morning instead of days later.

What I take away