Skip to content

Blogmacos

A 600k-row queue slowed our Mac to a crawl

Load past 30 on a 10-core Mac, swap nearly full, fseventsd pinned for hours. Spotlight was the wrong suspect; a SQLite job queue sorting every row into a temp file was the lead.

3hship4 min read

Share

Contents

Our Mac, a 10-core machine that runs local models and a stack of background jobs, was barely answering. The cause turned out to be a job queue that had grown to 605,034 rows in about 16 hours, and a claim query that sorted every one of them into a temp file each time a worker asked for work. This is how we found it, what we changed, and the part we still cannot prove.

What slow looked like

The machine was not frozen, just drowning. Every reading was well past its normal line:

Reading During the slowdown Normal line
Load average 33 to 60 alarm at 30 (10 cores)
Swap in use 9.4 of 11 GiB
Local API reads 5 to 35 s 2 s limit
fseventsd CPU 88 to 99%, for hours
kernel_task CPU 110 to 217%

Two lines stood out. fseventsd is the macOS daemon that turns filesystem changes into events for anything watching a folder, and it should sit near idle. kernel_task climbing past one full core usually means the kernel is busy on someone else’s behalf. Something was touching the disk very hard.

The wrong first suspect

A busy fseventsd and a hot disk point straight at Spotlight, so that went first.

Swap easing was a real improvement, which made the guess feel right for a moment. But the daemon we were worried about did not move, so whatever fed it was still running.

Counting the filesystem calls

Guessing had stopped working, so we measured. fs_usage prints every filesystem call on the machine as it happens; run as root with a filesystem filter, a short sample says who is doing what:

Terminal window
# 25-second sample of every filesystem call, wide output
sudo fs_usage -w -f filesys > fsu.txt # stop it after 25 seconds

That sample held about 2.03M filesystem calls, roughly 80k every second. Grouping the write-type calls by process gave one clear answer:

What the sample showed Reading
Filesystem calls in 25 s about 2.03M (about 80k/s)
pwrite calls from one source about 24k
The source short-lived Python processes of our local model runner
What each one wrote an unlinked SQLite temp file (etilqs_*), about 43 MB

etilqs_ is the prefix SQLite gives its temporary files; it is “sqlite” backwards. An unlinked temp file of that size means SQLite was spilling a big intermediate result to disk, again and again. The next question was which query.

Why a queue claim wrote 43 MB

The runner pulls work from a SQLite queue. A job-board fill had queued 605,034 jd-fetch rows, plus 3,513 jd-read rows, in about 16 hours. They were not duplicates: each was one distinct posting URL. Nothing capped how many could sit open.

Each worker claims its next job with a query that picks by tier. The tier order lived in an ORDER BY CASE expression, and SQLite cannot walk an index in the order of a CASE. The (state, tier, added_at) index was there and unused, so every claim did this:

-- the shape of the old claim, simplified
SELECT id FROM queue
WHERE state = 'open'
ORDER BY CASE tier WHEN ... THEN ... END, added_at
LIMIT 1;
-- no index matches the CASE order, so SQLite sorts every open row
-- into a temp b-tree before it can return the first one

On a small queue that sort is cheap. With about 608k rows it became a fresh temp file of about 43 MB, written about every 17 seconds, by a new process each time.

The fixes

  • Drop the backlog. Deleting the 605,034 queued rows took 1.7 seconds.
  • Cap the queue. A kind can now hold at most 5,000 open items, so a fill cannot grow it without limit again.
  • Rewrite the claim. Instead of one ORDER BY CASE, the claim probes each tier in turn on the index. On a 600k-row queue it now takes 6.6 ms and builds no temp b-tree.
  • Restart fseventsd. It had been running for 23 days. Restarting it as root took it from 90.6% to 0.9% CPU.

The rewritten claim is the durable fix: the cost of finding the next job no longer depends on how long the queue is.

-- the shape of the new claim: one indexed probe per tier, in tier order
SELECT id FROM queue
WHERE state = 'open' AND tier = :tier
ORDER BY added_at
LIMIT 1;
-- run for the first tier, then the next, until one returns a row

After

Once the queue was cleared, capped and claimed on the index, and fseventsd restarted, the machine came back:

Reading During After
Load average 33 to 60 8.6 (load1)
Swap in use 9.4 of 11 GiB 3.5 of 5.0 GiB
fseventsd CPU 90.6% 0.9%
kernel_task CPU 217% 30%

The queue fixes went in first. The reading that dropped fseventsd from 90.6% to 0.9% came right after the restart, and the restart is what cleared the load.

Verdict

A queue that only ever grows and a claim query that sorts the whole queue are each harmless when the queue is small. Together, at about 608k rows, they made a background worker write a 43 MB temp file about every 17 seconds, and the Mac spent its day keeping up. Spotlight was the obvious suspect and the wrong one; one fs_usage sample found the real writer in 25 seconds.

If your Mac is slow and fseventsd is busy, count the filesystem calls before you turn things off. If a SQLite queue shows up, check that its claim can use an index, and give it a cap. And if fseventsd stays high after the writer is gone, a restart may be what finally clears it, as it was here.

Check your own queue: read SQLite’s EXPLAIN QUERY PLAN guide to see whether your claim query uses an index, and how the query planner picks one.

Responses

Sign in to write a response

Keep reading