18 September 2026¶
3 changes:
- Two migrations asked
information_schemaa question MySQL answers differently - The unexplained test-suite flakes were TimescaleDB's scheduler segfaulting the server
- …and the other one was the bind mount saying a directory was not there
Two migrations asked information_schema a question MySQL answers differently¶
Yesterday's sessions.url widening shipped with a hand-written catalogue read to decide
whether the column needed touching. Reviewing what that read actually costs on a real
database turned up four things, of which two are defects and two are measurements worth
writing down.
Fixed¶
- MySQL answers
information_schemain upper case.DATA_TYPE, notdata_type, so$result->fields['data_type']is an empty string there and!== 'text'is true of every column. Right by accident on a narrow column; on atextone it reports narrow and rebuilds the table for nothing.WidenApplicationsCallbackhad the same read and the same defect — it is where the idiom was copied from — and both now go throughSchemaBuilder::columnType(), which routes togetColumns()and normalises both engines. Verified by probing the server directly rather than by reading the manual:keys: DATA_TYPE | COLUMN_NAME | CHARACTER_MAXIMUM_LENGTH. information_schemaspans every database on the server. The idiom endsORDER BY table_schema LIMIT 1, which answers from whichever database sorts first — not the one connected. Found the hard way: a scratch database created to time theALTERhad asessionstable of its own, and the test started reading that one and failed. The remaining reads pintable_schematoDATABASE()/current_schema().- MySQL's
MODIFY … TEXTsilently dropsNOT NULL.MODIFYreplaces the whole column definition, so omitting the constraint removes it — only on MySQL, because PostgreSQL'sALTER … TYPEkeeps it. As shipped, the migration gave the two engines different schemas and would have let the upsert write a nullurlinto code that has never had one. The nullability is read first and re-stated, both ways round: a column that was nullable stays nullable, because a migration should change the type and only the type. - A view over the column stopped the whole run. PostgreSQL refuses
ALTER … TYPEon a column a view or rule selects. The framework creates no view oversessions, but an application may — a live-page report overurlis an obvious thing to build — and the framework must not drop somebody else's view to get its ownALTERthrough. The dependents are found throughpg_rewriteand the migration declines instead: the run carries on,migrate:statusnames the view, and the nextmigratetries again. Throwing would have stopped an entire upgrade over a tracking column. - One
ALTERfor both columns. Each one is a separate table rebuild on MySQL.
The cost, measured¶
On 200,000 rows, which is already an unrealistic worst case — the middleware sweeps
time < now-300, so the table holds five minutes of visitors:
| Both columns | Why | |
|---|---|---|
| PostgreSQL | 1.4 ms | varchar(n) → text is binary-coercible with no new constraint, so it is a catalogue update: no rewrite, no scan. An ACCESS EXCLUSIVE lock for the length of that. |
| MySQL | 711 ms | ALGORITHM=INPLACE is refused outright — "Cannot change column type INPLACE" — so it is COPY: the table is rebuilt and writes block. Two separate statements cost 1,564 ms, which is why there is now one. |
And on text as a column type: in PostgreSQL text and varchar are the same storage, so
there is nothing to weigh. In MySQL 8 the difference is that a long value moves off-page,
which is the behaviour wanted for a column whose whole problem was values that did not fit.
Added¶
- Three tests on both lanes:
NOT NULLsurvives the widening, a nullable column stays nullable, and — PostgreSQL only — a view over the column makes the migration decline rather than fail. The last was verified by removing the dependent check and watching it come back ascannot alter type of a column used by a view or rule.
The unexplained test-suite flakes were TimescaleDB's scheduler segfaulting the server¶
Two FrameworkMigrationsPostgreSQLTest errors had been written off as a container starting
slowly — the database system is not yet accepting connections. They came back on the next
run, which is a poor sort of flake, so the container's log got read instead:
background worker "Retention Policy [9262]" was terminated by signal 11: Segmentation fault
DETAIL: Failed process was running: CALL _timescaledb_functions.policy_retention()
LOG: terminating any other active server processes
Seven of those in one day, roughly one per full suite run. Each crashes the postmaster,
puts PostgreSQL into recovery, and kills whatever test happens to be mid-statement. The two
migration errors were the visible ones because they run near the crash; the one-in-fifteen
AccountChangePasswordTest failure reported as unexplained on 17 September is the same
thing landing somewhere else.
The policy is well-formed — pushlog, a timestamptz dimension, drop_after 90 days, no
chunks to drop — and calling it by hand succeeds. It crashes only while the suite is
creating and dropping hypertables underneath it, which makes it a concurrency bug in 2.26.4.
Upgrading is not available: the version is pinned deliberately because it is the oldest the
framework supports, and being ahead of production is what let three migrations fail in
production while every local run passed.
Fixed¶
- The framework's TimescaleDB container runs as
postgres -c timescaledb.max_background_workers=0. Zero segfaults and zero recovery events across a full run afterwards, against seven in the preceding day. - The framework's container only. The
docker-compose.ymlthatinitwrites for an application does not carry the line, and the comment beside it says why: an application's retention and compression policies have to actually run.
Nothing in the suite needs the scheduler. It asserts that policies are registered —
TimescaleInspector reads jobs LEFT JOIN job_stats exactly because a job that has never
run has no stats row — and the software-emulated path is driven directly by PolicyEngine.
Documentation¶
- A section in the Testing Guide, with the line worth remembering: a database error in a
test that has nothing to do with TimescaleDB is worth checking
docker logs pramnos_timescaledb | grep Segmentationagainst before believing it.
…and the other one was the bind mount saying a directory was not there¶
The second unexplained flake, reported yesterday and left open: two errors in one run,
both in ScaffoldFormFeedbackTest, both for the tailwind data set.
UnexpectedValueException: RecursiveDirectoryIterator::__construct(
/var/www/html/scaffolding/themes/tailwind/views): Failed to open directory:
No such file or directory
The directory is tracked in git, is not ignored, nothing in src/ or in any test writes
under scaffolding/, and its mtime on the host predated the run by seventeen days —
which settles it: the directory was never removed and recreated. What went missing was the
container's view of it. The repository is a bind mount, and on macOS Docker Desktop that is
VirtioFS — fakeowner over /run/host_mark/Users — which under load can answer ENOENT
for a directory that is on the disk the whole time.
It is rare. A watcher stat-ing and readdir-ing the same path as fast as the mount allows,
alongside two controls, for the length of a full 16,000-test run, did not reproduce it once.
Two consecutive methods of one class saw it; every other class reading the same path in the
same run did not.
Fixed¶
Pramnos\Framework\Testing\Tree::files(): walks a directory, looks twice 50 ms apart and then raises aRuntimeExceptionnaming the path and saying how to tell a mount blink from a genuinely missing directory — the host mtime is the diagnostic, so the message says so. One retry, not a loop: it is for a mount that blinked, not a way to tolerate a directory that is gone.- The three sweeps that built their own
RecursiveDirectoryIteratorover a theme's views use it. The four that useglob()already assert their list is non-empty, which is the other half of the same problem —glob()returnsfalsehere and a sweep silently passes having examined nothing.
Two things this turned up on the way¶
- A
catchthat could only ever be untested. The first version also wrapped the walk itself, for a blink happening mid-traversal. The only way to make a descend fail on demand is a mode bit, and the container runs as root — so the test for it could onlymarkTestSkipped, which is the shape this suite spent yesterday removing. The branch went instead of the skip. - A dead
isFile()check, written on the assumption that an empty directory is a leaf and therefore arrives. Probed: inLEAVES_ONLY— the default — a directory is never yielded, empty or not. The check is gone andTreeTestpins the traversal mode instead, so switching toSELF_FIRSTfails there rather than in a sweep reading a directory as a file.Treeis at 100% line coverage with no skips, which is what made both visible.