Skip to content

sqlite3 to postgresql - migration - #296

Merged
adrpo merged 2 commits into
OpenModelica:masterfrom
adrpo:sqlite3-to-postgresql
Aug 11, 2026
Merged

sqlite3 to postgresql - migration#296
adrpo merged 2 commits into
OpenModelica:masterfrom
adrpo:sqlite3-to-postgresql

Conversation

@adrpo

@adrpo adrpo commented Aug 11, 2026

Copy link
Copy Markdown
Member

Towards #295: the results move from a sqlite3 file per test machine to one shared PostgreSQL database, and the machines coordinate through it instead of overwriting each other.

Today every job downloads its machine's sqlite3.db, tests, and copies the whole file back. Two machines running the same job both write a full copy, and whichever finishes last wins - the other one's results are gone. That is the thing this PR removes.

What is in the database

omdb on openmodelica.org now holds all 160,247,359 rows of both machines: ripper1's 34 tables and ripper2's 54. 50 GB, or 20 GB on disk after the ZFS lz4 the tablespace sits on.

The layout is a 1:1 mirror of the sqlite3 one - one table per branch, same column names, only integer/real becoming bigint/double precision - so the test scripts push new results with the statements they already use.

What the mirror adds is a key per table, which is what makes a shared database possible at all:

table key
<branch> (date, libname, model)
omcversion (date, branch)
libversion (date, branch, libname, confighash)

They hold across all 160 million migrated rows, and they stop a run that is pushed twice from being stored twice.

Job claiming

Before testing a library, test.py claims it in job_claim, keyed by exactly the question the run already asks - which library, in which version, against which compiler and configuration:

CREATE TABLE job_claim (
  branch text, libname text, libversion text, omcversion text, confighash bigint,
  host text NOT NULL, state text NOT NULL,
  claimed_at timestamptz NOT NULL DEFAULT now(),
  heartbeat  timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (branch, libname, libversion, omcversion, confighash)
);

Only one machine can win the claim. The others print

Skipping Buildings_9.1.0 as ripper2 has been testing it since 2026-08-10 22:14:03

and move on to the next library. The winner refreshes heartbeat every minute and sets state='done' when the results are written, so a machine that dies parks its jobs for 30 minutes rather than forever.

Using it

resultsdb.py puts both backends behind one interface. Every script takes --db, which defaults to the LIBTEST_DB environment variable:

./test.py --branch=master --db=postgresql://om@openmodelica.org/omdb configs/conf.json

The password comes from PGPASSWORD or ~/.pgpass, never from the URL or a command line. The scripts write the same statements for either backend, with ? as the placeholder, and ask the connection where the dialects genuinely differ: quote(), tableExists(), groupConcat(), countIf(), likeNoCase(), insertIgnore().

The Jenkinsfile gets a postgres parameter, on by default, which points the pipeline at omdb and drops both the wget of sqlite3.db and the sshPublisher that copied it back. Untick it and the old behaviour is exactly what it was. The password is a Jenkins secret file credential, omdb-pgpass, bound to PGPASSFILE at pipeline level.

Migrating and catching up

export PGPASSFILE=~/.pgpass
DB=/var/www/libraries.openmodelica.org/sqlite3
./sqlite2postgres.py --host localhost --sqlite $DB/ripper1/sqlite3.db --source ripper1
./sqlite2postgres.py --host localhost --index
./sqlite2postgres.py --host localhost --sqlite $DB/ripper2/sqlite3.db --source ripper2 --skip-existing
./sqlite2postgres.py --host localhost --index
./sqlite2postgres.py --host localhost --sqlite $DB/ripper1/sqlite3.db --verify

ripper1 goes in first, then ripper2 with --skip-existing, so ripper1 wins whenever a key collides. On the current data the priority never actually fires - not one (branch, date) is shared between the two machines, not even for the four branches both of them test - so the merge is a plain union. The rule matters for re-runs and for two machines pushing later on.

The script needs nothing but the standard library and the psql client. It streams through COPY in batches and commits each batch together with its progress row in migration_progress, so an interrupted migration continues where it stopped instead of duplicating rows. Run: ~50 minutes per machine at 48k rows/s, on the server itself.

--catch-up copies the runs a machine wrote after the migration read its database, which any job still using its sqlite3 file keeps doing until it is switched over. It picks the runs by date rather than continuing from the rowid it stopped at, because VACUUM renumbers the rowids of these tables and clean-empty-omcversion-dates.py runs one after every test - and because two machines write into the same table for the branches they both test. Two to three minutes per machine, and repeating it is harmless.

How it was checked

  • --verify on both machines: 0 of 34 and 0 of 54 tables incomplete; every table's row count matches its source, and the shared tables are clean unions (libversion 310901 + 452246 = 763147, omcversion 11271 + 8776 = 20047).
  • Round-trip fidelity: empty strings, NULLs, tabs, newlines, backslashes, a literal \N and unicode in model names all come back byte-identical. This is why COPY uses its text format and not CSV - an empty CSV field reads back as NULL, which would have silently turned the empty libversion strings of the old data into NULLs.
  • Interrupting a migration: killed mid-run, restarted, no gaps and no duplicates.
  • resultsdb.py self-test, both backends, 15 checks: two machines racing for one claim, the loser refused, a released claim taken over.
  • Report queries against both databases: the same queries run on ripper1's sqlite file and on the migrated data return the same rows - phase counts, per-phase sums and regression rows. Checked on master, whose table holds 43 million of them (92 libraries, 17 regression rows), and on heavy_tests, conversion (252 regression rows) and basemodelica_jl_master. The two only render the doubles with a different number of digits, and the report parses them back with float().

That last test earned its keep: it caught a %-versus-+ precedence slip in the ported regression query that would have thrown TypeError on the first all-reports.py run.

Before enabling it

  • ripper1 and ripper2 need psycopg2 - pip3 install psycopg2-binary. It is in requirements.txt and in .CI/build-dep, but the non-docker node stages run test.py with the node's python.
  • test.py has not been run end-to-end against PostgreSQL here, because that needs omc and the libraries. The claim path and the insert path are verified against synthetic and migrated data, not a live run. Worth ticking postgres on one small job first - heavy_tests or generateSymbolicJacobian - and checking that job_claim fills in and the new rows land.
  • Run --catch-up once more right after the switch, to sweep up whatever the last sqlite-era jobs wrote in the meantime.

Still open in #295: #78 (build each FMU once and simulate it with several tools) and dockerising the remaining Jenkins jobs.


Generated by Claude Code.

@adrpo
adrpo requested review from AnHeuermann and sjoelund August 11, 2026 00:23
@adrpo

adrpo commented Aug 11, 2026

Copy link
Copy Markdown
Member Author

@AnHeuermann and @sjoelund maybe you can have a quick look at this before I activate it so we can run all jobs on all the library testing computers.

adrpo added 2 commits August 11, 2026 17:35
Towards a network based SQL database for the library testing, issue OpenModelica#295.

sqlite2postgres.py copies the per-machine sqlite3 databases into PostgreSQL.
The PostgreSQL layout is a 1:1 mirror of the sqlite3 one - one table per branch
with the same column names, only the types adapted - so that the test scripts
can push new results to the network database with the statements they already
use.

The two machines are merged into the same tables, ripper1 first and ripper2
with --skip-existing, so that the row already in the database wins whenever a
key collides. The keys the migration adds - (date, libname, model) for a branch
table - are what makes a shared database possible at all: they hold across all
160 million migrated rows, and they stop a run that is pushed twice from
storing its results twice.

The script needs nothing but the standard library and the psql client, streams
the rows through COPY in batches, and commits each batch together with its
progress row in [migration_progress], so a migration that is interrupted
continues where it stopped instead of duplicating rows. --index creates the
keys and the index the reports need, --verify compares the row counts of both
sides.

--catch-up copies the runs a machine wrote after the migration read its
database, which a test still using its sqlite3 file keeps doing until the jobs
are switched over: a wasm-jit run of 19527 models finished right after the
migration and was missing from the shared database until it was caught up. It
picks the runs by date rather than by continuing from the rowid it stopped at,
because VACUUM renumbers the rowids of these tables and
clean-empty-omcversion-dates.py runs one after every test, and because two
machines write into the same table for the branches they both test. Reading a
whole database costs two to three minutes, and the keys make repeating it
harmless.

COPY is used in its text format rather than CSV because an empty CSV field
reads back as NULL, which would silently turn the empty libversion strings of
the old data into NULLs. For the same reason only the key columns are NOT NULL:
the sqlite3 tables declare every column NOT NULL, but tables created by an
older test.py kept their laxer declaration and the historical rows do not hold
up.

doc/README.md documents what is actually in the database: the per-branch
tables, omcversion and libversion, what every column means, how finalphase maps
to the phase names, how a test run writes its results, and how to run the
migration.

---
Generated by Claude Code.

Signed-off-by: Adrian Pop <adrian.pop@liu.se>
…iles

Second part of issue OpenModelica#295: the test machines can now write their results to one
network database and coordinate through it, instead of each copying a sqlite3
file in, testing, and copying it back - the step that made two machines
overwrite each other depending on which one finished last.

resultsdb.py holds both backends behind one interface. The scripts write the
same statements for either, with "?" as the placeholder, and ask the connection
where the dialects genuinely differ: quoting a branch name, testing whether a
table exists, concatenating a group, counting a condition, matching a branch
without case, skipping a row that is already there. Every script takes --db,
which defaults to the LIBTEST_DB environment variable, so Jenkins sets the
database once for the whole pipeline rather than on forty invocations.

A machine claims a job in [job_claim] before testing a library, keyed by
exactly the question the run already asks: which library, in which version,
against which compiler and configuration. Only one machine can win the claim,
and the others skip that library and move on. The winner refreshes a heartbeat
every minute and marks the claim done when the results are written, so a
machine that dies parks its jobs for STALE_CLAIM_MINUTES rather than forever.
A local sqlite3 file has a single writer, so there claim() always says yes.

Two PostgreSQL specifics were needed for the reports: GROUP_CONCAT relies on
sqlite keeping the order of the subquery that feeds it, so the ported query
orders inside the aggregate, and COUNT(x or null) becomes COUNT(*) FILTER.
Running the report queries against ripper1's sqlite file and against the
migrated database gives the same rows, down to the phase counts, the per-phase
sums and the regression rows: checked on master, whose table holds 43 million
of them, and on heavy_tests, conversion and basemodelica_jl_master. The two
only render the doubles with a different number of digits, and the report
parses them back with float().

The Jenkinsfile gets a "postgres" parameter, on by default, which selects the
database and drops the download and the publishing of sqlite3.db. Unticking it
restores the old behaviour unchanged. The password comes from a Jenkins secret
file credential, omdb-pgpass, bound to PGPASSFILE, so it never reaches a
command line or the build log.

---
Generated by Claude Code.

Signed-off-by: Adrian Pop <adrian.pop@liu.se>
@adrpo
adrpo force-pushed the sqlite3-to-postgresql branch from ea8596a to 4b7ec32 Compare August 11, 2026 15:35
@adrpo

adrpo commented Aug 11, 2026

Copy link
Copy Markdown
Member Author

I will merge this to check on the actual computers if this works properly. We'll sort the issues afterwards.

@adrpo
adrpo merged commit e48fd06 into OpenModelica:master Aug 11, 2026
7 checks passed
adrpo added a commit that referenced this pull request Aug 11, 2026
…299)

Every build of the library testing has failed since #296 with

  org.jenkinsci.plugins.workflow.steps.MissingContextVariableException:
  Required context class hudson.FilePath is missing

before a single stage ran. The password of the results database is a secret
file, and Jenkins writes such a file into the workspace of a node; #296 bound
it in the environment of the pipeline, which has agent none and therefore no
workspace at all.

It is now bound where a node exists: in the environment of the two report
stages, beside the IDA_EMAIL credential they already bind that way, and around
the test run and the cleanup in runRegressiontest, the way withSccache already
binds its key. LIBTEST_DB stays in the environment of the pipeline; it is a
string and needs nothing.

Nothing about how the scripts read the password changes: libpq still finds it
through PGPASSFILE.

---
Generated by Claude Code.
adrpo added a commit that referenced this pull request Aug 11, 2026
The image the report stages run in was still built on build-deps:v1.16.3, which
is the dependency image of OpenModelica 1.16. It now uses ubuntu-22.04, the one
the OpenModelica repository builds against in .CI/common.groovy and every cmake
Jenkinsfile, so the reports run on the same Ubuntu and the same Python as
everything else.

That also gets psycopg2-binary a wheel to install rather than a source build,
which needs pg_config from libpq-dev; the results database needs psycopg2 since
#296.

Two things the change makes explicit rather than assume of the base image: apt
is told -y, because an image that does not assume it turns the build into a
prompt that never gets answered, and python3-pip is installed, because the line
after it needs pip and the base may not carry it.

---
Generated by Claude Code.
adrpo added a commit that referenced this pull request Aug 11, 2026
The results of a job went into a per-machine sqlite3 file that had to be
downloaded before the run and published back afterwards, and since #296 they go
into the shared database instead. Which of the two was a tick box on the job,
defaulting to the shared one.

Nobody should be able to send a day of testing to a file nobody reads by
unticking a box, and keeping the old path around only leaves two ways of doing
the same thing for someone to wonder about later. Both are gone:

  - the "postgres" parameter, and the branches it guarded;
  - copying ~/TEST_LIBS_BACKUP/<machine>-sqlite3.db into the workspace before a
    run, copying it back after, and the sshPublisher that uploaded it;
  - fetching a machine's sqlite3.db in the two report stages, which now read the
    shared database directly;
  - keeping a week of dated backups of those files on every node;
  - dbPrefix and sshConfig, which existed only to name and publish that file,
    from runRegressiontest and its twenty-three callers.

The scripts themselves are untouched and still write a sqlite3 file by default:
that is what a developer running test.py gets, and what the GitHub checks use.
It is the pipeline that no longer has a second way of doing this.

---
Generated by Claude Code.
adrpo added a commit that referenced this pull request Aug 11, 2026
The results of a job went into a per-machine sqlite3 file that had to be
downloaded before the run and published back afterwards, and since #296 they go
into the shared database instead. Which of the two was a tick box on the job,
defaulting to the shared one.

Nobody should be able to send a day of testing to a file nobody reads by
unticking a box, and keeping the old path around only leaves two ways of doing
the same thing for someone to wonder about later. Both are gone:

  - the "postgres" parameter, and the branches it guarded;
  - copying ~/TEST_LIBS_BACKUP/<machine>-sqlite3.db into the workspace before a
    run, copying it back after, and the sshPublisher that uploaded it;
  - fetching a machine's sqlite3.db in the two report stages, which now read the
    shared database directly;
  - keeping a week of dated backups of those files on every node;
  - dbPrefix and sshConfig, which existed only to name and publish that file,
    from runRegressiontest and its twenty-three callers.

The scripts themselves are untouched and still write a sqlite3 file by default:
that is what a developer running test.py gets, and what the GitHub checks use.
It is the pipeline that no longer has a second way of doing this.

---
Generated by Claude Code.
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.

1 participant