if self.thread and self.thread.is_alive():
thread_age = time.time() - self.launch_time
if thread_age < self.max_wait:
log.info("Launch of report thread aborted: existing report thread still running")
return
else:
log.warning("Previous report thread has been running"
" %s seconds. Launching report thread anyway.", thread_age)
max_wait defaults to 600 (line 847). The behavior you and Matthew remember is intact.
Why you never see the message. Callbacks for an event run in registration order — bind() appends to a list, dispatchEvent() walks it (lines 224-241). Services register in the order they’re loaded, and the archive service registers for POST_LOOP ahead of the report service. Your own log shows the consequence: the archive record was written at 10:05:18, which happens inside the archive service’s POST_LOOP handler — so POST_LOOP was mid-dispatch when the OperationalError hit at 10:05:23. An exception in one callback aborts the whole dispatch. launch_report_thread(), later in the chain, was never called. The guard didn’t fail to log; it never ran.
That’s also the answer to “why did changing max_wait have no effect”: neither branch of that test is being reached.
A second report cycle did not start. Two independent reasons. The guard logs on both paths — the abort message under 600 seconds, the “Launching report thread anyway” warning over it — and neither appears anywhere in your log, which plainly isn’t filtering INFO. And the arithmetic forbids it anyway: the report thread launched at 10:00:21 and the engine died at 10:05:23. It was 302 seconds old. It cannot have reached the 600-second threshold, and the restart resets the launch clock, so nothing carries over.
If you want this settled by observation instead of argument, py-spy dump lists every thread in the process with its stack. Two report engines would be two entries. One dump ends the debate.
Where weewx could be better. It just isn’t the abort logic. Four things, and they compound:
1. There is no way to interrupt a running generator. shutDown() calls join(20.0), and that is the entire shutdown mechanism — no flag, no exception injected, nothing signalled. The report thread isn’t ignoring a request to stop; no request exists. “Unable to shut down StdReport thread” is a wait that expired, worded like an attempt that was refused.
2. The surviving thread becomes untracked. When the engine restarts, the new StdReport starts with self.thread = None. The orphan is no longer joined, checked, or counted — it is invisible to the very guard we’ve been discussing. The protection you’re asking about stops applying to the one thread that most needs it.
3. weewxd restarts the engine in the same process immediately after being told the shutdown failed. The orphan survives into the new engine’s lifetime, still holding database reads. Exiting the process and letting the supervisor restart it would kill the orphan for free.
4. The result is self-sustaining. The orphan holds reads; the new engine’s archive write collides with them; the engine dies with the same error; repeat. Your 10:07 restart is iteration two: up at 10:07:44, record at 10:07:55, dead at 10:08:00 — and no “Shutting down StdReport thread” line this time, because the new engine had no report thread of its own. On 1 vCPU, the orphan is also competing with the main loop for your only core.
Arguably a fifth: treating “database is locked” as fatal. It’s a transient condition; retrying the write would be proportionate. Tearing down the engine is what converts a slow report into a crash loop.
On beta4. I diffed it against beta3. The scan is unchanged — same _archive_latest_numeric, same unbounded query. What changed is the gating: a new aqi_source option (auto / local / forecast), with the archive path running only when
local_aqi_enabled = aqi_enabled and aqi_source in (“auto”, “local”)
aqi_source defaults to “auto” — which is in that tuple. On defaults, beta4 still runs the scans. So: which did you set? If you set aqi_source = forecast, that explains your speedup, and the bug remains live for anyone on defaults with a big database. If you’re on auto and it’s still fast, my diagnosis is incomplete and something else in beta4 deserves credit.
For the skin author: bound the lookback instead of gating the caller. The query walks backwards from the newest row — instant when the column has current data, a full scan of the table when it doesn’t. A WHERE dateTime >= ? window of a day or two gives the identical answer for any station with a working PM sensor and costs nothing for one without. Config gating leaves the landmine armed for the next person with thirteen years of data who never touches aqi_source.