Chạy cronjob mỗi N ngày

Chạy cronjob mỗi N ngày

Vừa rồi mình có làm GitHubArchive, đây là một project mã nguồn mở có chức năng gửi email thống kê các top các repository của GitHub trong ngày. Cụ thể là top 25 repository mới nổi được nhiều người đánh dấu (star) nhất và top 25 repository được nhiều người đánh dấu (star) nhất trong ngày.
Bạn có thể xem qua về newsletter này ở đây.

Khi làm xong project này, mình phải chạy cronjob để lấy dữ liệu theo thời gian định kỳ (1 ngày/3 ngày/7 ngày) vào lúc 0h sáng.

Cấu trúc của một cronjob như sau:

* * * * * script
Phút 0-59 Giờ 0-23 Ngày trong tháng 1-31 Tháng 1-12 Thứ trong tuần 0-6 (0 = CN) script cần được chạy (Vd: echo `whoami`)

Nếu muốn cronjob chạy 1 ngày/lần lúc 00:00 thì chỉ cần dùng cấu trúc: 0 0 * * * ruby daily.rb

Tuy nhiên, để chạy 3 ngày/lần hay n ngày/lần thì không đơn giản chỉ dùng 0 0 */3 * *. Bởi vì nếu tháng nào có 31 ngày thì cronjob sẽ chạy liên tục 2 ngày liên tiếp 31 và ngày 1 tháng sau => sai.

Bạn có thể xem thống kê các lần chạy ở dưới đây: (Mình lấy mốc là ngày 2015-03-25)

hoặc đây

Vấn đề được đặt ra là: Làm thế nào để cronjob chạy được chính xác mỗi N ngày xác định nào đó? N ở đây thậm chí có thể lớn hơn 31?

Cách 1: Lưu lại lần chạy cuối cùng và check mỗi khi chạy.

Cách này mình đã từng dùng ở 1 số project đã từng làm, cụ thể như sau:

  • Set cronjob chạy script 1 ngày 1 lần.
  • Sau mỗi lần script chạy, lưu lại thời gian chạy vào DB hoặc file.
  • Trước khi script được chạy, check khoảng thời gian từ lần chạy cuối cùng đến thời gian hiện tại, nếu số ngày cách nhau bằng N thì xử lý tiếp, còn không thì không xử lý nữa.
# crontab -e
0 0 * * * ruby 3days.rb
N = 3 # days
last_execution = read_file('/data/execution_logs.txt')
if day_diff(current_date, last_execution) != N:
  return
# Your logic
# Example: Send campaign email
write_file('/data/execution_logs.txt')

Cách 2: Tính toán trực tiếp từ Unix time.

Note: Cách này sẽ gặp vấn đề với DST (Daylight Saving Time) và các múi giờ khác UTC.

Với những ai chưa biết Unix time là gì thì đây là định nghĩa trên Wikipedia.

Thời gian Unix (tiếng Anh: Unix time, Epoch time hay POSIX time) là hệ thống mô tả một điểm trong thời gian. Thời gian Unix được định nghĩa bằng số giây kể từ 00:00:00 theo giờ Phối hợp Quốc tế (UTC) ngày 1 tháng 1 năm 1970, trừ đi giây nhuận. Mỗi ngày được xử lý như thể nó chứa chính xác 86400 giây, vì vậy số giây nhuận sẽ không được tính.

Đây là cách lấy ra Unix time cho 1 số ngôn ngữ, những ngôn ngữ khác bạn có thể tự tìm hiểu thêm nhé.

# Python
import time
int(time.time())

# PHP
strtotime('now');

# JS
# Do now() và getTime() trả về millisecond, không phải second nên phải chia cho 1000
Math.floor(Date.now()/1000)
Math.floor(new Date().getTime()/1000)

# MySQL
SELECT UNIX_TIMESTAMP();

# Bash
date +%s

Ý tưởng của cách này chính là chia nhóm các ngày kể từ 1970-01-01 00:00 (the Epoch) cho đến thời gian mà bạn chọn theo N ngày. Cụ thể là đổi ngày sang Unix time để tính ra số ngày kể từ the Epoch và dùng phép toán Modulo (phép toán chia số lấy số dư) để lấy ra những ngày có số dư giống nhau khi chia cho N.

Giả sử N của mình là 3, và mốc thời gian mình chọn là 2019-02-19 00:00 thì mình sẽ chia nhóm như sau:

# https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/UTC

# Group 1
1970-01-01 00:00 => 0 mod 3 = 0 (Date.UTC(1970,0,1,0,0,0,0) / 1000 / 86400 % 3 === 0)
1970-01-02 00:00 => 1 mod 3 = 1 (Date.UTC(1970,0,2,0,0,0,0) / 1000 / 86400 % 3 === 1)
1970-01-03 00:00 => 2 mod 3 = 2 (Date.UTC(1970,0,3,0,0,0,0) / 1000 / 86400 % 3 === 2)


# Group 2:
1970-01-04 00:00 => 3 mod 3 = 0 (Date.UTC(1970,0,4,0,0,0,0) / 1000 / 86400 % 3 === 0)
1970-01-05 00:00 => 4 mod 3 = 1 (Date.UTC(1970,0,5,0,0,0,0) / 1000 / 86400 % 3 === 1)
1970-01-06 00:00 => 5 mod 3 = 2 (Date.UTC(1970,0,6,0,0,0,0) / 1000 / 86400 % 3 === 2)


...


# Group ...:
...
2019-02-18 00:00 => 17945 mod 3 = 0 (Date.UTC(2019,1,18,0,0,0,0) / 1000 / 86400 % 3 === 2)
2019-02-19 00:00 => 17946 mod 3 = 0 (Date.UTC(2019,1,19,0,0,0,0) / 1000 / 86400 % 3 === 0)
2019-02-20 00:00 => 17947 mod 3 = 0 (Date.UTC(2019,1,20,0,0,0,0) / 1000 / 86400 % 3 === 1)
2019-02-21 00:00 => 17948 mod 3 = 0 (Date.UTC(2019,1,21,0,0,0,0) / 1000 / 86400 % 3 === 2)
2019-02-22 00:00 => 17949 mod 3 = 0 (Date.UTC(2019,1,22,0,0,0,0) / 1000 / 86400 % 3 === 0)
...

Từ kết quả phía trên, có thể nhận thấy nếu chọn mốc là 2019-02-19 00:00 thì để script có thể chạy 3 ngày 1 lần liên tục mình chỉ việc cho script chạy vào những ngày có cùng kết quả mod 3 bằng 0.

Cuối cùng, thay vì cách 1 phải lưu lại lần chạy gần nhất vào file hoặc DB rồi lấy ra so sánh thì ta chỉ cần tính toán bằng một vài phép toán đơn giản như trên thôi.

# crontab -e
0 0 * * * ruby 3days.rb
N = 3 # days
NUMBER_SECONDS_A_DAY = 86400
first_execution = '2019-02-19 00:00'
remainder = (unix_time(first_execution) / NUMBER_SECONDS_A_DAY) % N
# Or hard-code remainder for faster calculation
# remainder = 0

if current_unix_time % N !== remainder:
  return
# Your logic
# Example: Send campaign email

Mình thì mình không thích viết phần điều kiện check trên trong script cho lắm nên mình thường viết luôn trong file crontab như sau:

# crontab -e
0 0 * * * bash -c '(($(date +\%s) / 86400 \% 3 == 0)) && ruby 3days.rb'

Trên đây là 2 cách mà mình biết để xử lý vấn đề phổ biến với crontab đó là chạy cronjob tuần tự theo số ngày định sẵn. Bài này được bắt đầu viết từ đầu năm 2015 mà giờ mới viết xong thế nên câu cú lủng củng hay sai chỗ nào thì comment cho mình biết với nhé 🤪

Hiện tại thì project GitHubArchive của mình cũng đã dừng hoạt động rồi, nếu bạn quan tâm tới newsletter tương tự như thế thì hãy subscribe ChangelogNightly nhé 😁