AccueilÉcritsSpark 3 et les dates anciennes : le piège du calendrier proleptique

Spark 3 et les dates anciennes : le piège du calendrier proleptique

Le même code, les mêmes fichiers, une valeur différente. Spark 3 a changé de calendrier, et sur des données historiques ça se voit — sans qu'aucune erreur ne soit levée.

Chafiq Madkour28 juillet 20268 min de lectureSpark

C'est le genre de défaut qui ne ressemble pas à un bug. Le job tourne, le fichier sort, le schéma valide. Seules quelques dates ont bougé de dix jours, et uniquement sur les enregistrements les plus anciens. Sur un déclaratif portant des contrats ouverts depuis des décennies, ces enregistrements-là existent.

Ce qui a changé entre Spark 2.4 et Spark 3

Spark 2.4 utilisait le calendrier hybride julien-grégorien, celui de java.sql.Timestamp et de l'ancienne API java.util.Calendar. Spark 3 est passé au calendrier grégorien proleptique, celui de java.time, qui applique les règles grégoriennes uniformément y compris avant leur adoption en 1582.

Pour toute date postérieure à 1582, les deux calendriers coïncident : aucun effet. Avant, ils divergent — de dix jours au moment de la réforme, davantage en remontant. Une date sentinelle du type 0001-01-01, très courante comme valeur « non renseignée » dans des systèmes anciens, se décale de plusieurs jours.

Le problème n'est pas la conversion. C'est qu'elle est silencieuse : aucune exception, aucun avertissement dans les logs applicatifs, aucune ligne rejetée.

Les deux symptômes, très différents

Il faut distinguer deux mécanismes, parce qu'ils ne se corrigent pas au même endroit.

Le parsing de chaînes. Spark 3 utilise DateTimeFormatter au lieu de SimpleDateFormat. Ce dernier était permissif ; le nouveau est strict. Des motifs qui passaient lèvent maintenant une exception — yyyy et YYYY n'ont pas le même sens, et une date impossible comme 2021-02-30 était auparavant décalée silencieusement au 2 mars.

La relecture de fichiers. C'est le cas vicieux. Les Parquet et Avro écrits par Spark 2.4 contiennent des jours et micro-secondes encodés selon l'ancien calendrier. Relus par Spark 3 sans rebasage, ils rendent une date différente. Ici le code source n'a pas changé du tout : seul le moteur de lecture a changé d'interprétation.

Détecter avant de migrer

Le test tient en une requête, et il devrait tourner avant toute décision de paramétrage. On cherche les colonnes de date dont la valeur minimale précède la réforme grégorienne.

val colonnesDate = df.schema.fields
  .filter(f => f.dataType == DateType || f.dataType == TimestampType)
  .map(_.name)

val bornes = df.agg(
  colonnesDate.map(c => min(col(c)).as(c)): _*
).collect().head

colonnesDate.zipWithIndex.foreach { case (c, i) =>
  val v = bornes.get(i)
  if (v != null && v.toString < "1583-01-01")
    println(s"[risque] $c contient des dates anciennes : $v")
}

Sur les jeux que j'ai eu à migrer, ce sont presque toujours les mêmes colonnes qui ressortent : des dates de naissance, des dates d'effet de contrat, et surtout des sentinelles techniques (0001-01-01, 1000-01-01) utilisées comme « valeur absente ». Ce dernier cas est le plus fréquent et le plus facile à corriger : la vraie réponse est de remplacer la sentinelle par null, pas de rebaser.

Les trois politiques, et laquelle choisir

Spark expose des interrupteurs distincts. Les confondre est la source d'erreur suivante.

# parsing et formatage de chaînes de caractères
spark.sql.legacy.timeParserPolicy = EXCEPTION | CORRECTED | LEGACY

# lecture et écriture de fichiers déjà produits
spark.sql.parquet.datetimeRebaseModeInRead   = EXCEPTION | CORRECTED | LEGACY
spark.sql.parquet.datetimeRebaseModeInWrite  = EXCEPTION | CORRECTED | LEGACY
spark.sql.avro.datetimeRebaseModeInRead      = EXCEPTION | CORRECTED | LEGACY

Les trois valeurs ont des conséquences très différentes :

  • EXCEPTION — Spark échoue s'il rencontre une date ambiguë. C'est le défaut, et c'est le bon réglage pendant la migration : il transforme un écart silencieux en erreur bruyante, ce qui est exactement ce qu'on veut à ce moment-là.
  • LEGACY — Spark rebase pour reproduire le comportement de 2.4. C'est ce qu'il faut pour relire l'historique existant sans réécrire. Le coût est un rebasage à chaque lecture.
  • CORRECTED — Spark lit les valeurs telles quelles, sans rebasage. C'est la cible une fois l'historique réécrit.

La trajectoire qui marche : EXCEPTION pour découvrir l'ampleur, LEGACY en lecture pour ne pas bloquer la production, réécriture progressive de l'historique en CORRECTED, puis suppression des paramètres. Ce qu'il ne faut pas faire, c'est mettre LEGACY partout dès le premier jour : ça résout l'incident et fige la dette.

La règle qui évite le sujet

Le test de non-régression qui protège durablement ne porte pas sur les paramètres, mais sur les valeurs. Sur une journée de référence, on compare la sortie de l'ancienne et de la nouvelle chaîne, colonne de date par colonne de date :

val ecartsDate = ancien.as("a")
  .join(nouveau.as("n"), Seq("id_contrat"))
  .filter(col("a.date_effet") =!= col("n.date_effet"))
  .select("id_contrat", "a.date_effet", "n.date_effet")

require(ecartsDate.isEmpty,
  s"${ecartsDate.count()} contrats dont la date d'effet a changé")

Ce test a une propriété que le paramétrage n'a pas : il continue de protéger après la migration, lors de la prochaine montée de version. Et il documente une décision qui, sinon, se perd — parce que dans deux ans, personne ne se souviendra pourquoi datetimeRebaseModeInRead vaut LEGACY dans la configuration du cluster.

La règle générale, elle, est plus simple : une date sentinelle n'est pas une date. Si votre modèle utilise 0001-01-01 pour dire « inconnu », le vrai correctif n'est pas un paramètre de rebasage, c'est un null et une colonne de statut.

Chafiq Madkour est Tech Lead et Senior Data Engineer. Dix ans sur des plateformes data critiques en banque, en assurance et en finance de marché — déclaratifs réglementaires, détection de fraude, moteurs de calcul de risque.