משימות מתוזמנות (Cron)

איך WIZZO CMS מריץ משימות מתוזמנות: שורת crontab אחת, cron_runner, טבלאות המשימות והלוגים, מסך cron_manager וכתיבת controller למשימה.

⏱ 9 דק' קריאה 1533 מילים ערוך דף זה ב-GitHub

WIZZO CMS לא דורש שורת crontab לכל משימה. מוסיפים לשרת שורה אחת שמפעילה את cron_runner בכל דקה, וכל השאר (איזו משימה, מתי, באיזה פרמטרים ומה היה הפלט) מנוהל בטבלאות ובמסך ניהול. בעמוד הזה תראו איך ההרצה עובדת, איך מגדירים משימה, איך כותבים controller שמתאים לריצה מתוזמנת ואילו מלכודות כדאי להכיר.

מונחים בעמוד
system/ בעמוד הזה הוא תיקיית הליבה הפרוסה (api/core בריפו), ו-{admin} הוא CONFIG::$admin_url.

שורת ה-crontab#

השורה היחידה שנדרשת בשרת:

* * * * * /usr/local/bin/php /home/site/public_html/index.php url=system/cron_runner tk=<token> >/dev/null 2>&1
  • הנתיב ל-PHP הוא של ה-CLI שמתאים לגרסת האתר (הליבה דורשת PHP 8.3 ומעלה, ראו דרישות מערכת).
  • index.php הוא נקודת הכניסה הרגילה של האתר. במצב CLI הארגומנטים בצורת key=value הופכים ל-$_GET, וה-url= הופך ל-REQUEST_URI (זה עושה MISC::cli_check).
  • <token> הוא הערך של הפרמטר cron_runner_token (ראו למטה). אל תפרסמו אותו.

מסך {admin}/cron_manager/setup_cron מציג את השורה המדויקת לאתר שלכם, כולל נתיב ה-PHP שזוהה. הזיהוי (MISC::detect_cli_php()) בודק לפי הסדר:

  1. /usr/local/bin/php
  2. /usr/bin/php
  3. בינאריים של cPanel: /opt/cpanel/ea-php85/root/usr/bin/php ואחריו 84, 83, 82, 81, 80 ו-74
  4. PHP_BINARY של הבקשה הנוכחית, אם שם הקובץ אינו lsphp ואינו מכיל fpm
  5. ברירת מחדל: /usr/local/bin/php
ב-cPanel בדקו את גרסת ה-CLI

ב-cPanel ה-php הכללי הוא לעיתים גרסה ישנה. אם הליבה לא עולה מ-CLI, השתמשו בנתיב המפורש של ea-php83 ומעלה.

הטוקן#

הטוקן נשמר בטבלת CRM_params תחת sysName = cron_runner_token, עם perm_developer = 1 (כך הוא מוסתר ממשתמשי ניהול שאינם מפתחים, ראו פרמטרים ושפות). הוא נוצר אוטומטית בכניסה הראשונה למסך cron_manager (get_or_create_token()): 32 תווים הקסדצימליים מ-random_bytes(16).

בדיקת הטוקן ב-cron_runner נעשית עם hash_equals, מול PARAMS::get('cron_runner_token') ועם קריאה ישירה לטבלה כגיבוי. טוקן חסר או שגוי מחזיר ERROR: invalid or missing token.

מה קורה בכל דקה#

cron_runner::__construct קובע $_GET["pmode"] = "empg" (פלט חשוף, בלי תבנית של אתר). אחר כך index() עושה:

  1. בודק את הטוקן.
  2. מעדכן את cron_runner_heartbeat (שורה ב-CRM_params, גם היא perm_developer = 1) ומנקה את מטמון הפרמטרים. כך מסך האבחון יודע שה-crontab חי.
  3. בוחר משימות: is_active = 1, הקטגוריה ריקה או פעילה, ו-next_run ריק או קטן או שווה לדקה הנוכחית (בפורמט Y-m-d H:i), ממוין לפי next_run.
  4. לכל משימה מחשב את next_run הבא עם Cron\CronExpression::getNextRunDate(). ביטוי לא תקין מקבל דקה אחת קדימה. אחר כך מעדכן next_run, last_run = NOW() ו-runs_count + 1, וקורא ל-fire.

fire (פנימי) מכין את ההרצה:

  • מסיר / פותח מה-url של המשימה.
  • מפרק את שדה params (מחרוזת query, אחרי הסרת ? או & פותחים) עם parse_str. המפתח url נזרק, וערכים שאינם סקלריים הופכים ל-JSON.
  • יוצר שורת לוג ב-CRM_cron_logs בסטטוס running (CronLogger::create_running_log).
  • אם נוצר לוג, מוסיף לפרמטרים את cron_save_log_id ואת tk.
  • קורא ל-MISC::ping($url, $params).

איך המשימה רצה בפועל#

MISC::ping לא ממתין לתשובה. הוא בונה פקודת shell שמריצה את index.php שוב, בתהליך נפרד ברקע:

sh -c "nohup /usr/local/bin/php '/home/site/public_html/index.php' url='system/cache_cleanup/run' type='all' cron_save_log_id='57' tk='...' > /dev/null 2>/tmp/ping_errors.log < /dev/null &"

כל משימה היא אפוא תהליך PHP עצמאי, והרצת כל המשימות של דקה אחת מתחילה כמעט בו-זמנית. שגיאות כתיבה ל-stderr נאספות ב-/tmp/ping_errors.log.

אין נעילת חפיפה

הליבה לא בודקת אם ההרצה הקודמת של אותה משימה עדיין רצה. משימה שאורכת יותר מהמרווח שלה תרוץ במקביל לעצמה. כתבו משימות אידמפוטנטיות, או הוסיפו נעילה משלכם (למשל GET_LOCK של MySQL).

ב-CLI אין משתמש מחובר

בתוך משימה ADMIN::is_admin() מחזיר false, וגם COOKIES ו-SESSION ריקים. האימות היחיד הוא הטוקן.

הרצה ידנית#

הכתובת system/cron_runner/run?id=<task_id> מפעילה משימה בודדת מיד. היא דורשת את הטוקן (tk) או מנהל מחובר (ADMIN::is_admin()). היא מעדכנת last_run ו-runs_count, קוראת ל-fire ומחזירה:

OK: cron triggered (system/cache_cleanup/run) params=type=all log_id=58

מסך tasks בניהול כולל כפתור הרצה שמשתמש בזה.

הטבלאות#

טבלהעמודות עיקריות
CRM_cron_categoriesid, name, is_active, ord. הקטגוריה id = 1 היא התיקייה המוגנת "system"
CRM_cron_tasksid, category_id, title, url, params, crontime, is_active, next_run, last_run, runs_count
CRM_cron_logsid, task_id, url, params, status, started_at, finished_at, output, duration_ms, error_message

שדה crontime הוא ביטוי cron סטנדרטי של חמישה שדות (0 4 * * *). דוגמה לקריאת המשימות הפעילות והאחרונות שרצו:

SELECT id, title, url, crontime, next_run, last_run, runs_count
FROM CRM_cron_tasks
WHERE is_active = 1
ORDER BY last_run DESC
LIMIT 20;

מסך הניהול {admin}/cron_manager#

תת-מסךתפקיד
tasksרשימת משימות. ?id= הוא מזהה הקטגוריה
insertטופס משימה: title, category_id, url, params, is_active, crontime
insertCategoryקטגוריה חדשה
logsלוגים של משימה (?id= הוא מזהה המשימה)
setup_cronשורת ה-crontab המומלצת והוראות התקנה
crontab_statusהאם ה-heartbeat טרי, כלומר ה-crontab רץ
diagnosticsאבחון מערכת התזמון

בטופס המשימה:

  • url הוא כתובת של controller ו-method, לדוגמה cron_payments/daily (controller של האתר) או system/cache_cleanup/run (controller של הליבה).
  • params הם מחרוזת query, לדוגמה type=pages&limit=100.
  • crontime נבחר עם בורר ויזואלי (FormInput_CronTimePro). next_run מחושב אחרי השמירה.

משימות הליבה#

הליבה מגיעה עם controllers שמתאימים לריצה מתוזמנת, ביניהם:

URLמה הוא עושה
system/cache_cleanup/runמוחק קבצי מטמון שפג תוקפם (ברירת מחדל 0 4 * * *, קטגוריית system, פרמטר type=all|pages|items)
admin_session_cleanup/runמנקה סשנים שפג תוקפם של הניהול ורשומות bad_attempts ישנות (ברירת מחדל 0 3 * * *)
system/seo_indexnow/runשליחת כתובות ל-IndexNow, ראו IndexNow. מוגן בטוקן או במנהל מחובר
system/system_health/runשורה ותיקה שנשארה באתרים. ה-controller רק עונה ולא מבצע בדיקה, כי בדיקת הבריאות עברה לשירות אולג, ראו בריאות וביצועים

תור הדוא"ל (Tools::cron_emails_queue) לא נרשם אוטומטית. אם CONFIG::$mail_queue הוא true, הוסיפו ידנית משימה עם system/Tools/cron_emails_queue (ראו דוא"ל).

נקודות קצה של הליבה בלי אימות
cache_cleanup, core_ping, admin_session_cleanup, system_health, perf_advisor ו-ai_cron לא בודקים טוקן או הרשאה בתוך ה-controller, והם נגישים גם כ-URL רגיל. מי שמכיר את הכתובת יכול להפעיל אותם. חסמו אותם ברמת שרת האינטרנט לבקשות חיצוניות כשאפשר. ב-ai_cron::run_ai_tool ערך הפרמטר tool משמש לבניית נתיב קובץ, כך שאין להשאיר את הכתובת פתוחה. ראו הקשחה.

כתיבת controller למשימה#

המשימה היא controller רגיל (ראו Controllers). שלושה כללים הופכים אותו למתאים לריצה מתוזמנת:

  1. מגדירים $_GET["pmode"] = "empg" ב-constructor, כדי שלא יתווסף עטיפת עמוד.
  2. בודקים טוקן או מנהל. בלי זה כל מבקר יכול להפעיל את המשימה.
  3. מדפיסים שורת סיכום קצרה. CronLogger שומר את כל הפלט ב-CRM_cron_logs.output. פלט שמתחיל ב-ERROR מסומן כשגיאה.
<?php
// application/controllers/cron_reports.php
class cron_reports extends wz_controller
{
	function __construct()
	{
		parent::__construct();
		$_GET["pmode"] = "empg";
	}

	function daily()
	{
		if (!$this->authorized()) {
			return "ERROR: forbidden";
		}
		set_time_limit(0);
		ignore_user_abort(true);

		$since = date("Y-m-d H:i:s", strtotime("-1 day"));
		$rows = DB::get_all("SELECT id FROM CRM_orders WHERE created >= '" . DB::escape($since) . "' AND report_sent = 0");

		$done = 0;
		foreach ($rows as $row) {
			DB::update("orders")->set("report_sent", 1)->update((int)$row["id"]);
			$done++;
		}
		return "OK processed=" . $done;
	}

	private function authorized(): bool
	{
		$expected = (string)PARAMS::get("cron_runner_token");
		$given = (string)($_GET["tk"] ?? "");
		if ($expected !== "" && hash_equals($expected, $given)) {
			return true;
		}
		return ADMIN::is_admin();
	}
}

אחרי שמירת הקובץ רושמים את ה-controller כמודול (אחרת ה-URL מחזיר 404) ומנקים את מטמון המודולים:

INSERT INTO CRM_modules (modulesDir, moduleName, active, ord)
VALUES ('', 'cron_reports', 1, 0);
cache_engine::remove("modules");

לבסוף יוצרים משימה במסך cron_manager עם url = cron_reports/daily ו-crontime = 30 2 * * *.

העדיפו
return על echo ו-die die() באמצע משימה עוקף את סיום הלוג הרגיל. CronLogger מנסה להשלים זאת ב-shutdown handler, אבל פלט וסטטוס יהיו נאמנים יותר כשהמשימה חוזרת רגיל.

הלוגים#

CronLogger נכנס לפעולה רק כשבבקשה יש גם cron_save_log_id תקף וגם tk שנכון (הוא נתלה ב-api/core/core.php). אז:

  • הפלט נאסף לבאפר וחוזר כרגיל.
  • בסיום נכתבים status, output, duration_ms ו-finished_at.
  • status הוא error אם הפלט מתחיל ב-ERROR (ללא תלות באותיות רישיות), או מכיל Fatal error או Uncaught . אחרת ok.
  • fatal שלא נתפס נלכד ב-shutdown handler, והודעתו נשמרת ב-error_message.
  • פלט מעל 50,000 בתים (MAX_OUTPUT) נחתך עם סימון.
  • נשמרים 50 הלוגים האחרונים של כל משימה (KEEP_PER_TASK), ישנים יותר נמחקים.
  • לוג שנשאר ב-running לאורך זמן מעיד על משימה שנתקעה או שהתהליך נהרג.

מלכודות#

  • בלי נעילה: ראו למעלה. חפיפה אפשרית.
  • מפתחות הפרמטרים לא עוברים escape: MISC::ping מעביר כל ערך דרך escapeshellarg, אבל את שם המפתח לא. שמות מפתחות בשדה params של משימה חייבים להיות מזהים פשוטים. הרשאת עריכת משימות שקולה להרצת קוד, ולכן שמרו אותה למפתחים.
  • ביטוי cron שגוי: המשימה תרוץ כל דקה (ה-next_run נדחה בדקה אחת בלבד).
  • הטוקן ב-URL: הטוקן נראה ברשימת התהליכים של השרת (ps) ובלוגים. אל תשתפו אותו, והחליפו ב-CRM_params אם נחשף.

ראו גם#

מצאתם טעות או חוסר? תקנו את הדף או פתחו Issue בריפו. התיעוד נכתב מתוך הקוד של ליבה 5.0.115.