ביטול כפילויות של בקשות למשרות

‫Cloud Scheduler כולל כותרות HTTP של זיהוי משימות וזמן תזמון בבקשות HTTP ו-App Engine, שמאפשרות לבטל כפילויות של הפעלות חוזרות ולהפוך את ה-handlers של היעד לאידמפוטנטיים. מכיוון ש-Cloud Scheduler מספק מסירה של לפחות פעם אחת, ביטול כפילויות של בקשות מונע מה-handler או מממשקי ה-API של Google Cloud במורד הזרם לעבד את אותה הפעלה מתוזמנת יותר מפעם אחת, כשמתרחשות הפסקות זמניות ברשת או כשנגמר הזמן הקצוב לתפוגה.

איך פועלת מניעת כפילויות בבקשות

בקשות כפולות לאותה הרצה מתוזמנת יכולות להתרחש במצבים הבאים:

  • יעדים שפועלים לאורך זמן: לשירות היעד לוקח יותר זמן להגיב מאשר המועד האחרון לניסיון (attemptDeadline) של המשימה, מה שגורם ל-Cloud Scheduler להגיע לזמן הקצוב לתפוגה ולנסות שוב את המשימה בזמן שהניסיון הראשון עדיין פועל. ליעדי HTTP, ערך ברירת המחדל של attemptDeadline הוא 3 דקות, ואפשר להגדיר אותו עד 30 דקות. אם זמן הביצוע יכול לחרוג מ-30 דקות, צריך להתאים את attemptDeadline לזמן הקצוב לתפוגה של היעד, או לעבד עבודה ארוכה באופן אסינכרוני.
  • אישורי רשת שאבדו או ניסיונות חוזרים של שרת proxy זמני: היעד מקבל ומעבד את הבקשה, אבל הפרעה זמנית ברשת או ניתוק של שרת ה-proxy מונעים מ-Cloud Scheduler לקבל את תגובת ה-HTTP 2xx. גם אם מגדירים את retryCount לערך 0, מסירה לפחות פעם אחת פירושה שיעד עדיין יכול לקבל בקשות כפולות במהלך כשלים זמניים בהעברה.

כדי להפוך את ה-handler של המשימה לאידמפוטנטי ולבטל כפילויות של בקשות חוזרות, משתמשים בכותרות ה-HTTP ש-Cloud Scheduler כולל באופן אוטומטי בבקשות ליעדי HTTP ו-App Engine:

כותרת תיאור
X-CloudScheduler-JobName מזהה המשימה הקצר (JOB_ID) מ-projects/PROJECT_ID/locations/LOCATION_ID/jobs/JOB_ID.
X-CloudScheduler-ScheduleTime

במשימות שצוינו בפורמט unix-cron, זמן הביצוע המתוזמן כהיסט של UTC בפורמט RFC 3339 – לדוגמה, 2026-10-16T12:00:00Z או 2026-10-16T05:00:00-07:00. הערך הזה נשאר קבוע בכל ניסיונות החזרה של אותה הרצה מתוזמנת.

הכותרת הזו לא מובטחת למשימות שמשתמשות בפורמט התזמון החלופי של groc, כמו every 5 minutes. אם מפעילים משימה באופן ידני (לדוגמה, על ידי הפעלת gcloud scheduler jobs run או לחיצה על Force a job run במסוף Google Cloud ), הכותרת הזו משתמשת בחותמת הזמן של ההפעלה המתוזמנת הבאה, או שהיא מושמטת. כתוצאה מכך, ביטול כפילויות על סמך כותרות מדלג על ההפעלה המתוזמנת הבאה ועל כל הפעלה ידנית נוספת שמופעלת לפני הזמן המתוזמן.

דרישת אימות בנקודת הקצה (endpoint) של היעד

הכותרות של X-CloudScheduler-* לא מאומתות, וכל גורם ששולח קריאה יכול להגדיר אותן. אל תשתמשו בכותרות האלה לאימות. במקום זאת, צריך לאבטח את נקודת הקצה בהתאם לסוג היעד:

שימוש בחיפושי כותרות לא תלויי-רישיות

האותיות הרישיות בכותרות יכולות להשתנות בין גרסאות HTTP ותשתיות שונות (לדוגמה, X-Cloudscheduler-Jobname ב-HTTP/1.1 או אותיות קטנות x-cloudscheduler-jobname ו-x-cloudscheduler-scheduletime בסביבות HTTP/2 כמו Cloud Run). תמיד צריך להשתמש בחיפושי כותרות לא תלויי-רישיות ב-handler.

הפגת עמימות של מזהי משימות בפרויקטים או באזורים שונים

‫Cloud Scheduler לא שולח באופן אוטומטי כותרות של מזהה פרויקט או אזור. נקודת קצה (endpoint) אחת של handler יכולה לקבל בקשות מכמה פרויקטים או אזורים שחולקים את אותו JOB_ID. במקרה כזה, מעבירים את מזהה הפרויקט או האזור ב-HttpTarget.headers, ב-AppEngineHttpTarget.headers או בנתיב כתובת ה-URL או בפרמטרים של השאילתה, וכוללים את הערכים האלה במפתח ביטול הכפילויות.

ביטול כפילויות של בקשות ב-HTTP handler

לפני שמטמיעים את ביטול הכפילויות של הבקשות ב-handler, צריך לבצע את הפעולות הבאות:

  • מגדירים את לוח הזמנים של המשימה בפורמט unix-cron (לדוגמה, */5 * * * * במקום every 5 minutes) כדי ש-Cloud Scheduler ישלח את הכותרת X-CloudScheduler-ScheduleTime.
  • מגדירים מאגר נתונים קבוע שתומך בטרנזקציות או בכתיבה מותנית אטומית, כמו Firestore,‏ Spanner או Memorystore. מגדירים מדיניות של אורך חיים (TTL) באוסף או בטבלה (לדוגמה, בשדה expire_at) כדי למחוק רשומות ישנות של ביטול כפילויות אחרי חלון הניסיון החוזר של המשימה (maxRetryDuration). מדיניות ה-TTL של Firestore מוחקת רשומות שפג תוקפן באופן אסינכרוני, בדרך כלל תוך 24 עד 72 שעות. כדי לאפשר לניסיונות החוזרים להחזיר את ההרצות ממכונות שקרסו לפני ניקוי ה-TTL, צריך גם לאחסן חותמת זמן של תפוגת החכירה (כמו lease_expires, שמוגדרת לפחות לזמן הקצוב לתפוגה של הבקשה של היעד ו-attemptDeadline) ולהעריך אותה בטרנזקציה.

כדי לעבד כל הפעלה מתוזמנת פעם אחת בלבד ב-HTTP handler:

  1. קוראים את X-CloudScheduler-JobName ו-X-CloudScheduler-ScheduleTime מכותרות ה-request header הנכנסת ומגבבים אותם למפתח ביטול כפילויות באורך קבוע (run_id).
  2. מריצים עסקה במאגר הנתונים כדי לבדוק את הרשומה של מפתח ביטול הכפילויות ולתבוע עליה בעלות:
    • אם הרשומה קיימת והערך של status הוא completed, מחזירים מיד קוד סטטוס HTTP 200 OK (או 204 No Content) בלי להריץ את הלוגיקה של המשימה, כדי ש-Cloud Scheduler יפסיק לנסות שוב.
    • אם הרשומה קיימת, הערך של status הוא in_progress וההשכרה (lease_expires) לא הסתיימה, מחזירים קוד סטטוס של שגיאה שניתן לנסות שוב, כמו 409 Conflict, כדי ש-Cloud Scheduler ימשיך להפעיל את מדיניות הניסיונות החוזרים במקרה שהניסיון הנוכחי ייכשל. הימנעו מהחזרת 503 Service Unavailable מיעדי App Engine, כי App Engine מתייחס ל-503 כשגיאת מערכת ומנסה שוב לשלוח את הבקשה בלי לספור אותה כחלק מ-retryCount.
    • אם הרשומה לא קיימת או שתוקף ההשכרה שלה פג, כותבים את הרשומה עם status שמוגדר ל-in_progress, מזהה ניסיון ייחודי (attempt_id), חותמת זמן של תפוגת ההשכרה (lease_expires) וחותמת זמן של תפוגת TTL‏ (expire_at).
  3. אחרי שתובעים בעלות על הרשומה, מריצים את הלוגיקה של המשימה. בעסקה, מוודאים שהערך attempt_id של הרשומה עדיין תואם לניסיון הנוכחי לפני שמשנים את הרשומה:

    • אם הלוגיקה של המשימה מצליחה, מעדכנים את status ל-completed ומחזירים קוד סטטוס HTTP 200 OK.
    • אם לוגיקת המשימה נכשלת עם שגיאה שאפשר לנסות שוב, מחקו את רשומת ביטול הכפילויות לפני החזרת קוד סטטוס 5xx, כדי ש-Cloud Scheduler יוכל לנסות שוב את ההרצה בלי להמתין עד שתוקף ההשכרה יפוג.

דוגמה ל-HTTP handler

בדוגמה הבאה ב-Python (ל-Cloud Run או לפונקציות Cloud Run) נעשה שימוש בטרנזקציה של Firestore עם השכרה כדי לתבוע הרצות ולבטל כפילויות של בקשות חוזרות למשימות.

הדוגמה הזו לא מוכנה לייצור. לפני הפריסה, צריך לבדוק את הקוד ולהתאים אותו לצרכים שלכם:

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

מניעת בקשות כפולות ליעדי API של Google Cloud

כש-Cloud Scheduler מפעיל נקודת קצה ל-API של Google Cloud (*.googleapis.com) ישירות דרך HTTP, ה-API של היעד מתעלם מהכותרות X-CloudScheduler-JobName ו-X-CloudScheduler-ScheduleTime. לדוגמה, קריאה ל-workflowexecutions.googleapis.com כדי להתחיל הרצה של Workflows, ל-run.googleapis.com כדי להריץ משימה של Cloud Run או ל-cloudbuild.googleapis.com כדי להריץ טריגר לפיתוח גרסת Build של Cloud Build, תתחיל הרצה חדשה בכל ניסיון חוזר.

כדי לשמור על אמינות בסביבת הייצור, צריך לאכוף אידמפוטנטיות בשירות שנמצא הכי נמוך בשרשרת ומשנה את המצב. אם מבטלים כפילויות של בקשות רק בשכבה במעלה הזרם, חלון הניסיונות החוזרים מצטמצם. אבל אם בקשה במורד הזרם מצליחה ותגובת ה-HTTP אובדת ברשת, המתקשר במעלה הזרם לא יכול לקבוע אם הפעולה הושלמה, ויכול להיות שהוא עדיין ינסה שוב לשלוח את הבקשה.

כדי למנוע הפעלות כפולות כשמתזמנים יעדי API ב- Google Cloud , השתמשו בגישות הבאות יחד כדי להשיג אידמפוטנטיות מקצה לקצה:

  • ניתוב בקשות דרך שרת proxy אידמפוטנטי: במקום לשלוח קריאה לנקודת הקצה (endpoint) של *.googleapis.com ישירות מ-Cloud Scheduler, אתם יכולים לפרוס שירות Cloud Run או שרת proxy של פונקציית Cloud Run משלכם, ולהגדיר את Cloud Scheduler כך שיפעיל את שרת ה-proxy הזה. בשרת ה-proxy, מטמיעים את דפוס התביעה (claim) האטומי של טרנזקציות ב-Python וב-Firestore שמתואר במסמך הזה, בקטע ביטול כפילויות של בקשות ב-HTTP handler.

    שרת ה-proxy קורא את X-CloudScheduler-JobName ואת X-CloudScheduler-ScheduleTime, מסיר כפילויות של בקשות חוזרות ומעביר את מפתח הסרת הכפילויות כאסימון אידמפוטנטיות במטען הייעודי (payload) של הבקשה (לדוגמה, בשדה argument כשמתחילים הפעלה של Workflows).

    דורשים אימות בשרת ה-proxy (לדוגמה, פורסים באמצעות --no-allow-unauthenticated) ומגדירים את המשימה של Cloud Scheduler כך שתאמת באמצעות אסימון OIDC. מריצים את שרת ה-proxy בחשבון שירות ייעודי עם תפקידי ניהול הזהויות והרשאות הגישה (IAM) המינימליים הנדרשים (כמו roles/workflows.invoker בתהליך העבודה של היעד ו-roles/datastore.user לרשומות של ביטול כפילויות).

  • אכיפת אידמפוטנטיות בשירותים במורד הזרם: משתמשים באסימון האידמפוטנטיות כדי לבצע בדיקה אטומית או טרנזקציונלית במאגר נתונים כמו Firestore, לפני שמריצים שלבים שמשנים את המצב. מעבירים את האסימון לכל נקודות הקצה של HTTP או לפונקציות Cloud Run שהשירות מפעיל, כדי שגם היעדים האלה יוכלו לבטל כפילויות של ניסיונות חוזרים. בפוסט בבלוג Google Cloud, ביצוע קריאות ל-API פעם אחת בדיוק כשמשתמשים ב-Workflows, תוכלו לקרוא דוגמה מקצה לקצה לשימוש ב-Workflows, בטרנזקציות של Firestore ובפונקציות של Cloud Run.