Des feeders aléatoires et infinis pour Gatling

Dans un précédent article, j’évoquais l’utilisations de valeurs pseudo-aléatoires dans les tests de performance. Dans cet article, on parlera de la définition de feeders déterministes et infinis via les lazy streams de Scala.

Rappels sur les feeders et les sources de valeurs aléatoires

La documentation de Gatling mentionne qu’un feeder est avant tout un Iterator[Map[String, T]]. Dans la suite de l’article, j’utiliserai le type Iterator[Map[String, Any]], plus concret et autorisant tout type de valeurs. L’idée principale étant que n’importe quelle série de maps peut être utilisée par Gatling.

Dans l’article sur les valeurs aléatoires, je parlais du constructeur de java.util.Random prenant en paramètre un seed. L’idée était alors que la création d’une instance de Random avec un seed fixe permettait une génération prévisible de valeurs aléatoires.

Les streams infinis

L’exemple typique d’un feeder est le code ci-dessous. Il correspond au type attendu, mais n’utilise pas une source prévisible de valeurs aléatoires.

val feeder = Iterator.continually(
  Map("email" -> (Random.alphanumeric.take(20).mkString + "@foo.com"))
)

Une autre possibilité est d’utiliser la classe scala.collection.immutable.Stream. Le code précédent peut être ré-écrit comme ceci:

val feeder = Stream.continually(
  Map("email" -> (Random.alphanumeric.take(20).mkString + "@foo.com"))
).iterator

Jusqu’ici, rien de bien exceptionnel. Ceci dit, on peut aller plus loin. On peut par exemple générer un identifiant numérique et l’associer à chaque adresse e-mail. On aura donc une série d’adresses e-mail associées à des identifiants entiers commençant à 1.

val feeder = Stream.from(1).map(id => Map(
  "id" -> id,
  "email" -> (Random.alphanumeric.take(20).mkString + "@foo.com"))
).iterator

Mais ces exemples restent encore trop simplistes. Passons sur un cas d’utilisation plus réaliste.

Un modèle de données IoT

CREATE TABLE iot.sensors_data (
  sensor_id text,
  metric_type text,
  measurement_date date,
  measurement_time time,
  value bigint,
  PRIMARY KEY ((sensor_id, metric_type, measurement_date), measurement_time)
)

Considérons un cas d’utilisation IoT pour lequel des capeurs envoient leur relevés toutes les secondes à un serveur central. Chaque capteur a un identifiant et peut envoyer plusieurs métriques (A, B et C). Il envoie une nouvelle valeur par seconde, pour chaque métrique. Dans Cassandra, on pourrait stocker cela dans la table créée ci-dessus.

On pourrait donc définir une Map qui contiendrait les valeurs d’une ligne unique comme ceci:

Map(
  "sensor_id" -> "abcdef",
  "metric_type" -> "A",
  "measurement_date" -> "2020-01-01",
  "measurement_time" -> "00:00:01",
  "value" -> 42
)

Serait-il possible de générer un Stream infini pour les valeurs de 10 capteurs différents?

Première étape: générer 3 métriques

Note: pour la suite, on utilisera la fonction suivante pour afficher les valeurs produites.

def debug(stream: Stream[Map[String, Any]]) =
  stream.map(_.values.mkString(",")).foreach(println)

On peut créer une fonction qui nous génèrera les trois métriques d’un capteur donné à un instant t donné.

val metricTypes = Stream("A", "B", "C")

val isoDate = DateTimeFormatter.ISO_DATE
val isoTime = DateTimeFormatter.ISO_TIME

def newSensorMetrics(sensorId: String,
                     measurementDateTime: LocalDateTime,
                     random: Random): Stream[Map[String, Any]] =
  metricTypes.map(metricType => Map(
    "sensor_id" -> sensorId,
    "metric_type" -> metricType,
    "measurement_date" -> isoDate.format(measurementDateTime),
    "measurement_time" -> isoTime.format(measurementDateTime),
    "value" -> random.nextInt
  ))

Pour un capteur et une date donnée, cette fonction nous renvoie donc autant d’éléments qu’il y a de metricTypes. On peut facilement vérifier les résultats produits:

scala> debug(newSensorMetrics("abcdef", LocalDateTime.of(2019, 12, 29, 18, 51, 59), new Random()))
18:51:59,2019-12-29,abcdef,A,994309426
18:51:59,2019-12-29,abcdef,B,94083573
18:51:59,2019-12-29,abcdef,C,1420415181

Deuxième étape: Flatmap zat shit

how to draw an owl

On peut maintenant améliorer un peu le design. On va définir une fonction qui nous renverra les valeurs de 5 capteurs différents, soit 15 valeurs au total. Sa définition va être relativement simple et basée sur la fonction précédente.

val sensorIds = Stream("room-1", "room-2", "room-3", "room-4", "basement")

def newMetricsAt(measurementDateTime: LocalDateTime,
                 random: Random): Stream[Map[String, Any]] =
  sensorIds.flatMap(sensorId => newSensorMetrics(sensorId, measurementDateTime, random))

Vérifions son résultat. On voit bien qu’elle crée les valeurs des métriques A, B et C pour chacun des capteurs.

scala> debug(newMetricsAt(LocalDateTime.of(2019, 12, 29, 18, 51, 59), new Random()))
18:51:59,2019-12-29,room-1,A,-225317240
18:51:59,2019-12-29,room-1,B,-1407073398
18:51:59,2019-12-29,room-1,C,1353350072
18:51:59,2019-12-29,room-2,A,1391219556
18:51:59,2019-12-29,room-2,B,-1186475735
18:51:59,2019-12-29,room-2,C,-1528496533
18:51:59,2019-12-29,room-3,A,820521014
18:51:59,2019-12-29,room-3,B,155042503
18:51:59,2019-12-29,room-3,C,4863426
18:51:59,2019-12-29,room-4,A,1366254389
18:51:59,2019-12-29,room-4,B,1687308010
18:51:59,2019-12-29,room-4,C,554733033
18:51:59,2019-12-29,basement,A,1903022513
18:51:59,2019-12-29,basement,B,-1904888787
18:51:59,2019-12-29,basement,C,-939754900

Pour finir, il ne reste plus qu’à appeler cette nouvelle fonction pour chaque seconde, et nous aurons terminé. Cette fois, nous allons réutiliser l’idée présentée en début d’article et nous baser sur Stream.from(0). La valeur représentée ici sera le nombre de secondes écoulées depuis le 1er janvier 2020. Mais cela fonctionnerait avec toute autre date de référence.

val baseEpochSecond = LocalDateTime.of(2020, 1, 1, 0, 0, 0).toEpochSecond(ZoneOffset.UTC)

def newMetricsFeeder(random: Random): Stream[Map[String, Any]] = {
  Stream.from(0)
    .map(x => LocalDateTime.ofEpochSecond(baseEpochSecond + x, 0, ZoneOffset.UTC))
    .flatMap(measurementDateTime => newMetricsAt(measurementDateTime, random))
}

Vérifions son résultat, pour les 30 premières entrées. On peut constater qu’après les 15 premières lignes correspondant aux valeurs à minuit, on voit les 15 valeurs suivantes correspondant à 00:00:01.

scala> debug(newMetricsFeeder(new Random()).take(30))
00:00:00,2020-01-01,room-1,A,-853286656
00:00:00,2020-01-01,room-1,B,919370010
00:00:00,2020-01-01,room-1,C,178811566
00:00:00,2020-01-01,room-2,A,1508459530
00:00:00,2020-01-01,room-2,B,1676289209
00:00:00,2020-01-01,room-2,C,-741724980
00:00:00,2020-01-01,room-3,A,139680150
00:00:00,2020-01-01,room-3,B,653904810
00:00:00,2020-01-01,room-3,C,-72138997
00:00:00,2020-01-01,room-4,A,71036715
00:00:00,2020-01-01,room-4,B,1664416044
00:00:00,2020-01-01,room-4,C,1597468231
00:00:00,2020-01-01,basement,A,-1453698797
00:00:00,2020-01-01,basement,B,-1731220294
00:00:00,2020-01-01,basement,C,-1653835053
00:00:01,2020-01-01,room-1,A,635621957
00:00:01,2020-01-01,room-1,B,-299580046
00:00:01,2020-01-01,room-1,C,1538466835
00:00:01,2020-01-01,room-2,A,-2092402454
00:00:01,2020-01-01,room-2,B,1597417387
00:00:01,2020-01-01,room-2,C,833558919
00:00:01,2020-01-01,room-3,A,1260013834
00:00:01,2020-01-01,room-3,B,1891732764
00:00:01,2020-01-01,room-3,C,1860549092
00:00:01,2020-01-01,room-4,A,2051048928
00:00:01,2020-01-01,room-4,B,556152339
00:00:01,2020-01-01,room-4,C,-337711114
00:00:01,2020-01-01,basement,A,-792602173
00:00:01,2020-01-01,basement,B,-1781777940
00:00:01,2020-01-01,basement,C,117395424
Notons la présence de la fonction take(30) dans le listing ci-dessus. Sans cette fonction, on aurait une boucle infinie, car on essaierait d’afficher le contenu d’une liste infinie.

Un peu de golf

Dans cet article, j’ai divisé le travail sur trois fonctions différentes. Cela a pour résultat trois fonctions très simples, voire simplistes. Mais il est possible de définir le même feeder via une boucle for typique en Scala.

Le code ci-dessous produit le même résultat que précédemment. Des variables ont toutefois été conservées pour que le code reste lisible.

Les données sont générées dans le même ordre. On retrouve un parcours en largeur d’abord. C’est à dire qu’en priorité, les métriques d’un même capteur sont générées ensemble. Ensuite, les relevés des capteurs à un même instant sont générés ensemble. Enfin, tous les relevés à partir du 1er janvier 2020 sont générés.

val baseEpochSecond = LocalDateTime.of(2020, 1, 1, 0, 0, 0).toEpochSecond(ZoneOffset.UTC)

val dateStream = Stream.from(0).map(x => LocalDateTime.ofEpochSecond(x + baseEpochSecond, 0, ZoneOffset.UTC))
val sensorStream = Stream("room-1", "room-2", "room-3", "room-4", "basement")
val metricStream = Stream("A", "B", "C")

val random = new Random(1)

val feeder:Iterator[Map[String,Any]] = (for {
  measurementDateTime <- dateStream
  sensorId <- sensorStream
  metricType <- metricStream
} yield Map(
  "sensor_id" -> sensorId,
  "metric_type" -> metricType,
  "measurement_date" -> DateTimeFormatter.ISO_DATE.format(measurementDateTime),
  "measurement_time" -> DateTimeFormatter.ISO_TIME.format(measurementDateTime),
  "value" -> random.nextInt
)).iterator

Conclusion

Dans l’article précédent, je notais l’importance d’utiliser des valeurs pseudo-aléatoires pour des facilités opérationnelles. Cette technique révèle encore plus d’avantages lorsqu’elle est couplée à la classe Stream.

Le design de Gatling autorise la génération de données de manière à la fois infinie, aléatoire et prévisible. Quelque soit la complexité des données à générer et le type de parcours (largeur/profondeur d’abord), il existe un moyen d’écrire un feeder infini.


Si vous avez des questions ou voulez me faire part de vos commentaires, envoyez moi un tweet (@pingtimeout). Et si vous avez apprécié cet article et voulez soutenir mon travail, vous pouvez toujours m’offrir un café sur BuyMeACoffee ☕️.