Dédupliquer les requêtes de job

Cloud Scheduler inclut l'identification des jobs et les en-têtes HTTP de planification dans les requêtes HTTP et App Engine. Cela vous permet de dédupliquer les exécutions relancées et de rendre vos gestionnaires cibles idempotents. Étant donné que Cloud Scheduler fournit une diffusion au moins une fois, la déduplication des requêtes empêche votre gestionnaire ou vos API Google Cloud en aval de traiter la même exécution planifiée plusieurs fois en cas de délai d'expiration ou d'interruption temporaire du réseau.

Fonctionnement de la déduplication des requêtes

Des demandes de job en double pour la même exécution planifiée peuvent se produire dans les situations suivantes :

  • Cibles de longue durée : le service cible met plus de temps à répondre que le délai avant expiration de la tentative (attemptDeadline) du job, ce qui entraîne l'expiration du délai de Cloud Scheduler et la nouvelle tentative du job alors que la première tentative est toujours en cours d'exécution. Pour les cibles HTTP, attemptDeadline est défini par défaut sur trois minutes et peut être configuré jusqu'à 30 minutes. Alignez attemptDeadline sur le délai avant expiration de votre cible ou traitez les tâches de longue durée de manière asynchrone si l'exécution peut dépasser 30 minutes.
  • Accusés de réception réseau perdus ou nouvelles tentatives de proxy temporaires : la cible reçoit et traite la requête, mais une interruption temporaire du réseau ou une déconnexion du proxy empêche Cloud Scheduler de recevoir la réponse HTTP 2xx. Même si vous définissez retryCount sur 0, la distribution au moins une fois signifie qu'une cible peut toujours recevoir des requêtes en double lors d'échecs de transport temporaires.

Pour rendre votre gestionnaire de jobs idempotent et dédupliquer les requêtes relancées, utilisez les en-têtes HTTP que Cloud Scheduler inclut automatiquement dans les requêtes adressées aux cibles HTTP et App Engine :

En-tête Description
X-CloudScheduler-JobName Identifiant de job court (JOB_ID) de projects/PROJECT_ID/locations/LOCATION_ID/jobs/JOB_ID.
X-CloudScheduler-ScheduleTime

Pour les jobs spécifiés au format unix-cron, l'heure d'exécution planifiée est un décalage de l'heure UTC au format RFC 3339 (par exemple, 2026-10-16T12:00:00Z ou 2026-10-16T05:00:00-07:00). Cette valeur reste constante pour toutes les tentatives d'une même exécution planifiée.

Cet en-tête n'est pas garanti pour les jobs qui utilisent l'autre format de planification groc, tel que every 5 minutes. Si vous déclenchez un job manuellement (par exemple, en exécutant gcloud scheduler jobs run ou en cliquant sur Forcer l'exécution d'un job dans la console Google Cloud ), cet en-tête utilise le code temporel de la prochaine exécution planifiée ou peut être omis. Par conséquent, la déduplication basée sur les en-têtes ignore l'exécution planifiée à venir et toutes les exécutions manuelles supplémentaires déclenchées avant cette heure planifiée.

Exiger l'authentification sur votre point de terminaison cible

Les en-têtes X-CloudScheduler-* ne sont pas authentifiés et peuvent être définis par n'importe quel appelant. N'utilisez pas ces en-têtes pour l'authentification. Sécurisez plutôt votre point de terminaison en fonction du type de cible :

Utiliser des recherches d'en-têtes non sensibles à la casse

La casse des en-têtes peut varier selon les versions HTTP et l'infrastructure (par exemple, X-Cloudscheduler-Jobname en HTTP/1.1 ou x-cloudscheduler-jobname et x-cloudscheduler-scheduletime en minuscules dans les environnements HTTP/2 tels que Cloud Run). Utilisez toujours des recherches d'en-têtes non sensibles à la casse dans votre gestionnaire.

Désambiguïser les ID de job entre les projets ou les régions

Cloud Scheduler n'envoie pas automatiquement les en-têtes d'ID de projet ou de région. Un même point de terminaison de gestionnaire peut recevoir des requêtes de plusieurs projets ou régions qui partagent le même JOB_ID. Dans ce cas, transmettez l'ID du projet ou la région dans HttpTarget.headers, AppEngineHttpTarget.headers, ou le chemin d'accès ou les paramètres de requête de l'URL cible, et incluez ces valeurs dans votre clé de déduplication.

Dédupliquer les requêtes dans un gestionnaire HTTP

Avant d'implémenter la déduplication des requêtes dans votre gestionnaire, effectuez les étapes de configuration suivantes :

  • Configurez la planification de votre job au format unix-cron (par exemple, */5 * * * * au lieu de every 5 minutes) afin que Cloud Scheduler envoie l'en-tête X-CloudScheduler-ScheduleTime.
  • Configurez un datastore persistant compatible avec les transactions ou les écritures conditionnelles atomiques, tel que Firestore, Spanner ou Memorystore. Configurez une règle de valeur TTL (Time To Live) sur la collection ou la table (par exemple, sur le champ expire_at) pour supprimer les anciens enregistrements de déduplication après la fenêtre de nouvelle tentative du job (maxRetryDuration). La valeur TTL de Firestore supprime les enregistrements arrivés à expiration de manière asynchrone, généralement sous 24 à 72 heures. Pour permettre aux nouvelles tentatives de récupérer les exécutions des instances plantées avant le nettoyage TTL, stockez également un code temporel d'expiration du bail (tel que lease_expires, défini sur au moins le délai avant expiration de la requête de votre cible et attemptDeadline) et évaluez-le dans une transaction.

Pour ne traiter chaque exécution planifiée qu'une seule fois dans votre gestionnaire HTTP, procédez comme suit :

  1. Lisez X-CloudScheduler-JobName et X-CloudScheduler-ScheduleTime à partir des en-têtes de requête entrants et hachez-les dans une clé de déduplication de longueur fixe (run_id).
  2. Exécutez une transaction dans votre datastore pour vérifier et revendiquer l'enregistrement de la clé de déduplication :
    • Si l'enregistrement existe et que status est completed, renvoyez immédiatement un code d'état HTTP 200 OK (ou 204 No Content) sans exécuter la logique du job afin que Cloud Scheduler arrête de réessayer.
    • Si l'enregistrement existe, que status est in_progress et que le bail (lease_expires) n'a pas expiré, renvoyez un code d'état d'erreur pouvant être relancé, tel que 409 Conflict, afin que Cloud Scheduler maintienne sa règle de relance active en cas d'échec de la tentative en cours. Évitez de renvoyer 503 Service Unavailable à partir des cibles App Engine, car App Engine traite 503 comme une erreur système et relance la requête sans la comptabiliser dans retryCount.
    • Si l'enregistrement n'existe pas ou si son bail a expiré, écrivez l'enregistrement avec status défini sur in_progress, un ID de tentative unique (attempt_id), un code temporel d'expiration du bail (lease_expires) et un code temporel d'expiration de la valeur TTL (expire_at).
  3. Après avoir revendiqué l'enregistrement, exécutez la logique de votre job. Dans une transaction, vérifiez que le attempt_id de l'enregistrement correspond toujours à la tentative actuelle avant de modifier l'enregistrement :

    • Si la logique du job réussit, remplacez status par completed et renvoyez un code d'état HTTP 200 OK.
    • Si la logique du job échoue avec une erreur que vous pouvez réessayer, supprimez l'enregistrement de déduplication avant de renvoyer un code d'état 5xx afin que Cloud Scheduler puisse réessayer l'exécution sans attendre l'expiration du bail.

Exemple de gestionnaire HTTP

L'exemple Python suivant (pour Cloud Run ou Cloud Run Functions) utilise une transaction Firestore avec un bail pour revendiquer des exécutions et dédupliquer les requêtes de job relancées.

Cet exemple n'est pas prêt pour la production. Examinez-le et ajustez-le à vos besoins avant de le déployer :

import datetime
import hashlib
import uuid

import flask
import functions_framework
from google.cloud import firestore

# Longer than the work takes, and at least the job's attempt deadline.
LEASE = datetime.timedelta(minutes=5)
# Keep records past the job's retry window. A TTL policy on expire_at deletes them.
RETENTION = datetime.timedelta(days=1)

db = firestore.Client()


def run_job_logic() -> None:
    """Does the scheduled work. Replace the body with your own task."""


@firestore.transactional
def claim_run(
    transaction: firestore.Transaction,
    run_ref: firestore.DocumentReference,
    attempt_id: str,
    now: datetime.datetime,
) -> str:
    """Claims the run unless it's done or another attempt holds the lease."""
    snapshot = run_ref.get(transaction=transaction)
    if snapshot.exists:
        run = snapshot.to_dict()
        if run["status"] == "completed":
            return "completed"
        if run["lease_expires"] > now:
            return "in_progress"
    transaction.set(
        run_ref,
        {
            "status": "in_progress",
            "attempt_id": attempt_id,
            "lease_expires": now + LEASE,
            "expire_at": now + RETENTION,
        },
    )
    return "claimed"


@firestore.transactional
def finish_run(
    transaction: firestore.Transaction,
    run_ref: firestore.DocumentReference,
    attempt_id: str,
    succeeded: bool,
) -> None:
    """Completes or releases the run, if this attempt still holds the claim."""
    snapshot = run_ref.get(transaction=transaction)
    if not snapshot.exists or snapshot.to_dict().get("attempt_id") != attempt_id:
        return  # A retry claimed the run after this attempt's lease expired.
    if succeeded:
        transaction.update(run_ref, {"status": "completed"})
    else:
        transaction.delete(run_ref)


@functions_framework.http
def handle_scheduled_job(request: flask.Request) -> tuple[str, int]:
    """Runs a job's work once per scheduled run, even if the run is delivered twice."""
    job_name = request.headers.get("X-CloudScheduler-JobName")
    schedule_time = request.headers.get("X-CloudScheduler-ScheduleTime")
    if not job_name or not schedule_time:
        return "error: missing Cloud Scheduler headers", 400

    # Retries keep the same schedule time. A forced run uses the next
    # scheduled time, so that scheduled run is then skipped.
    run_id = hashlib.sha256(f"{job_name}/{schedule_time}".encode()).hexdigest()
    run_ref = db.collection("scheduler_runs").document(run_id)
    now = datetime.datetime.now(datetime.timezone.utc)
    attempt_id = uuid.uuid4().hex

    status = claim_run(db.transaction(), run_ref, attempt_id, now)
    if status == "completed":
        return "Duplicate run skipped", 200
    if status == "in_progress":
        # A non-2xx response keeps retries going in case that attempt fails.
        return "Run in progress", 409

    try:
        run_job_logic()
    except Exception:
        # Releases the claim so that a retry can process the run.
        finish_run(db.transaction(), run_ref, attempt_id, succeeded=False)
        raise
    finish_run(db.transaction(), run_ref, attempt_id, succeeded=True)
    return "Run processed", 200

Empêcher les requêtes en double vers les cibles d'API Google Cloud

Lorsque Cloud Scheduler appelle directement un point de terminaison d'API Google Cloud (*.googleapis.com) via HTTP, l'API cible ignore les en-têtes X-CloudScheduler-JobName et X-CloudScheduler-ScheduleTime. Par exemple, appeler workflowexecutions.googleapis.com pour démarrer une exécution de Workflows, run.googleapis.com pour exécuter un job Cloud Run ou cloudbuild.googleapis.com pour exécuter un déclencheur de compilation Cloud Build démarre une nouvelle exécution à chaque nouvelle tentative.

Pour assurer la fiabilité de la production, appliquez l'idempotence dans le service le plus en aval qui modifie l'état. La déduplication des requêtes uniquement au niveau d'une couche en amont réduit la fenêtre de nouvelle tentative. Toutefois, si une requête en aval aboutit et que la réponse HTTP est perdue sur le réseau, l'appelant en amont ne peut pas déterminer si l'opération est terminée et peut toujours relancer la requête.

Pour éviter les exécutions en double lors de la planification de cibles d'API Google Cloud , utilisez les approches suivantes ensemble pour une idempotence de bout en bout :

  • Acheminez les requêtes via un proxy idempotent : au lieu d'appeler le point de terminaison *.googleapis.com directement depuis Cloud Scheduler, déployez votre propre service Cloud Run ou proxy Cloud Run Functions, puis configurez Cloud Scheduler pour appeler ce proxy. Dans le proxy, implémentez le modèle de revendication transactionnelle atomique Python et Firestore décrit dans ce document, dans Dédupliquer les requêtes dans un gestionnaire HTTP.

    Le proxy lit X-CloudScheduler-JobName et X-CloudScheduler-ScheduleTime, déduplique les requêtes relancées et transmet la clé de déduplication en tant que jeton d'idempotence dans la charge utile de la requête (par exemple, dans le champ argument lors du démarrage d'une exécution Workflows).

    Exigez l'authentification sur le proxy (par exemple, déployez avec --no-allow-unauthenticated) et configurez le job Cloud Scheduler pour qu'il s'authentifie avec un jeton OIDC. Exécutez le proxy sous un compte de service dédié ne disposant que des rôles IAM (Identity and Access Management) minimum requis (par exemple, roles/workflows.invoker sur le workflow cible et roles/datastore.user pour les enregistrements de déduplication).

  • Appliquer l'idempotence dans les services en aval : utilisez le jeton d'idempotence pour effectuer une vérification atomique ou transactionnelle dans un datastore tel que Firestore avant d'exécuter des étapes de changement d'état. Transmettez le jeton à tous les points de terminaison HTTP ou fonctions Cloud Run en aval que votre service appelle, afin que ces cibles puissent également dédupliquer les nouvelles tentatives. Pour obtenir un exemple de bout en bout utilisant Workflows, les transactions Firestore et Cloud Run Functions, consultez l'article de blog Google CloudEffectuer des appels d'API une seule fois lors de l'utilisation de Workflows.