#macos
On macOS, you can run a background job on a timed schedule in several different ways, although some of them are deprecated:
- `launchd`
- `cron`
- `at` jobs (_deprecated_)
- `periodic` jobs (_deprecated_)
If you are new to working on Macs and need to run a scheduled task, your first instinct might be to reach for `cron`, although this is not necessarily the right tool for the job.
According to Apple's archived documentation on scheduling timed jobs:
> [!info] **The preferred way to add a timed job is to use `launchd`** [...] **Although it is still supported, `cron` is not a recommended solution. It has been deprecated in favor of `launchd`.** [1]
In this post, I will outline some examples of where `launchd` indeed outshines `cron`.
## Scenario
To start, let's say you have a Python script called `convert_txt_to_md.py` that points at a path and recursively goes through all the directories and changes all the text files to markdown:
```python
import os
directory = '/Users/andrew.katz/notes/superwhisper/recordings'
for root, dirs, files in os.walk(directory):
for file in files:
if file.endswith(".txt"):
txt_path = os.path.join(root, file)
md_path = txt_path[:-4] + '.md'
os.rename(txt_path, md_path)
```
_For context, I use_ [_superwhisper_](https://superwhisper.com/?ref=akatz.org) _for dictation which outputs a transcript of my voice recording as text files to my_ [_Obsidian_](https://obsidian.md/?ref=akatz.org) _vault (`/notes`). Since Obsidian can't search text files, I convert them to markdown using this script which allows Obsidian to natively search for them._
![[Pasted image 20240811131454.png]]
To create a scheduled job using `launchd`, you need to create a **Launch Agent.** Launch Agents run once the user has logged in with standard user permissions, and they may interact with the user session. Third-party launch agents live in `/Library/LaunchAgents` or `~/Library/LaunchAgents`.
Creating your Launch Agent involves creating a property list (plist) file that you will add to `~Library/LaunchAgents`. A plist is an XML, JSON, or binary file that contains key/value pairs that store configuration information, settings, serialized objects, and more.
Plists are usually named with `com.companyName.itemName`, so we are going to go with `com.ackatz.converttxttomd` as the name/label.
Using `nano ~/Library/LaunchAgents/com.ackatz.converttxttomd.plist`, I create the plist using the following information:
```XML
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.ackatz.converttxttomd</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/python3</string>
<string>/Users/andrew.katz/convert_txt_to_md.py</string>
</array>
<key>StartInterval</key>
<integer>3600</integer> <!-- Runs every hour -->
<key>RunAtLoad</key>
<true/>
</dict>
</plist>
```
I find plists a lot easier to read than cron expressions/crontab syntax
To describe some of the keys above:
| Key | Behavior |
|------------------|----------------------------------------------------|
| Label | name/label of your Launch Agent |
| Program Arguments| path to the executable script or binary + args |
| Start Interval | run job every _n_ seconds |
| RunAtLoad | run job one time at load (in addition to other schedules) |
Finally, to tell `launchd` that it needs to start using our Launch Agent, we run the following command:
```
launchctl load ~/Library/LaunchAgents/com.ackatz.converttxttomd.plist
```
To check that it has been added:
```
❯ launchctl list | grep ackatz
- 0 com.ackatz.converttxttomd
831 0 application.ackatz.seclook
```
> [!info] seclook is a security lookup app that I wrote.. check it out!: https://seclook.app
`-` for the PID tells us that our Launch Agent does not have a continuously running process associated with it, which makes sense.
`0` means that it ran successfully the last time it was invoked, which is good.
## `launchd` Advantages over `cron`
Besides the fact that `launchd` offers significantly more options to constrain and schedule your job [2], one interesting edge that `launchd` has over `cron` is related to how job scheduling is handled depending on power state:
| | sleep | powered off |
| ------- | ------------------------- | ------------------------- |
| launchd | run @computer wakeup | run @next designated time |
| cron | run @next designated time | run @next designated time |
> [!info] 💡 If your machine is always powered off at the designated time, your script is not going to run.
If you were reaching for `cron` in the first place, it is likely that the scheduling key you will use in your Launch Agent plist is going to either be `StartCalendarInterval` or `StartInterval`.
| scheduling key | behavior |
| --------------------- | --------------------------------- |
| StartInterval | run job every _n_ seconds |
| StartCalendarInterval | run job at a _specific_ time/date |
Let's say that you decided to use `StartInterval` with an interval of `30` seconds. If your machine is in a sleep state and missed 100 invocations, it will coalesce all of those missed jobs together until it wakes up, invoking it just once.
When using `StartCalendarInterval`, if you missed the scheduled start time due to the machine being asleep, it will just run the job as soon as it wakes up.
It's worth mentioning that `launchd` has so many more scheduling options than just the two above. Consider the `convert_txt_to_md.py` example from above. There is a scheduling option called `WatchPaths` that you can point at a path/directory/file and your job will run any time that changes are detected on the path.
```
<key>WatchPaths</key>
<array>
<string>/path/to/directory_or_file</string>
</array>
```
Instead of waiting potentially an hour for the `txt` to be changed to `md`, `WatchPaths` runs the job as needed. In my testing, it usually occurred either instantly or within a few seconds. This would have made a lot more sense to use in this scenario, so I've switched to it 😄
This is what it looks like in a Launch Agent plist:
```
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.ackatz.converttxttomd</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/python3</string>
<string>/Users/andrew.katz/convert_txt_to_md.py</string>
</array>
<key>WatchPaths</key>
<array>
<string>/Users/andrew.katz/notes/superwhisper/recordings</string>
</array>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>
```
## Summary
- The preferred way to run a scheduled job on macOS is using `launchd`, where you create a Launch Agent to run your job.
- You can still run jobs with `cron` on macOS, but your options are way more limited (i.e., can't run things based on triggers and less survivability of your job based on power states/missed jobs)
- If you want to learn more about `launchd` and the other things you can do with it, check out this amazing site: [https://launchd.info/](https://launchd.info/?ref=akatz.org)
## Sources & Further Reading
1. Scheduling Timed Jobs
Explains how to write background processes that perform work on behalf of applications or serve content over the network.
[https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/ScheduledJobs.html](https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/ScheduledJobs.html?ref=akatz.org)
2. A launchd Tutorial
A launchd primer covering configuration, administration and troubleshooting. Complete with examples.
[https://launchd.info/](https://launchd.info/?ref=akatz.org)