I have been tracking the rides of the Berlin Critical Mass to create small visualisations of where the route was taking people for a while now. To do so, I wrote a little script that uses the Critical Maps API and takes bi-minutely snapshots.

To make sure that I had the data to visualise, I needed to remember to start the script on every last Friday of the month. Sometimes this was not possible since I was out of town with no access to my server where I kept the script.

I wanted to automate this and planned to set up a small cron job to start the script for me. After a little research I learned that the cron syntax does not allow for “every last Friday of the month”. Instead, what people would mostly do as a workaround is to set up cron to run their scripts every Friday and then to exit early in the script if it was not in fact the last Friday of the month.

This did not seem so nice to me, so I kept looking for better options.

I read somewhere that systemd’s timer feature allowed one to define jobs in intervals like the one I was looking for. I initially felt like the setup would be too complicated but then realised that the system is quite nice. I’m going to share what I learned in the rest of this aritcle (this is mostly from the documentation on suse.com).

Setting up a timer in systemd actually requires one to set up a service first. Mine looks like this:

[Unit]
Description=Critical Tracks Recorder

[Service]
ExecStart=python3 /home/knut/critical-tracks/main.py

This file is called critical-tracks.service and resides in /etc/systemd/system/critical-tracks.service.

Along with the service, I also needed to set up a timer. The configuration is not much more complex and looks like this.

[Unit]
Description=Critical Tracks monthly timer

[Timer]
OnCalendar=Fri *-*~07/1 18:00:00

[Install]
WantedBy=multi-user.target

This file is called critical-tracks.timer and resides in /etc/systemd/system/critical-tracks.timer.

The matching between service and timer is achieved by them sharing the same name so the only complicated part of the config is the OnCalendar section. Details on the setup can be found by running man 7 systemd.time which includes this section:

A date specification may use “~” to indicate the last day(s) in a month. For example, “*-02~03” means “the third last day in February,” and “Mon *-05~07/1” means “the last Monday in May.”

This looked quite like what I wanted already and just required some more tweaking to match my exact use case. Luckily, there also is a handy executable called sytemd-analyze which lets one test the calendar pattern.

Running systemd-analyze calendar --iterations 12 "Fri *-*~7/1 18:00:00" Gave me:

  Original form: Fri *-*~7/1 18:00:00
Normalized form: Fri *-*~07/1 18:00:00
    Next elapse: Fri 2023-10-27 18:00:00 CEST
       (in UTC): Fri 2023-10-27 16:00:00 UTC
       From now: 1 week 3 days left
       Iter. #2: Fri 2023-11-24 18:00:00 CET
       (in UTC): Fri 2023-11-24 17:00:00 UTC
       From now: 1 month 8 days left
       Iter. #3: Fri 2023-12-29 18:00:00 CET
       (in UTC): Fri 2023-12-29 17:00:00 UTC
       From now: 2 months 13 days left
       Iter. #4: Fri 2024-01-26 18:00:00 CET
       (in UTC): Fri 2024-01-26 17:00:00 UTC
       From now: 3 months 10 days left
       Iter. #5: Fri 2024-02-23 18:00:00 CET
       (in UTC): Fri 2024-02-23 17:00:00 UTC
       From now: 4 months 8 days left
       Iter. #6: Fri 2024-03-29 18:00:00 CET
       (in UTC): Fri 2024-03-29 17:00:00 UTC
       From now: 5 months 12 days left
       Iter. #7: Fri 2024-04-26 18:00:00 CEST
       (in UTC): Fri 2024-04-26 16:00:00 UTC
       From now: 6 months 10 days left
       Iter. #8: Fri 2024-05-31 18:00:00 CEST
       (in UTC): Fri 2024-05-31 16:00:00 UTC
       From now: 7 months 14 days left
       Iter. #9: Fri 2024-06-28 18:00:00 CEST
       (in UTC): Fri 2024-06-28 16:00:00 UTC
       From now: 8 months 12 days left
      Iter. #10: Fri 2024-07-26 18:00:00 CEST
       (in UTC): Fri 2024-07-26 16:00:00 UTC
       From now: 9 months 10 days left
      Iter. #11: Fri 2024-08-30 18:00:00 CEST
       (in UTC): Fri 2024-08-30 16:00:00 UTC
       From now: 10 months 14 days left
      Iter. #12: Fri 2024-09-27 18:00:00 CEST
       (in UTC): Fri 2024-09-27 16:00:00 UTC
       From now: 11 months 12 days left

which I confirmed where the dates that I wanted.

I wanted to fully understand the syntax though and it took me a bit to understand the last part. The documentation says:

In the date and time specifications, any component may be specified as “*” in which case any value will match. Alternatively, each component can be specified as a list of values separated by commas. Values may be suffixed with “/” and a repetition value, which indicates that the value itself and the value plus all multiples of the repetition value are matched.

I tried around a bit and finally understood that the ~ made it so that we were counting from the end of the month. That is, instead of adding, the / would subtract from the n-th last day in multiples of the value after the slash.

To start easy, systemd-analyze calendar --iterations 3 "Fri *-*~1 18:00:00" for example would only give us Fridays that were exactly the last day of the month:

  Original form: Fri *-*~1 18:00:00
Normalized form: Fri *-*~01 18:00:00
    Next elapse: Fri 2024-05-31 18:00:00 CEST
       (in UTC): Fri 2024-05-31 16:00:00 UTC
       From now: 7 months 14 days left
       Iter. #2: Fri 2025-01-31 18:00:00 CET
       (in UTC): Fri 2025-01-31 17:00:00 UTC
       From now: 1 year 3 months left
       Iter. #3: Fri 2025-02-28 18:00:00 CET
       (in UTC): Fri 2025-02-28 17:00:00 UTC
       From now: 1 year 4 months left

Similarly, "Fri *-*~2 18:00:00" would give us Fridays on the second last day of the month.

Adding the /1 suffix made it so that we could also change the last value by increments of that value. "Fri *-*~2/1 18:00:00" would now give us Fridays on the last or the second-last day of the month. One could also choose non-1 increments for really complicated setups such as "Fri *-*~03/2 18:00:00" which would translate to “Fridays that are on the third (3) last or the last (3 - 2*1) day of the month”. This would be much more than I needed though. I could get just what I wanted from Fri *-*~7/1 18:00:00 that is the last Friday on the seventh or fewer last of day of the month.

I would now only have to enable the timer by running:

systemctl enable critical-tracks.timer
systemctl start critical-tracks.timer

I could check that the timer was registered for the correct day by listing all timers:

NEXT                         LEFT               LAST                         PASSED        UNIT                         ACTIVATES
Tue 2023-10-17 00:00:00 CEST 4h 42min left      Mon 2023-10-16 00:00:01 CEST 19h ago       dpkg-db-backup.timer         dpkg-db-backup.service
<snip>
Fri 2023-10-27 18:00:00 CEST 1 week 3 days left n/a                          n/a           critical-tracks.timer        critical-tracks.service

And that was it I think. I’ll see at the end of the month but my learning of the day was that setting up timers in systemd might be much easier than I had initially feared.