Skip to content

Always flush the buffer on stop, and let unload finish (#577) - #578

Merged
GermanBluefox merged 4 commits into
masterfrom
fix/flush-buffer-on-unload
Oct 3, 2026
Merged

GermanBluefox merged 4 commits into
masterfrom
fix/flush-buffer-on-unload

Conversation

@GermanBluefox

Copy link
Copy Markdown
Contributor

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 when maxLength > 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 the skipped branch or the writeNulls branch. There was no unconditional flush. A datapoint with neither lost its buffer.

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".

2. The counter was shared across datapoints

count lived in finish(), not in finishId(). Every datapoint incremented it and the flush ran on whichever callback brought it back to zero, using that callback's id. With 50 datapoints, one buffer was written and 49 were not — even with writeNulls on.

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 an allDispatched flag 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) and prettier --check are clean; 192 unit tests pass. The full SQLite integration suite was run locally: 36 passing, 0 failing, with a clean Adapter normal terminated: true.

That run proves no regression, not the fix. SQLite forces writeNulls off, so Write 0 NULL appears 0 times in the log — but the suite's datapoints do produce skipped values, so the old code reached allFinished() 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 > 0 and disableSkippedValueLogging on, 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

GermanBluefox and others added 2 commits October 3, 2026 10:12
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>
@GermanBluefox

Copy link
Copy Markdown
Contributor Author

The MySQL job caught a regression in this PR, now fixed in cf90100.

uncaught exception: Cannot read properties of undefined (reading 'length')
    at SqlAdapter.pushValuesIntoDB (build/main.js:2181:19)
    at flushBuffer (build/main.js:1156:22)
    at Timeout.finishId [as _onTimeout] (build/main.js:1198:13)

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. pushValuesIntoDB() takes list.length on its first line.

The old flushes never reached them, because both sat behind skipped or config, which only a real datapoint has. Making the flush unconditional is exactly what exposed them. flushBuffer() now checks the buffer before using it and reports the datapoint done when there is nothing to write.

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.

@GermanBluefox

Copy link
Copy Markdown
Contributor Author

This PR is blocked on a release of @iobroker/aggregate, not on anything in it. Recording why, so the red MySQL job is not mistaken for a problem with the change.

The two failures are both getCounter returns the summed progression incl. a counter reset, in testMySQLExisting.js and testMySQLExistingNoNulls.js, answering 200.317… and 200.651… instead of 200.

That is this fix working. Once the adapter stops losing its boundary markers on shutdown, the NULL rows that writeNulls is supposed to write actually reach the database — and sendResponseCounter() had no concept of them:

where a NULL sits before correct
inside the window: 100 → null → 110 110 10
before the window 266.67 200

null coerces to 0 in that arithmetic and null > x is false, so the guard that drops a row before the window never fired for one. The fractional results here are the mild version; a restart inside a chart's window adds a whole meter reading to an energy counter.

Fixed in ioBroker/aggregate#5, merged to main as 2f53aaa. There is deliberately no release yet, so npm still serves the old 1.0.2 that ^1.0.2 resolves to, and CI keeps failing until one is cut and this adapter picks it up.

Nothing to change here in the meantime. Adjusting the test would only paper over a defect that is already fixed upstream.

GermanBluefox and others added 2 commits October 3, 2026 16:02
…-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>
@GermanBluefox
GermanBluefox merged commit d4952ef into master Oct 3, 2026
25 of 31 checks passed
@GermanBluefox
GermanBluefox deleted the fix/flush-buffer-on-unload branch October 3, 2026 13:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Buffered values are lost on adapter stop, and unload can hang

1 participant