Utiliser un récepteur ForEach pour écrire chaque ligne dans les pipelines
Utilisez un récepteur ForEach pour écrire des données en streaming vers des destinations qui n’ont pas de récepteur intégré, comme une API REST externe ou un magasin clé-valeur.
Le sink ForEach traite un stream ligne par ligne. Pour chaque ligne, le pipeline appelle une fonction Python personnalisée ou un objet d'écriture, semblable au sink foreach dans Apache Spark Structured Streaming.
Le sink ForEach décrit sur cette page est destiné aux pipelines. Apache Spark Structured Streaming prend aussi en charge foreach. Pour plus d’informations sur l’écriture de sorties personnalisées dans Structured Streaming, voir Utiliser foreachBatch pour écrire dans des sinks de données arbitraires.
Quand utiliser une destination ForEach
Utilisez un sink ForEach quand vous devez écrire chaque enregistrement individuellement vers une destination qui n’a pas de format de sink intégré, comme l’appel d’une API externe ou la mise à jour d’un magasin clé-valeur pour chaque enregistrement. Utilisez plutôt un sink ForEachBatch quand votre logique fonctionne sur un micro-batch complet, comme la fusion dans une table Delta Lake ou l’écriture dans plusieurs destinations avec des opérations DataFrame.
Ce tableau compare les deux récepteurs :
Caractéristique |
|
|
|---|---|---|
Unité de traitement | Une ligne à la fois | Un DataFrame de micro-batch à la fois |
Où votre code s'exécute | Sur les exécuteurs | Sur le driver |
API Python |
|
|
Pour plus d’informations sur les récepteurs intégrés, consultez Récepteurs dans les LakeFlow Pipelines. Pour la référence de l’API Python @dp.foreach_sink(), consultez foreach_sink.
Définir un puits ForEach
Vous pouvez définir une destination ForEach à partir d’une fonction ou d’un objet writer.
Pour définir la destination à partir d’une fonction, appliquez @dp.foreach_sink() à la fonction. Le pipeline appelle la fonction pour chaque ligne. Si vous omettez name, la destination utilise le nom de la fonction :
from pyspark import pipelines as dp
@dp.foreach_sink()
def my_row_sink(row):
# Custom per-row logic here.
...
Pour définir le récepteur à partir d’un objet d’écriture, passez l’objet avec l’argument de mot clé writer. L’écriture doit implémenter process(row) et peut implémenter open(partition_id, epoch_id) et close(error). Utilisez l’écriture lorsque vous souhaitez ouvrir une connexion pour chaque partition et la réutiliser pour chaque ligne. Si vous omettez name, le récepteur utilise le nom de classe de l’écriture, en minuscules :
from pyspark import pipelines as dp
class MyRowWriter:
def open(self, partition_id, epoch_id):
# Open a connection for this partition.
return True
def process(self, row):
# Write the row by using the connection.
...
def close(self, error):
# Close the connection.
...
dp.foreach_sink(name="my_row_sink", writer=MyRowWriter())
Pour obtenir des détails sur chaque méthode, consultez Méthodes d'écriture.
Entrées des Logs des événements
Lorsque vous définissez un récepteur ForEach, le log d’événements du pipeline enregistre un événement SinkDefinition avec "format": "foreach" pour le récepteur.
Écrire vers un récepteur ForEach
Vous pouvez écrire vers un sink ForEach depuis un flux d'ajout ou un flux de mise à jour.
Écrire avec un flux d’ajout
Pour écrire dans un sink ForEach à partir d'un flux append, référencez le nom du sink comme target:
@dp.append_flow(target="my_row_sink")
def my_flow():
return spark.readStream.table("my_catalog.my_schema.source_table")
Vous pouvez définir plusieurs flux d'ajout qui ciblent le même récepteur ForEach. Chaque flux conserve son propre point de contrôle.
Écrire avec un flux de mise à jour
Pour écrire vers un récepteur ForEach à partir d’un flux de mise à jour, utilisez le décorateur @dp.update_flow :
@dp.update_flow(target="my_row_sink")
def my_stateful_flow():
return spark.readStream.table("my_catalog.my_schema.events").groupBy("user_id").count()
Un flux de mise à jour peut renvoyer la même clé au récepteur dans un micro-batch ultérieur avec une nouvelle valeur. Pour remplacer l'ancienne valeur, effectuez un upsert de chaque ligne dans votre destination par clé au lieu de l'insérer. Dans l'exemple précédent, utilisez user_id comme clé, de sorte que chaque nouveau compte pour un utilisateur remplace le compte précédent.
Pour le traitement à faible latence, un flux de mise à jour qui écrit dans un récepteur ForEach peut s’exécuter en mode temps réel. Consultez Utiliser le mode temps réel dans les LakeFlow Pipelines.
Importer des bibliothèques et des dépendances sur les exécuteurs
Votre fonction ou votre rédacteur s’exécute sur les executors, pas sur le driver. Suivez les pratiques suivantes :
- Ajoutez les packages dont votre code a besoin à l’environnement de pipeline. Les packages de l’environnement de pipeline sont disponibles pour votre code sur les exécuteurs. Consultez Gérer les dépendances Python pour les pipelines.
- Importez les bibliothèques que votre code utilise à l’intérieur de la fonction ou des méthodes d’écriture, pas seulement en haut du fichier source du pipeline.
- Ne vous appuyez pas sur des objets que vous créez sur le driver, par exemple un client ou une connexion que vous ouvrez au début du fichier. Créez-les à l'intérieur de la fonction, ou dans la méthode
opendu writer.
Traiter les échecs et les lignes en double
Si votre code génère une exception, le flux échoue et le journal des événements du pipeline enregistre un événement d'erreur qui inclut le message d'exception.
Lorsqu’un micro-batch est relancé après un échec, le sink peut recevoir certaines lignes à nouveau. Rendez vos écritures par ligne idempotentes, par exemple en utilisant une clé unique de la ligne pour effectuer une upsert dans la destination. Voir Garanties de traitement dans les LakeFlow Pipelines.
Si vous utilisez un objet writer, open reçoit un partition_id et un epoch_id. Ensemble, ces IDs identifient les lignes d'une partition dans un micro-batch, afin que vous puissiez les utiliser pour dédoublonner les lignes. Une fonction ne reçoit pas ces IDs.
Les ID ne sont uniques que dans le point de contrôle d'un flux. La même paire peut se répéter dans les cas suivants :
- Plusieurs flux écrivent dans le même récepteur.
- Un refresh complet Reset les points de contrôle.
Pour dédoublonner avec ces identifiants, écrivez vers le récepteur à partir d’un flux unique et effacez votre état de dédoublonnement lorsque vous exécutez un refresh complet.
Actualisation complète refresh
Lors d'un refresh complet, le pipeline reset le checkpoint de chaque flux qui écrit dans le récepteur ForEach. Le pipeline ne suit ni ne nettoie les données écrites par le récepteur, vous devez donc gérer la rétention et le nettoyage pour votre destination. Si vous avez besoin d'un clean start, supprimez vous-même ces données.
Limitations
- Métrique de ligne : le pipeline ne signale pas le nombre de lignes qu’un récepteur
ForEachécrit. - Appels par ligne : si votre code appelle un système externe pour chaque ligne, le système externe peut limiter le throughput. Pour réduire la surcharge, ouvrez des connexions dans la méthode
opende l’enregistreur et réutilisez-les pour chaque ligne, ou utilisez un récepteurForEachBatchpour écrire en masse.
Exemples
Ces exemples montrent des méthodes courantes pour utiliser un récepteur ForEach.
Exemple de syntaxe de base
Cet exemple définit un récepteur ForEach à partir d’une fonction et y écrit à partir d’un flux d’ajout :
from pyspark import pipelines as dp
# Create a ForEach sink
@dp.foreach_sink(name="my_foreach_sink")
def handle_row(row):
# Custom per-row logic here.
...
# Create source data for the example
@dp.table()
def example_source_data():
return spark.range(5)
# Write to the sink from an append flow
@dp.append_flow(target="my_foreach_sink")
def my_flow():
return spark.readStream.table("example_source_data")
Écrire chaque ligne dans une API externe
Cet exemple utilise un objet writer pour envoyer chaque commande à une API REST externe. L’objet writer ouvre une session HTTP pour chaque partition. Il utilise l’ID de la commande dans l’URL, de sorte qu’une ligne retentée met à jour le même enregistrement au lieu de créer un doublon :
from pyspark import pipelines as dp
class OrdersApiWriter:
def open(self, partition_id, epoch_id):
import requests # Import on the executor
self.session = requests.Session()
return True
def process(self, row):
response = self.session.put(
f"https://api.example.com/orders/{row.order_id}",
json={"order_id": row.order_id, "status": row.status},
timeout=10,
)
# Raise on HTTP errors so that the flow fails instead of dropping the row.
response.raise_for_status()
def close(self, error):
self.session.close()
dp.foreach_sink(name="orders_api_sink", writer=OrdersApiWriter())
@dp.append_flow(target="orders_api_sink")
def orders_to_api():
return spark.readStream.table("my_catalog.my_schema.orders")
Écrire vers un récepteur à partir de plusieurs flux
Cet exemple écrit deux sources dans le même récepteur ForEach :
from pyspark import pipelines as dp
@dp.foreach_sink(name="events_sink")
def handle_event(row):
# Custom per-row logic here.
...
@dp.append_flow(target="events_sink")
def web_events():
return spark.readStream.table("my_catalog.my_schema.web_events")
@dp.append_flow(target="events_sink")
def mobile_events():
return spark.readStream.table("my_catalog.my_schema.mobile_events")