ข้ามไปยังเนื้อหา
Tayakorn

← คู่มือ Airflow — ทำไมงานหลายขั้นต้องมีคนคุมลำดับ

LEVEL 2 · ระดับกลาง

รันซ้ำแล้วต้องได้ผลเท่าเดิม

ทุกเครื่องมือของสามบทที่ผ่านมาคือการรันซ้ำ · retry รันขั้นที่พังซ้ำ · clear รันช่องที่ล้างซ้ำ — บทที่แล้วมันส่งข้อความเช้าไปสองฉบับ · backfill ของบทที่ 13 จะย้อนรันเดือนเก่าซ้ำ · และเช้าวันที่ 1 มิถุนายนของบทที่ 1 คุณรันทั้งสคริปต์ซ้ำเอง · อาการถูกเผยแล้วในเฉลยข้อ 3 ของบทที่ 8: รายงานบอก 951 ชิ้น แต่ "ในคลังเดือนนี้ 198 แถว" · บทนี้ตอบว่าทำไมตัวคุมกันให้ไม่ได้ และทำยังไงให้การรันซ้ำทุกแบบปลอดภัยในคราวเดียว

ตัวคุมรู้ว่าช่องไหนเสร็จแล้ว แต่ไม่รู้ว่าขั้นนั้นเขียนอะไรออกไปนอกตาราง — ทำให้รันซ้ำแล้วได้ผลเท่าเดิมเป็นงานของคนเขียนขั้น

นี่คือความจริงข้อสามของเล่ม (หัวข้อ 1.4) · หน้า Best Practices ทางการบอกให้มองงานแต่ละขั้น "equivalent to transactions in a database" — ไม่ทิ้งผลครึ่ง ๆ กลาง ๆ และรันซ้ำได้ผลเดิม · ชื่อทางเทคนิคของคุณสมบัตินี้คือ idempotency

12.1 ตารางเห็นแค่ "เสร็จ" สองครั้ง

ตารางของตัวคุมรอบที่ 1รอบที่ 2transformsuccess 1success 1loadsuccess 1success 1notifysuccess 1success 1เหมือนกันทุกช่อง — “เสร็จ” สองครั้งคลัง — แถวเดือนกรกฎาคมต่อท้าย (บทที่ 8)99หลังรอบที่ 1198หลังรอบที่ 2เขียนทับทั้งเดือน (บทที่ 12)99หลังรอบที่ 199หลังรอบที่ 2
FIG 12.1 — สิ่งที่ตัวคุมเห็นกับสิ่งที่ load ทิ้งไว้ · ตารางเหมือนกันทุกช่องทั้งสองรอบ — มันจำแค่ว่าช่องเสร็จ · คลังคือสิ่งที่ขั้นเขียนออกไปนอกตาราง: ต่อท้าย 99 → 198 (เฉลยข้อ 3 ของบทที่ 8) · เขียนทับทั้งเดือน 99 → 99 (หัวข้อ 12.3) · ความต่างนี้ไม่มีทางโผล่ในตาราง

รันซ้ำในตารางของบทที่ 4 คือคอลัมน์ใหม่ (หรือช่องที่ถูกล้าง) และ load ก็เป็น success 1 อีกครั้ง — ตารางสวยทั้งสองคอลัมน์ · แต่ load ของบทที่ 8 ทำ INSERT ต่อท้าย ของในคลังจึงสะสม 99 แล้ว 198 · ไม่มีช่องไหนจำว่าแถวพวกนั้นมาจากรอบไหน และวงรอบก็ไม่ได้ถาม

⚠️ ความเข้าใจผิด: "Airflow ไม่รันซ้ำขั้นที่สำเร็จแล้ว ใช้ INSERT ได้" — หน้า Best Practices ทางการต้องเตือนตรงตัวว่าห้ามใช้ INSERT ในขั้นที่อาจถูกรันซ้ำ เพราะได้แถวซ้ำ · ผิดเพราะ "สำเร็จแล้ว" ไม่ได้กันการรันซ้ำ: clear ในบทที่ 11 รัน morning ที่สำเร็จแล้วซ้ำต่อหน้าคุณ และสั่งรันเดือนเดิมอีกครั้งก็คือคอลัมน์ใหม่ทั้งคอลัมน์ · กลไกที่ถูกคือให้สิ่งที่ขั้นเขียนออกไปขึ้นกับข้อมูลของงวดนั้นอย่างเดียว ไม่ขึ้นกับว่าเคยรันมากี่ครั้ง · พิสูจน์ที่หัวข้อ 12.3

12.2 เขียนทับทั้งเดือน ในธุรกรรมเดียว

ลองเดาก่อน: ฐานข้อมูลตัดการเชื่อมต่อหลังเขียนไปได้ 50 จาก 99 แถว แล้วคุณรันใหม่ · ตัวโหลดแบบต่อท้ายจะเหลือกี่แถว

append คือแบบบทที่ 8 (บันทึกทีละแถวเหมือนสคริปต์ของบทที่ 1) · replace_month ลบของเดือนนั้นแล้วเขียนใหม่ทั้งเดือน ภายใน with db: ซึ่งของ sqlite3 คือธุรกรรมเดียว — จบปกติ = บันทึกทั้งก้อน · มี error กลางทาง = ย้อนทั้งก้อนเหมือนไม่เคยเกิด

# ~/airflow-lab/dags/stock_load.py — โหลดที่รันซ้ำได้: เขียนทับทั้งเดือนในธุรกรรมเดียว (ฟังก์ชันธรรมดา ไม่ import Airflow)
import json
import sqlite3
from pathlib import Path


def append(db, month, rows):
    """แบบบทที่ 8: ต่อท้ายทีละแถว — ทุกแถวที่เขียนแล้วอยู่ถาวรทันที"""
    for row in rows:
        db.execute("INSERT INTO stock VALUES (?, ?, ?, ?, ?)", [month, *row])
        db.commit()


def replace_month(db, month, rows):
    """ลบของเดือนนี้ทั้งหมดแล้วเขียนใหม่ทั้งเดือน ในธุรกรรมเดียว: สำเร็จทั้งก้อน หรือเหมือนไม่เคยเกิด"""
    with db:  # ← ออกจาก with ปกติ = commit · มี error กลางทาง = rollback ทั้งก้อน
        db.execute("DELETE FROM stock WHERE month = ?", [month])
        db.executemany("INSERT INTO stock VALUES (?, ?, ?, ?, ?)", ([month, *row] for row in rows))


def load_month(summary, db_path, month):
    """ขั้น load ของท่อ — ผลขึ้นกับเดือนกับไฟล์ของเดือนนั้นเท่านั้น รันกี่ครั้งก็ได้ตารางเดียวกัน"""
    rows = json.loads(Path(summary["path"]).read_text(encoding="utf-8"))
    db = sqlite3.connect(db_path)
    db.execute("CREATE TABLE IF NOT EXISTS stock (month, branch, code, qty, hidden)")
    replace_month(db, month, rows)
    return db.execute("SELECT COUNT(*) FROM stock WHERE month = ?", [month]).fetchone()[0]


if __name__ == "__main__":  # เทียบสองแบบโดยไม่มี Airflow: พังกลางการเขียน แล้วรันใหม่ รันซ้ำ
    rows = [["a", f"S{i:03d}", 3, False] for i in range(1, 100)]  # 99 แถว เท่าเดือนกรกฎาคม

    def cut_at(n):  # ฐานข้อมูลตัดการเชื่อมต่อหลังเขียนไปได้ n แถว
        for i, row in enumerate(rows):
            if i == n:
                raise ConnectionError("ฐานข้อมูลตัดการเชื่อมต่อ")
            yield row

    for write in (append, replace_month):
        db = sqlite3.connect(":memory:")
        db.execute("CREATE TABLE stock (month, branch, code, qty, hidden)")
        seen = []
        for label, data in [("พังกลางทาง", cut_at(50)), ("รันใหม่", rows), ("รันซ้ำ", rows), ("พังกลางทางอีก", cut_at(50))]:
            try:
                write(db, "2026-07", data)
            except ConnectionError:
                pass
            seen.append(f"{label} {db.execute('SELECT COUNT(*) FROM stock').fetchone()[0]} แถว")
        print(f"{write.__name__:<14}" + " → ".join(seen))

ผลรันจริง

append        พังกลางทาง 50 แถว → รันใหม่ 149 แถว → รันซ้ำ 248 แถว → พังกลางทางอีก 298 แถว
replace_month พังกลางทาง 0 แถว → รันใหม่ 99 แถว → รันซ้ำ 99 แถว → พังกลางทางอีก 99 แถว
  • append ผิดทุกช่วงหลังพังครั้งแรก — ครึ่งที่เขียนแล้วค้าง รันใหม่ก็ต่อท้ายทั้งก้อน · นี่คือ 1,480 ชิ้นของบทที่ 1 และคำตอบของคำถามท้ายบทที่ 11: clear load ที่พังกลางการเขียน = เบิ้ลครึ่งหนึ่ง
  • replace_month พังแล้วเหลือ 0 ไม่มีครึ่งค้าง · รันใหม่ 99 รันซ้ำ 99 · พังอีกรอบยัง 99 เพราะการลบของเก่าอยู่ในธุรกรรมเดียวกับการเขียนของใหม่ พังจึงย้อนกลับไปเป็นฉบับก่อนหน้า ไม่ใช่ว่างเปล่า

Best Practices แนะนำ UPSERT · UPSERT ด้วยคีย์ (เดือน, สาขา, รหัส) ก็รันซ้ำได้เท่าเดิม แต่ถ้าสาขาส่งไฟล์แก้ที่ลบสินค้าออกหนึ่งรายการ แถวเก่าจะค้างเพราะไม่มีแถวใหม่มาทับ · เขียนทับทั้งเดือนได้ผลเท่ากับไฟล์ล่าสุดของเดือนนั้นพอดี ทั้งเพิ่ม แก้ และลบ

12.3 ต่อสาย แล้วรันซ้ำจริง

ฉบับนี้ต่างจากบทที่ 11 สองบรรทัด: import stock_load และ load เรียก stock_load.load_month แทน steps.load

# ~/airflow-lab/dags/stock_monthly.py — บทที่ 12: load เขียนทับทั้งเดือน รันซ้ำแล้วได้ผลเท่าเดิม
from datetime import datetime, timedelta
from pathlib import Path

import stock_gate
import stock_load
import stock_steps as steps
from airflow.sdk import dag, task
from airflow.sdk.exceptions import AirflowFailException, AirflowSkipException

LAB = Path.home() / "airflow-lab"


@dag(
    schedule=None,
    start_date=datetime(2026, 5, 1),
    catchup=False,
    params={"month": "2026-07"},
    default_args={"retries": 2, "retry_delay": timedelta(seconds=20)},  # แล็บรอ 20 วินาที ของจริงหลายนาที
)
def stock_monthly():
    @task
    def arrived(**context):
        month = context["params"]["month"]
        files = sorted((LAB / "inbox").glob(f"stock_{month}_*.xlsx"))
        if not files:  # ยังไม่มีไฟล์ของเดือนนี้สักสาขา — ไม่มีอะไรให้ทำ ไม่ใช่ความผิดของใคร
            raise AirflowSkipException(f"ยังไม่มีไฟล์ของเดือน {month}")
        return len(files)

    @task
    def receive(branch, **context):
        month = context["params"]["month"]
        path = LAB / "inbox" / f"stock_{month}_{branch}.xlsx"
        if not path.exists():  # ไฟล์มาช้า — ชั่วคราว ปล่อยให้ retries ลองใหม่
            raise FileNotFoundError(f"ไฟล์สาขา {stock_gate.NAMES[branch]} เดือน {month} ยังมาไม่ถึง")
        rows, missing = steps.read_branch(path)
        staged = steps.stage(branch, rows, missing, LAB / "staging" / month / f"{branch}.json")
        return {**staged, "problems": stock_gate.inspect(path, rows, missing, month)}

    @task
    def gate(staged):
        for s in staged:
            print(f"ด่าน ← receive_{s['branch']}: {s['rows']} แถว · ปัญหา {len(s['problems'])} ข้อ")
        try:
            return stock_gate.check(staged)
        except stock_gate.BadFile as e:  # ไฟล์ผิดรูป ลองกี่ครั้งก็ไม่หาย — หยุดเลย ไม่ใช้ retries ที่เหลือ
            raise AirflowFailException(str(e)) from e

    @task
    def transform(staged, **context):
        return steps.transform(staged, LAB / "staging" / context["params"]["month"] / "all.json")

    @task
    def load(summary, **context):
        return stock_load.load_month(summary, LAB / "warehouse.db", context["params"]["month"])  # ← เดิม steps.load

    @task
    def notify(staged, summary, in_db):
        print(steps.report(staged, summary, in_db))

    @task(trigger_rule="all_done")  # ส่งทุกเช้า ไม่ว่าท่อจะผ่านหรือพัง — แบบ notify ของบทที่ 7
    def morning():
        print("morning: ส่งข้อความเช้าแล้ว")

    @task(trigger_rule="one_failed")  # watcher: มีขั้นไหนพัง ขั้นนี้ได้รันแล้วพังเสมอ
    def watcher():
        raise AirflowFailException("มีขั้นที่พังในรอบนี้")

    found = arrived()
    received = [receive.override(task_id=f"receive_{b}")(b) for b in "abc"]
    found >> received
    staged = gate(received)
    summary = transform(staged)
    loaded = load(summary)
    done = notify(staged, summary, loaded)
    note = morning()
    done >> note
    [found, *received, staged, summary, loaded, done, note] >> watcher()  # ต่อจากทุกขั้น


stock_monthly()

วาง stock_load.py ไว้ใน dags/ ด้วย แล้วรันเดือนกรกฎาคมสองครั้งติดกันโดยไม่ล้างอะไร

for i in 1 2; do airflow dags test stock_monthly 2>&1 | grep "ในคลัง"; done

ผลรันจริง

สต็อกรวม 951 ชิ้น (ก 121 · ข 382 · ค 448) จาก 99 แถว · ในคลังเดือนนี้ 99 แถว
สต็อกรวม 951 ชิ้น (ก 121 · ข 382 · ค 448) จาก 99 แถว · ในคลังเดือนนี้ 99 แถว

สองตัวเลขที่ขัดกันในเฉลยข้อ 3 ของบทที่ 8 กลับมาตรงกันทั้งสองรอบ

12.4 กรณีที่ร้ายกว่า: ตัวนำ⁠เข้าที่ถูกเฉพาะเดือนล่าสุด

ตัวโหลดแบบต่อท้ายพังแบบเห็นได้ ตัวเลขเบิ้ล · ที่อันตรายกว่าคือตัวนำเข้าที่ถูกทุกครั้งที่รันกับเดือนล่าสุด แล้วกลายเป็นตัวทำลายเมื่อรันกับเดือนเก่า

⚠️ ความเข้าใจผิด: "ตัวนำเข้ารายเดือนรันซ้ำกับเดือนเก่าเพื่อเติมหรือแก้ข้อมูลย้อนหลังได้" — ผู้เขียนเจอรูปร่างนี้ในงานจริง: ตัวนำเข้าที่ปิดสถานะของที่ไม่อยู่ในไฟล์ ถูกทุกเดือน จนวันที่มีคนเอาไปรันกับเดือนเก่า · ผิดเพราะมันไม่ได้เป็นฟังก์ชันของเดือนที่ส่งให้ แต่เป็นฟังก์ชันของ "สถานะล่าสุด" ซึ่งเดือนเก่าไม่ใช่ · กลไกที่ถูกคือเก็บข้อมูลแยกตามงวด แล้วคิดสถานะล่าสุดตอนอ่าน · พิสูจน์ที่บล็อกข้างล่าง

สาขา ข มีสินค้า 33 รายการเดือนกรกฎาคม 36 รายการเดือนสิงหาคม · แบบ "สถานะล่าสุด" ถือว่าอยู่ในไฟล์ = ขายอยู่ ไม่อยู่ = เลิกขาย · แบบ "ฟังก์ชันของงวด" เขียนทับเฉพาะแถวของเดือนนั้น แล้วคิด "ขายอยู่" จากเดือนล่าสุดตอนอ่าน · นำเข้ากรกฎาคม → สิงหาคม → แล้วมีคนรันกรกฎาคมซ้ำเพื่อแก้ข้อมูล

import sqlite3

# รหัสสินค้าในไฟล์ของสาขา ข: กรกฎาคม 33 รายการ · สิงหาคมเพิ่มหมวดใหม่อีก 3 (S034–S036) แบบไฟล์ปลอมของบทที่ 8
FILES = {
    "2026-07": [f"S{i:03d}" for i in range(1, 34)],
    "2026-08": [f"S{i:03d}" for i in range(1, 37)],
}


def import_latest(db, month):
    """ตัวนำเข้าแบบ "สถานะล่าสุด": ของที่อยู่ในไฟล์ = ขายอยู่ · ของที่ไม่อยู่ในไฟล์ = เลิกขาย"""
    codes = FILES[month]
    with db:
        db.executemany("INSERT OR REPLACE INTO items VALUES (?, 1)", [[c] for c in codes])
        db.execute(f"UPDATE items SET selling = 0 WHERE code NOT IN ({','.join('?' * len(codes))})", codes)


def import_period(db, month):
    """ตัวนำเข้าแบบ "ฟังก์ชันของงวด": เขียนทับเฉพาะแถวของเดือนนั้น · "ขายอยู่" คิดจากเดือนล่าสุดตอนอ่าน"""
    with db:
        db.execute("DELETE FROM listed WHERE month = ?", [month])
        db.executemany("INSERT INTO listed VALUES (?, ?)", [[month, c] for c in FILES[month]])


def selling_latest(db):
    return db.execute("SELECT COUNT(*) FROM items WHERE selling = 1").fetchone()[0]


def selling_period(db):
    return db.execute("SELECT COUNT(*) FROM listed WHERE month = (SELECT MAX(month) FROM listed)").fetchone()[0]


db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE items (code PRIMARY KEY, selling)")
db.execute("CREATE TABLE listed (month, code)")
for label, month in [("นำเข้ากรกฎาคม", "2026-07"), ("นำเข้าสิงหาคม", "2026-08"), ("รันกรกฎาคมซ้ำ", "2026-07")]:
    import_latest(db, month)
    import_period(db, month)
    print(f"{label:<14} → ขายอยู่ตามแบบสถานะล่าสุด {selling_latest(db)} · ตามแบบงวด {selling_period(db)}")

ผลรันจริง

นำเข้ากรกฎาคม  → ขายอยู่ตามแบบสถานะล่าสุด 33 · ตามแบบงวด 33
นำเข้าสิงหาคม  → ขายอยู่ตามแบบสถานะล่าสุด 36 · ตามแบบงวด 36
รันกรกฎาคมซ้ำ  → ขายอยู่ตามแบบสถานะล่าสุด 33 · ตามแบบงวด 36
แบบสถานะล่าสุดนำเข้ากรกฎาคม33ขายอยู่นำเข้าสิงหาคม36ขายอยู่รันกรกฎาคมซ้ำ33ขายอยู่แบบฟังก์ชันของงวดนำเข้ากรกฎาคม33ขายอยู่นำเข้าสิงหาคม36ขายอยู่รันกรกฎาคมซ้ำ36ขายอยู่เส้นประ: หลัง S033 · S034–S036 = หมวดใหม่เดือนสิงหาคมขายอยู่เลิกขาย
FIG 12.2 — สาขา ข มีสินค้า 33 รายการเดือนกรกฎาคม 36 รายการเดือนสิงหาคม · แบบสถานะล่าสุด ถูกทุกครั้งที่รันตามลำดับ แต่พอรันกรกฎาคมซ้ำ สินค้าใหม่สามรายการถูกปิดเป็นเลิกขาย · แบบงวด เขียนทับเฉพาะแถวของเดือนนั้น แล้วคิด “ขายอยู่” จากเดือนล่าสุดตอนอ่าน — รันเดือนไหนซ้ำก็ไม่แตะเดือนอื่น

สองบรรทัดแรกเหมือนกันทุกตัว — ทดสอบทีละเดือนตามลำดับไม่มีวันเจอ · บรรทัดที่สาม สินค้าใหม่สามรายการถูกปิดเป็น "เลิกขาย" ทั้งที่ยังขายอยู่ ไม่มี error และไม่มีตัวเลขเบิ้ลให้สะดุดตา

ทุกขั้นต้องเป็นฟังก์ชันของช่วงข้อมูลที่ถูกบอกมา — ไม่ใช่ของสถานะล่าสุด และไม่ใช่ของเวลาที่รัน

Best Practices ทางการเขียนเรื่องเดียวกันไว้สองข้อ: อ่านและเขียนในงวดที่ระบุ อย่าอ่าน "ข้อมูลล่าสุดที่มี" · และห้ามใช้ now() ตัดสินอะไรสำคัญในขั้น · ท่อนี้ส่งเดือนให้ทุกขั้นผ่าน params ตั้งแต่บทที่ 8 — ทำไมไม่ใช้วันที่ของรอบแทน คือบทถัดไป

12.5 ไล่ทั้งท่อ: ขั้นไหนรันซ้ำได้แล้ว

ขั้นเขียนอะไรออกไปนอกตารางรันซ้ำกับเดือนเดิมarrived · gateไม่เขียนอะไรออกไป — อ่านอย่างเดียวรันซ้ำได้เสมอreceive · transformไฟล์ทับชื่อเดิม staging/<เดือน>/…jsonรันซ้ำได้ ตั้งแต่บทที่ 8loadบทที่ 8INSERT ต่อท้ายตาราง stockเบิ้ล · พังกลางทางค้างครึ่งloadบทที่ 12ลบแล้วเขียนทั้งเดือน ในธุรกรรมเดียวรันซ้ำได้ · พังแล้วย้อนกลับnotify · morningข้อความถึงคนเรียกคืนไม่ได้ — ทำได้แค่กันส่งซ้ำ
FIG 12.3 — ทุกขั้นของ stock_monthly ตอบคำถามเดียว: รันซ้ำกับเดือนเดิม โลกข้างนอกต่างจากรันครั้งเดียวไหม · ขั้นที่อ่านอย่างเดียว หรือเขียนทับของที่มีเดือนเป็นชื่อ รันซ้ำได้ · load เปลี่ยนฝั่งในบทนี้ · ข้อความที่ส่งถึงคน ทำให้ “เท่าเดิม” ไม่ได้ ต้องกันเอา · retry clear backfill ปลอดภัยเท่าขั้นที่อ่อนที่สุดในรายการนี้

ถามทุกขั้นคำถามเดียว: ถ้าขั้นนี้ถูกรันซ้ำกับเดือนเดิม โลกข้างนอกต่างจากรันครั้งเดียวไหม

  • arrived · gate อ่านอย่างเดียว — รันซ้ำได้เสมอ
  • receive · transform เขียนไฟล์ทับชื่อที่มีเดือนอยู่ใน path — รันซ้ำได้ตั้งแต่บทที่ 8 โดยไม่ได้ตั้งใจ · นี่คือคำตอบของปริศนาข้อ 3 ในหัวข้อ 4.7: transform ที่ถูกทำซ้ำหลังตัวรันตาย เขียนทับไฟล์ครึ่ง ๆ กลาง ๆ ของตัวเองพอดี
  • load เขียนทับทั้งเดือนในธุรกรรมเดียว — ตั้งแต่บทนี้
  • notify · morning ส่งข้อความ ซึ่งเรียกคืนไม่ได้ — ทำได้แค่กัน: จดว่าส่งของเดือนไหนไปแล้วแล้วเช็กก่อนส่ง หรือยอมให้ซ้ำแต่บอกว่าเป็นฉบับแก้ของเดือนไหน

ปริศนาของบทที่ 10 ตอบได้แล้ว · retry คือการรันขั้นซ้ำอัตโนมัติ และตัวคุมไม่รู้ว่าขั้นของคุณอยู่แถวไหนในรายการข้างบน · ไม่ลองซ้ำเลยจึงเป็นค่าเดียวที่ไม่เพิ่มความเสียหายให้ขั้นที่ยังไม่มีใครไล่ · เปิด retries ให้ขั้นที่ไล่แล้วว่ารันซ้ำได้ — และนี่คือเหตุผลที่ retry · clear · backfill ใช้ได้: ทุกขั้นเป็นฟังก์ชันของเดือนที่ถูกบอกมา รันซ้ำกี่ครั้งด้วยเหตุอะไรก็ได้คลังเดียวกัน

ลองตอบโดยไม่ย้อนกลับไปอ่าน (เฉลยท้ายเล่ม)

  1. ขั้นใหม่: ส่งอีเมลยอดสต็อกรวมของเดือนให้ผู้จัดการ · ทำให้รันซ้ำได้ปลอดภัยได้ไหม ถ้าไม่ได้ จะกันยังไงไม่ให้ผู้จัดการได้อีเมลเดือนเดียวกันสองฉบับเมื่อมีคน clear
  2. ตอนนี้ load รันซ้ำได้แล้ว · ควรตั้ง retries ให้ load สูงขึ้นไหม แล้ว notify ล่ะ — ตอบด้วยเหตุผลของบทที่ 10 กับบทนี้
  3. ถ้าสคริปต์ก้อนเดียวของบทที่ 1 โหลดด้วยแบบ replace_month เช้าวันที่ 1 มิถุนายนที่รันใหม่ทั้งก้อน รายงานจะบอกกี่ชิ้น · และสคริปต์นั้นยังขาดอะไรจากสามคำถามของบทที่ 1

อ่านแบบเต็มเล่ม