Always flush the buffer on stop, and let unload finish (#577) - #578
Conversation
finish() was supposed to write everything still held in RAM when the adapter stops. Three things kept it from doing so, all of them only visible when maxLength is greater than 0. The buffered values were written only from inside the `skipped` and the `writeNulls` branch - there was no unconditional flush anywhere. A datapoint with neither simply lost its buffer. That is not an exotic case: writeNulls is forced off for every dialect with multiRequests === false, which is SQLite, so it hit every SQLite instance, plus anyone who unticked "Write NULL values on start/stop boundaries". The counter was shared by all datapoints. Each one incremented it and the flush ran on whichever callback brought it back to zero, using that callback's id, so at most one datapoint's buffer was ever written even with writeNulls on. The same counter let allFinished() close the pool while later datapoints were still being dispatched - the dispatch is deliberately spread over seconds (`delay += dpcount % 50 === 0 ? 1000 : 0`), which makes that more likely rather than less. And allFinished() was reachable only through that one flush callback. With nothing to write, the counter never moved, the unload callback never fired and js-controller had to kill the instance. There was a guard for zero datapoints but none for "datapoints exist and none scheduled work". finishId() now counts its own pending writes, flushes the buffer unconditionally once they are done, and reports the datapoint complete. finish() counts datapoints rather than writes and only lets the last one end the run, after the dispatch loop has set allDispatched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The unconditional flush crashed the adapter on stop with
"Cannot read properties of undefined (reading 'length')" in
pushValuesIntoDB, which takes `list.length` on its first line.
Not every entry in sqlDPs is a configured datapoint. Four places create a stub
with `this.sqlDPs[id] ||= {} as SQLPointConfig` - storeState, getHistory and the
object view do it just to carry an index - and those have no `list` at all. The
previous flushes never reached them because both sat behind `skipped` or
`config`, which only a real datapoint has; making the flush unconditional
exposed them.
flushBuffer() now checks the buffer before using it and reports the datapoint
done when there is nothing to write.
Caught by the MySQL CI job, which runs five test files in sequence and so stops
the adapter five times with stubs present. The single-file SQLite run did not
reach the case.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
The MySQL job caught a regression in this PR, now fixed in Not every entry in The old flushes never reached them, because both sat behind Worth recording why the local run missed it: the MySQL job runs five test files in sequence, so it stops the adapter five times with stubs present. The single-file SQLite suite I ran locally — 36 passing — stops it once, and the stubs were not there. A green single-dialect run was not the evidence I treated it as. |
|
This PR is blocked on a release of The two failures are both That is this fix working. Once the adapter stops losing its boundary markers on shutdown, the NULL rows that
Fixed in ioBroker/aggregate#5, merged to Nothing to change here in the meantime. Adjusting the test would only paper over a defect that is already fixed upstream. |
…-unload # Conflicts: # README.md # build/main.js.map
Flushing the buffer on stop also persists the NULL boundary marker that
writeNulls writes on unload. getCounterDiff's "last row before the
window" subquery has no `val IS NOT NULL` filter, so that marker is the
first entry getCounter sees - and aggregate 1.0.2 reads it as the number
0, interpolating the value at the window start between 0 and the first
real reading instead of using the reading itself.
That is why adapter-tests-mysql failed on this branch while master is
green: on master the marker never reached the database. testMySQLExisting
asked for 200 and got 200.3171411627029, which is exactly 0 interpolated
across the 314 s between the previous test file's unload marker and this
window:
(200 - 100 * 314317/315317) + (110 - 10) = 200.31714...
aggregate 1.0.3 drops null entries before computing the counter.
testCommons.js now covers the case, so the version floor is pinned by a
unit test rather than only by the MySQL job.
The lockfile is edited surgically - a plain `npm install` here also drops
the optional `pg` entry and every `"peer": true` flag, which would break
the Postgres job.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixes #577, found while answering #298.
finish()is supposed to write everything still held in RAM when the adapter stops. Three things kept it from doing so — all only visible whenmaxLength> 0, since the default 0 buffers nothing.1. The buffer was only flushed as a side effect
pushValuesIntoDB(id, list)appeared three times and all three sat inside theskippedbranch or thewriteNullsbranch. There was no unconditional flush. A datapoint with neither lost its buffer.Not an exotic case:
writeNullsis forced off for every dialect withmultiRequests === false— which is SQLite — so it hit every SQLite instance, plus anyone who unticked "Write NULL values on start/stop boundaries".2. The counter was shared across datapoints
countlived infinish(), not infinishId(). Every datapoint incremented it and the flush ran on whichever callback brought it back to zero, using that callback'sid. With 50 datapoints, one buffer was written and 49 were not — even withwriteNullson.The same counter let
allFinished()close the pool while later datapoints were still being dispatched. The dispatch is deliberately spread out (delay += dpcount % 50 === 0 ? 1000 : 0), which makes that more likely rather than less.3. Unload could never complete
allFinished()was reachable only through that one flush callback. With nothing to write, the counter never moved, the unload callback never fired, and js-controller had to kill the instance. There was a guard for zero datapoints, none for "datapoints exist and none scheduled work".The change
finishId()now counts its own pending writes, flushes its buffer unconditionally once they are done, and reports the datapoint complete.finish()counts datapoints rather than writes, and anallDispatchedflag stops an early finisher from ending the run while the dispatch loop is still going.Verification — and what it does not show
npm run check:ts,npm run lint(both passes) andprettier --checkare clean; 192 unit tests pass. The full SQLite integration suite was run locally: 36 passing, 0 failing, with a cleanAdapter normal terminated: true.That run proves no regression, not the fix. SQLite forces
writeNullsoff, soWrite 0 NULLappears 0 times in the log — but the suite's datapoints do produceskippedvalues, so the old code reachedallFinished()through that branch and never exhibited the hang. The suite does not cover the "nothing to write" case.Demonstrating the recovered data needs a dedicated test: a datapoint with
maxLength > 0anddisableSkippedValueLoggingon, a few values, a stop, then a query. Worth adding as a follow-up; I did not want to claim this run covers it.Points 1 and 3 are plain readings of the control flow. Point 2 follows from the same code and would be worth confirming against an instance with many datapoints.
🤖 Generated with Claude Code