Μετάβαση στο περιεχόμενο

Κύκλος ζωής εκτέλεσης

Αυτή η σελίδα παρακολουθεί μία εκτέλεση μίας εργασίας μέσα από τον κώδικα, σε ένα lease backend. Η εργασία είναι η every('sync', '10s', handler) και η εκτέλεση είναι εκείνη που αναμένεται στις 12:00:10.

sequenceDiagram
  participant T as JobTimers
  participant S as Scheduler
  participant L as LeaseRunner
  participant B as Backend
  participant E as Executor
  participant H as Ο handler σας
  T->>T: arm(): επόμενο slot 12:00:10, χρονόμετρο στο slot + jitter
  Note over T: 12:00:10.310, το χρονόμετρο ενεργοποιείται
  T->>S: fire(job, slot)
  S->>T: arm(): προγραμματισμός 12:00:20 αμέσως
  S->>S: πολύ αργά (now - slot > maxLateMs);
  S->>L: run(job, slot, 'scheduled')
  L->>B: claim(job, slot, token, leaseMs, ...)
  B-->>L: won, προσπάθεια 1
  L->>L: εκκίνηση χρονομέτρου heartbeat
  L->>E: execute(job, slot, run)
  E-->>S: συμβάν: start
  E->>H: handler(ctx)
  loop κάθε heartbeatMs
    L->>B: heartbeat(job, slot, token)
    B-->>L: ok
  end
  H-->>E: επιστρέφει
  E->>B: finish(job, slot, token, 'success')
  E->>B: saveResult(job, result)
  E-->>S: συμβάν: success
  L->>L: διακοπή χρονομέτρου heartbeat

Όταν ξεκινά ο scheduler, καθώς και μετά από κάθε εκτέλεση, η JobTimers.arm() ζητά από το πρόγραμμα της εργασίας το πρώτο slot μετά το «τώρα», και ορίζει ένα χρονόμετρο για slot + τυχαίο jitter.

Τα χρονόμετρα δεν ορίζονται ποτέ για διάστημα μεγαλύτερο από ένα λεπτό. Μια μεγαλύτερη αναμονή χωρίζεται σε επιμέρους διαστήματα, και το ρολόι ελέγχεται ξανά κάθε φορά. Αυτό διατηρεί το χρονόμετρο ακριβές σε βάθος ωρών και επιτρέπει στον scheduler να αντιληφθεί μια αλλαγή ρολογιού.

Η Scheduler.fire() κάνει τρία πράγματα:

  1. Οπλίζει πρώτα την επόμενη εκτέλεση. Το επόμενο slot προγραμματίζεται πριν ξεκινήσει η επεξεργασία του τρέχοντος, ώστε μια χρονοβόρα εργασία να μην καθυστερεί ποτέ το πρόγραμμα.
  2. Ελέγχει τοπικά την καθυστέρηση (lateness). Αν το instance έφτασε σε αυτή την εκτέλεση περισσότερο από maxLateMs μετά το slot της (λόγω παγώματος ή μπλοκαρίσματος του event loop), εκπέμπει skip με αιτιολογία 'late' και σταματά εδώ.
  3. Παραδίδει την εκτέλεση στον runner.

Η LeaseRunner.run() κατασκευάζει τη διεκδίκηση. Εκτός από την εργασία και το slot, περιλαμβάνει ένα νέο τυχαίο token, τη διάρκεια της μίσθωσης (lease length), τον κανόνα αλληλεπικάλυψης (overlap) και το όριο προσπαθειών.

Το backend απαντά με ένα από έξι αποτελέσματα:

flowchart TD
  claim["runs.claim()"] --> o{"αποτέλεσμα"}
  o -- "won" --> exec["εκτέλεση της εργασίας,<br/>ανανεώνοντας τη μίσθωση"]
  o -- "running" --> watch1["skip (taken)<br/>παρακολούθηση της εκτέλεσης ως backup"]
  o -- "overlap" --> watch2["skip (overlap)<br/>παρακολούθηση της παλαιότερης εκτέλεσης,<br/>και αυτής αν τυχόν ξεκινήσει"]
  o -- "late" --> skipLate["skip (late)"]
  o -- "done" --> skipDone["skip (taken)<br/>τίποτα προς παρακολούθηση"]
  o -- "none" --> none["κανείς δεν την ξεκίνησε:<br/>συνέχιση ελέγχων όσο μπορεί να ξεκινήσει"]
  o -- "η κλήση απέτυχε" --> err["συμβάν error"]
Αποτέλεσμα Σημασία Τι κάνει αυτό το instance
won Αυτό το instance κατέχει αυτή την προσπάθεια Εκτελεί τον handler
running Ένα άλλο instance την εκτελεί, και η μίσθωσή του είναι ενεργή Γίνεται εφεδρικός (backup)
overlap Μια παλαιότερη εκτέλεση της εργασίας έχει ακόμα ενεργή μίσθωση Παραλείπει (skip) και παρακολουθεί και τις δύο εκτελέσεις
late Η διεκδίκηση έφτασε στο store υπερβολικά αργά μετά το slot, βάσει του ρολογιού του store Παραλείπει (skip)
done Η εκτέλεση έχει ήδη ολοκληρωθεί ή εξάντλησε τις προσπάθειές της Τίποτα
none Ένα backup ρώτησε για μια εκτέλεση που κανείς δεν έχει διεκδικήσει Συνεχίζει τους ελέγχους για ένα διάστημα

Η executeLeased() ξεκινά ένα χρονόμετρο που στέλνει ένα heartbeat κάθε heartbeatMs, και στη συνέχεια καλεί τον Executor.

Ο Executor:

  1. Εκπέμπει start.
  2. Καλεί τον handler σας με το context του. Το ctx.signal ανήκει αποκλειστικά σε αυτή την εκτέλεση.
  3. Αν έχει οριστεί timeoutMs, θέτει σε αντιπαραβολή (race) τον handler με ένα χρονόμετρο. Όταν κερδίζει το χρονόμετρο, ακυρώνει το signal και θεωρεί την εκτέλεση αποτυχημένη (failed).

Όταν ο handler επιστρέφει ή πετάει εξαίρεση (throws), ο Executor κατηγοριοποιεί την εκτέλεση:

flowchart TD
  ended{"ο handler..."}
  ended -- "επέστρεψε κανονικά" --> success["success"]
  ended -- "πέταξε εξαίρεση" --> why{"ακυρώθηκε το ctx.signal<br/>από stop() ή ανάληψη (takeover);"}
  why -- "όχι" --> failed["failed"]
  why -- "ναι" --> interrupted["interrupted"]
  ended -- "υπερέβη το timeoutMs" --> failed

Μια εξαίρεση μετά από stop() ή ανάληψη (takeover) καταγράφεται ως interrupted, όχι ως failed. Ζητήθηκε από τον handler να σταματήσει· δεν υπήρξε σφάλμα.

Δύο εγγραφές, με αυτή τη σειρά:

  1. finish: σηματοδοτεί τη λήξη της εκτέλεσης στην κατάσταση μίσθωσης, συνοδευόμενη από το token. Το heartbeat συνεχίζει να εκτελείται μέχρι να αποθηκευτεί αυτό: αν το store είναι στιγμιαία μη προσβάσιμο εκείνη τη στιγμή, ένα backup δεν πρέπει να επανεκτελέσει μια εργασία που έχει ήδη ολοκληρωθεί.
  2. saveResult: αποθηκεύει την εγγραφή ιστορικού: κατάσταση, χρόνος έναρξης και λήξης, διάρκεια, και το μήνυμα σφάλματος αν υπήρξε.

Αν η εκτέλεση διακόπηκε από stop(), το πρώτο βήμα είναι ένα release αντί για finish: η μίσθωση λήγει αμέσως, ώστε ένα backup να μπορεί να αναλάβει (takeover) χωρίς αναμονή.

Τέλος, ο Executor εκπέμπει success, failure ή interrupted.

sequenceDiagram
  participant A as Instance A (έχασε)
  participant B as Backend
  A->>B: claim sync @ 12:00:10
  B-->>A: running (προσπάθεια 1)
  Note over A: συμβάν: skip (taken)
  loop κάθε 1 έως 2 heartbeats
    A->>B: claim sync @ 12:00:10, mode 'takeover'
    B-->>A: running: η μίσθωση είναι ενεργή
  end
  A->>B: claim sync @ 12:00:10, mode 'takeover'
  B-->>A: done: η εκτέλεση ολοκληρώθηκε
  Note over A: σταματά την παρακολούθηση

Ο έλεγχος ενός backup είναι μια διεκδίκηση σε λειτουργία ανάληψης (takeover mode): «δώσε μου την επόμενη προσπάθεια αυτής της εκτέλεσης αν η μίσθωσή της έχει λήξει». Όσο η μίσθωση παραμένει ενεργή, η απάντηση είναι running και τίποτα δεν αλλάζει. Ο έλεγχος πραγματοποιείται σε μια τυχαία χρονική στιγμή μεταξύ ενός και δύο heartbeats, ώστε τα backups να μην ρωτούν όλα ταυτόχρονα.

Η διαδρομή είναι συντομότερη. Δεν υπάρχει μίσθωση, επομένως δεν υπάρχουν heartbeats ούτε backups:

sequenceDiagram
  participant S as Scheduler
  participant C as ClaimOnlyRunner
  participant B as Backend
  participant E as Executor
  S->>C: run(job, slot)
  C->>B: claim(job, slot, token)
  alt won
    B-->>C: true
    C->>E: execute(job, slot)
    E->>B: saveResult(job, result)
  else lost
    B-->>C: false
    Note over C: συμβάν: skip (taken)
  end

Σε ένα lease backend, κάθε εργασία περνά από ανάκαμψη (recovery) όταν ξεκινά ο scheduler, πριν ενεργοποιηθεί το πρώτο της χρονόμετρο:

flowchart TD
  start["start()"] --> active["ερώτημα στο store: ποιες εκτελέσεις αυτής της εργασίας είναι ημιτελείς;"]
  active --> each{"για καθεμία"}
  each -- "η μίσθωσή της είναι ενεργή" --> watch["παρακολούθηση ως backup"]
  each -- "η μίσθωσή της έληξε,<br/>και είναι ακόμα η πιο πρόσφατη εκτέλεση" --> take["διεκδίκηση της επόμενης προσπάθειας (takeover)"]
  each -- "η μίσθωσή της έληξε,<br/>αλλά αναμένεται νεότερη εκτέλεση" --> leave["αγνόηση: η επόμενη εκτέλεση την αντικαθιστά"]
  start --> catchup{"αναμενόταν το πιο πρόσφατο slot<br/>πριν από λιγότερο από catchUpMs;"}
  catchup -- "ναι" --> claimc["αναμονή τυχαίου jitter,<br/>έπειτα διεκδίκηση (catch-up)"]
  catchup -- "όχι" --> nothing["τίποτα"]

Αυτό καθιστά ασφαλή μια πλήρη επανεκκίνηση: όποιο instance επιστρέψει πρώτο αναλαμβάνει ό,τι βρισκόταν σε εξέλιξη.