11

Is there a way to schedule a cron job to run every fortnight?

(One way I can think of, within crontab, would be to add two entries for "date-of-month"...)

4
14

No, cron only knows about the day of the week, the day of the month and the month.

Running a command twice a month on fixed days (e.g. the 1st and the 16th) is easy:

42 4 1,16 * *  do_stuff

Running a command every other week is another matter. The best you can do is to run a command every week, and make it do nothing every other week. On Linux, you can divide the number of seconds since the epoch (date +%s) by the number of seconds in a week to get a number that flips parity every week. Note that in a crontab, % needs to be escaped (cron turns % into newlines before executing the command).

42 4 * * 1  case $(($(date +\%s) / (60*60*24*7))) in *[02468]) do_stuff;; esac
1
  • 2
    According to the documentation I have at hand, cron turns percent signs into newlines and pipes everything that was after the first percent sign into the command. Not that it matters much, I guess; I don't think I've ever willingly used this feature.
    – dhag
    Apr 20 '15 at 0:02
2

You cannot directly have cron run a job fortnightly (every two weeks). However, it is reasonably straightforward to ensure that the main part of the job runs only every other week. @Gilles has offered one solution; here's another:

42 4 * * 1    test 1 -eq $(($(date +\%g) & 1)) && do_stuff...

The date +%g command returns the current week number (of the year), and this is bitwise ANDed to return either 1 or 0 before being used to determine whether the real job can be run.

The same caveat on the percent symbol %: in a crontab entry it must be escaped to prevent cron treating it specially.

1
  • 2
    My one concern with this week-number-based solution is that a year consists of 52 weeks plus an extra day (two days in the case of leap years). I think this would lead to a situation where, occasionally, there will be Monday 31st December in week 53 followed by Monday 7th January in week 1. Both week numbers are odd, so the job would run in consecutive weeks.
    – user140906
    Oct 30 '15 at 10:37
2

I know I'm late to the party but, everyone wants a solution or three to any problem...

I found this post while wanting to do the same thing as the OP, except for a Sunday.

@user140906 has a valid concern, which can be observed when running this snippet;

for n in $(seq 17 33); do ncal -A1 -bw dec 20${n}; done

Which initially seems to repeat every 6 years - I didn't check them all! :-)

I then found this link which offers two solutions, one similar to @gilles-so-stop-being-evil, that should work, the most obvious one is below;

#!/bin/bash

# a file marking the state on disk
mark_file=$HOME/.job-run-marker-1

# check whether the job run last time it is invoked
if [ -e $mark_file ] ; then
  rm -f $mark_file
else
  touch $mark_file
  exit 0
fi

# job command is here

Coupled with a crontab entry like;

0 2 * * 2 my_script

Easily adapted to be used for multiple crontab entries (pass a value for mark_file or an ID instead of hard-coding it), or expand the same logic with some grep / awk / sed magic, to add/remove/append lines in a file that represent the ID and status/date of each job execution.

Either way, hopefully that helps someone, perhaps even a future me!

0

If you need an very stable method of run every fortnight, not affected by month, year or century, you should consider this.

It is mostly math:

12 21 * * * [ $(( ($(date +\%s)/86400+3) \% 14 )) = 0 ]  && do_stuff

Yes, it runs correctly under (POSIX) sh (and dash, ksh, zsh and bash).

Description

Since epoch days are (by definition) 86400 seconds long, dividing the epoch seconds value by 86400 we get how many (full) days have elapsed since 1/1/1970. Taking this result in mod 14 we get a loop from 0 to 13 for every new day, always, no matter if there is a leap year or if the month change or it is used in a new year.

Assuming we are in America/New_York at 2021-08-29 21:12:34

$ TZ=America/New_York date -d '2021/8/29 21:12:34 -4'
Mon 29 Aug 2021 09:12:34 PM EDT

Which is well pass midnight in UTC (the value used by the +%s format):

$ TZ=America/New_York date -ud '2021/8/29 21:12:34 -4'
Tue 30 Aug 2021 01:12:34 AM UTC

Steps

date +%s

Generates the value of seconds since epoch (1/1/1970 00:00:00) at UTC[1].

Like:

$ date +%s
1630285954

Reduce to days

Dividing that by 86400 we get elapsed full days (again, in UTC).

$ echo "$(( $(date +%s) / 86400 ))"
18870

With a remainder of 72 minutes (echo "$(( ($(date +%s) % 86400)/60 ))"`)[2]

Convert to a 14 days cycle.

Taking module 14 of the number of days we get a number from 0 up to 13 which is zero when a whole number of 14 days have elapsed from the origin (in UTC) 1/1/1970. So we should get 0 again on the 1/15/1970, the 1/29/1970, the 2/12/1970, so on and so forth.

But remember that 1/1/1970 00:00:00 in UTC is 31/12/1969 20:00:00 in New_York local time (which was a Wednesday). Only at that time the remainder of the division by 14 is actually zero.

The closest day that also has a 0 remainder near today (as above: 2021/8/29 21:12:34 -4) is the 18 (2021/8/18 21:12:34 -4). Which is also a Wednesday. To move that 0-day to some other we need to add an offset. If we want to make the 29 a 0-day we need to add 14-11 days, or 3 days.[3]


Notes

[1] It is important to understand that crontab runs on local time while date reports time at UTC. When the day changes at UTC, a whole block of 86400 seconds have elapsed. But the day change might have happened before at local times before UTC (local clock shows a latter time) if the timezone offset is positive (toward east like +5:30 --Kolkata-- ) or will happen later if the timezone is negative (toward west like -8 --Los Angeles--).

That is not important as long as it is consistent for a given crontab. If local time changes (is modified or copied to a computer on a different time zone) the crontab will be affected and will need adjustment.

[2] The remainder should be greater of 60 minutes. Assuming the time change of DST (Daylight Saving Time) is only one hour (60 minutes). If that margin is not observed, it may happen that two consecutive days, at the same local time, have the same number of days (or the difference may be two days):

### This script:

#!/bin/bash -

for d in 12 13 14 15 16; do
    a='3/'"$d"'/2021 19:12:01';
    b=$(TZ='America/New_York' date -d "$a" +'%F %T %z');
    c=$(TZ='America/New_York' date -d "$a" +%s);
    echo "$b $(( c/86400 )) $(( c%86400/ 60))"
done

### Prints
2021-03-12 19:12:01 -0500 18699 12
2021-03-13 19:12:01 -0500 18700 12       # same value 18700 as ...
2021-03-14 19:12:01 -0400 18700 1392     # next day with 18700 again.
2021-03-15 19:12:01 -0400 18701 1392
2021-03-16 19:12:01 -0400 18702 1392

[3] To help with the calculation of the offset and to read all the values used, you could try this script:

#!/bin/bash -
day=${1:-'2021-08-29 21:12:34'}

date -d "$day" +'%F %r %z'

offset=${2:-3}

a=$(date -d "$day" +%s)
b=$(( a / 86400 ))
c=$(( (a % 86400)/60 ))
d=$(( b + offset ))
e=$(( d % 14 ))
echo "secs=$a days=$b remainderInMinutes=$c Offset=$d Cycle=$e ToZero=$((14-e))" 

Run once with the date (argument 1) that needs tohave a zero offset (argument 2): ./script "$date" "0" which will say what is the offset of that day, and then confirm with the correct date and offset:

./script '2021/8/29 21:12:34 -4' 3
secs=1630285954 days=18869 remainderInMinutes=72 Offset=18872 Cycle=0 ToZero=14

Once that date has been found (and tested), all that remains is to build a sh compatible test:

    [ $(( ($(date +%s)/86400+3) % 14 )) = 0 ] 

And write that as a cron compatible string (escape %)

12 21 * * * [ $(( ($(date +\%s)/86400+3) \% 14 )) = 0 ]  && do_stuff

Note that the time to execute the command 21:12:00 should generate the adequate remainder greater than 60 to avoid problems with changes of DST. Check remainder (if needed) with the script from above).

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.