Skip to content

Bind the SQLite stack at the floor each server line ships - #339

Merged
iderex merged 1 commit into
masterfrom
build/330-sqlite-at-the-floor
Sep 5, 2026
Merged

iderex merged 1 commit into
masterfrom
build/330-sqlite-at-the-floor

Conversation

@iderex

@iderex iderex commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Closes #330.

What was wrong

The archive a server installs holds meta.json and one assembly. That is not a
packaging choice: Jellyfin.Plugin.Stats.csproj is a library, so no package
assembly is copied to its build output at all, and the output is what the
packaging tool zips.

$ unzip -l artifacts/jellyfin-10.11/playback-statistics_0.1.0.0.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
   478208  2026-09-05 14:18   Jellyfin.Plugin.Stats.dll
      854  2026-09-05 14:18   meta.json
---------                     -------
   479062                     2 files

So the Microsoft.Data.Sqlite the assembly binds is a demand on the server, and
master demanded 9.0.11.0 while the floor server of the 10.11 line ships
9.0.10.0. A strong-named assembly found below the referenced version is not
found at all, so the plugin loads, the store never opens, no data folder is
created and no play is stored - on the one server the manifest's targetAbi 10.11.0.0 promises the loudest.

The floors, read rather than recalled

I copied Microsoft.Data.Sqlite.dll out of four server images and read its
assembly version:

$ for tag in 10.11.0 10.11.11 12.0-rc1 12.0-rc4; do
    cid=$(docker create jellyfin/jellyfin:$tag)
    docker cp "$cid:/jellyfin/Microsoft.Data.Sqlite.dll" "Microsoft.Data.Sqlite-$tag.dll"
    docker rm "$cid"
  done
$ [System.Reflection.AssemblyName]::GetAssemblyName(...).Version
Microsoft.Data.Sqlite-10.11.0.dll  -> 9.0.10.0
Microsoft.Data.Sqlite-10.11.11.dll -> 9.0.11.0
Microsoft.Data.Sqlite-12.0-rc1.dll -> 10.0.9.0
Microsoft.Data.Sqlite-12.0-rc4.dll -> 10.0.10.0

SqliteFloorNet9 is 9.0.10 and SqliteFloorNet10 is 10.0.9, declared once
in Directory.Build.props beside the two Jellyfin floors with that reading next
to them. Both project files read the properties and neither names a version of
its own. The plugin references the package with ExcludeAssets runtime, so the
project file now reads the way it already behaved.

Done-when, one at a time

The assembly references of the built package. Read out of the assembly
inside the zip jprm produced, not out of a build tree:

$ unzip -o playback-statistics_0.1.0.0.zip -d zipcheck
$ dotnet run refs.cs zipcheck/Jellyfin.Plugin.Stats.dll | grep -E 'Sqlite|MediaBrowser.Controller'
MediaBrowser.Controller 10.11.0.0
Microsoft.Data.Sqlite 9.0.10.0

and the net10.0 build of the same commit:

$ dotnet build Jellyfin.Plugin.Stats/Jellyfin.Plugin.Stats.csproj -f net10.0
$ dotnet run refs.cs Jellyfin.Plugin.Stats/bin/Debug/net10.0/Jellyfin.Plugin.Stats.dll | grep -E 'Sqlite|MediaBrowser.Controller'
MediaBrowser.Controller 12.0.0.0
Microsoft.Data.Sqlite 10.0.9.0

refs.cs is fifteen lines over System.Reflection.Metadata that print every
assembly reference of a file; nothing about it is in this tree.

The package installed on a jellyfin/jellyfin:10.11.0 server. The zip
unpacked into /config/plugins/Jellyfin.Plugin.Stats, a fresh config and cache,
one generated video in /media, and the reading drove the server over its own
API: wizard, sign-in, add the folder as a library, report a play, read it back.

server answered after 8s: version 10.11.0, wizard completed False
finishing the first-run wizard
  POST /Startup/Configuration -> 204
  GET /Startup/User -> 200
  POST /Startup/User -> 204
  POST /Startup/RemoteAccess -> 204
  POST /Startup/Complete -> 204
signed in as the account the wizard created
the account is cfee3436d4924c21a13c244e64ff6f1e
  POST /Library/VirtualFolders -> 204
the library holds A Reading Film (44ce7b0277ed91031d66c5db2c734264)
  POST /Sessions/Playing -> 204
  POST /Sessions/Playing/Progress -> 204
  POST /Sessions/Playing/Stopped -> 204
  GET /Stats/Users/cfee3436d4924c21a13c244e64ff6f1e/Statistics/last30Days -> 200
  {'Window': 'last30Days', 'ZoneId': 'UTC', 'Plays': 1, 'Watched': '00:00:02.0592442',
   'Finished': 1, 'Abandoned': 0, ...,
   'TopItems': [{'Key': '44ce7b0277ed91031d66c5db2c734264', 'Name': 'A Reading Film',
                 'Plays': 1, 'Watched': '00:00:02.0592442'}], 'Degraded': {}}

The data folder:

$ ls -l server/config/plugins/Jellyfin.Plugin.Stats/
-rwxr-xr-x 478208 Sep  5 14:18 Jellyfin.Plugin.Stats.dll
-rw-r--r--    489 Sep  5 14:20 meta.json
-rw-r--r--  73728 Sep  5 14:21 plays.db

The log:

$ docker logs stats-330-floor 2>&1 | grep -c "could not be read"
0
$ docker logs stats-330-floor 2>&1 | grep -E "\[ERR\]"
$ docker logs stats-330-floor 2>&1 | grep -i "Jellyfin.Plugin.Stats"
[12:20:44] [INF] Emby.Server.Implementations.Plugins.PluginManager: Loaded assembly
  Jellyfin.Plugin.Stats, Version=0.1.0.0, Culture=neutral, PublicKeyToken=null from
  /config/plugins/Jellyfin.Plugin.Stats/Jellyfin.Plugin.Stats.dll
[12:20:47] [INF] Emby.Server.Implementations.Plugins.PluginManager: Loaded plugin:
  Playback Statistics 0.1.0.0

No [ERR] line at all, and the grep for the failing line counts zero rather
than being left unrun.

The same reading against the version that was wrong. I did not want to
inherit the failure from the issue, so I built the identical assembly with the
floor raised back to 9.0.11 and gave it a second fresh 10.11.0 server. Same
image, same media, same script:

$ docker logs stats-330-control 2>&1 | grep -iE "could not be read|Loaded plugin: Playback"
[12:22:30] [INF] Emby.Server.Implementations.Plugins.PluginManager: Loaded plugin:
  Playback Statistics 0.1.0.0
[12:22:30] [ERR] Jellyfin.Plugin.Stats.Capture.PlaybackEventListener: The plays a
  previous run left open could not be finished, because the store could not be read:
  FileNotFoundException. They stay on the file and the next start tries again.
  Recording was not affected.
$ ls -l server/config/plugins/Jellyfin.Plugin.Stats/
-rwxr-xr-x 478208 Sep  5 14:22 Jellyfin.Plugin.Stats.dll
-rw-r--r--    489 Sep  5 14:22 meta.json
  GET /Stats/Users/b75c18e559f84ba496262b652d74f2e5/Statistics/last30Days -> 503

No plays.db, the play answered 503, and the plugin loaded cleanly right up to
the moment the store was touched. That is the failure this change removes, on
this machine rather than on somebody's report of it.

The suite compares the floor against the assembly. Two new checks, and I
proved each by putting the mistake back.

ThePluginBindsTheSqliteTheFloorServerShips reads the reference out of the
built assembly. Built one patch above the floor, with the table and the property
untouched, it alone goes red:

$ dotnet test -f net9.0 -p:SqliteFloorNet9=9.0.11 -p:RestoreLockedMode=false --filter SupportMatrixTests
  SupportMatrixTests.ThePluginBindsTheSqliteTheFloorServerShips [FAIL]
  Assert.Equal() Failure: Strings differ
  Expected: "9.0.10"
  Actual:   "9.0.11"
Failed! - Failed: 1, Passed: 6, Skipped: 0, Total: 7

EverySqliteFloorInTheTableIsTheFloorTheBuildUses compares the new column of
docs/support-matrix.md against Directory.Build.props. With the column moved
to 9.0.11 and the property left alone, both new checks bite and the other five
pass:

  SupportMatrixTests.EverySqliteFloorInTheTableIsTheFloorTheBuildUses [FAIL]
  SupportMatrixTests.ThePluginBindsTheSqliteTheFloorServerShips [FAIL]
Failed! - Failed: 2, Passed: 5, Skipped: 0, Total: 7

The ABI floor job. It now reads the resolved Microsoft.Data.Sqlite out of
packages.lock.json against a floor of its own, the way it already reads the
two Jellyfin packages. Run at this commit:

$ FLOOR=10.11.0 SQLITE_FLOOR=9.0.10 FRAMEWORK=net9.0 python floorcheck.py
ok   Jellyfin.Controller resolved to the floor 10.11.0
ok   Jellyfin.Model resolved to the floor 10.11.0
ok   Microsoft.Data.Sqlite resolved to the floor 9.0.10
exit=0
$ FLOOR=12.0.0-rc1 SQLITE_FLOOR=10.0.9 FRAMEWORK=net10.0 python floorcheck.py
ok   Jellyfin.Controller resolved to the floor 12.0.0-rc1
ok   Jellyfin.Model resolved to the floor 12.0.0-rc1
ok   Microsoft.Data.Sqlite resolved to the floor 10.0.9
exit=0

and against a lock file whose resolved SQLite is one patch higher:

FAIL Microsoft.Data.Sqlite resolved to 9.0.11, and the declared floor is 9.0.10.
     A version belongs in Directory.Build.props, not in a project file.
exit=1

floorcheck.py is that step's own body, cut out of build.yaml by a script
rather than retyped.

The floor job going red on a runner. scratch/330-the-floor-job-goes-red
carries this branch with one difference: the SQLite reference is a literal
9.0.11 in the project file again, Directory.Build.props and the table left
alone, the lockfile regenerated so the restore reaches the comparison. Run
33966016363, dispatched on that ref:

ABI floor build (10.11)   failure
ABI floor build (12.0)    success
call / build              failure

ok   Jellyfin.Controller resolved to the floor 10.11.0
ok   Jellyfin.Model resolved to the floor 10.11.0
FAIL Microsoft.Data.Sqlite resolved to 9.0.11, and the declared floor is 9.0.10.
     A version belongs in Directory.Build.props, not in a project file.
##[error]Process completed with exit code 1.

Two things to read out of that. The 12.0 leg stayed green, so the refusal is the
line that broke rather than the job, and the near miss is one patch rather than a
whole major. And call / build went red beside it for a second reason nobody
designed: with the plugin raised and the suite still at the floor, the suite's
reference is a downgrade and NU1605 refuses it.

error NU1605: Detected package downgrade: Microsoft.Data.Sqlite from 9.0.11 to 9.0.10.
  Jellyfin.Plugin.Stats.Tests -> Jellyfin.Plugin.Stats -> Microsoft.Data.Sqlite (>= 9.0.11)
  Jellyfin.Plugin.Stats.Tests -> Microsoft.Data.Sqlite (>= 9.0.10)

That is the suite reference earning its place a second time: it also pins the
plugin down to the floor from below.

That branch is not for merging and is left where it is; its head is the evidence.

The suite gained the two references, and that is a scope line the issue does not carry

Jellyfin.Plugin.Stats.Tests.csproj is the one file I touched that the issue's
Scope: does not name. It is not optional and it is not a second topic: once the
plugin excludes the runtime asset, nothing reaches the suite through the project
reference, and 300 store tests failed to load Microsoft.Data.Sqlite before
those two lines existed. It is the same shape the file already carries for
Jellyfin.Controller and Jellyfin.Model directly above it.

It differs from those in one way worth stating. The suite cannot follow the
plugin down to the Jellyfin floor, because FakeUserManager does not compile
against IUserManager on 10.11.0. Nothing stops it here, so it takes the floor
package: the store is exercised against exactly the assembly a floor server
supplies, rather than against something newer that happens to satisfy the same
reference.

The means

No new means. This is MSBuild properties, a PackageReference, a step in a
workflow that already exists and xUnit checks in the suite that already holds
this table - every one of them a thing this tree already carries, and every claim
above is backed by a command run at this commit. Adding a version to a project
file, which is the alternative, is the shape that produced the defect.

What is green

$ dotnet test
Passed! - Failed: 0, Passed: 1050, Skipped: 0, Total: 1050 - (net9.0)
Passed! - Failed: 0, Passed: 1051, Skipped: 0, Total: 1051 - (net10.0)
$ npx prettier@3.9.6 --check docs/support-matrix.md .github/workflows/build.yaml
All matched files use Prettier code style!
$ bash tools/invariants/lint.sh --self-test
21 rule(s), each proved by a near miss of its own.
$ bash tools/invariants/lint.sh
21 rule(s) checked over the tree, no match.

The suite was 1048 and 1049 before this; the two added checks are the difference.

No second reader

Nobody else has read this branch. The evidence above stands in place of a second
reader and does not replace one, and every number in it carries the command that
produced it so that a reader can disagree with the reading rather than with me.

The archive a server installs holds meta.json and one assembly, because this
project is a library and no package assembly is copied to its build output.
So the Microsoft.Data.Sqlite the plugin references is a demand on the server
rather than something it carries, and 0.1.0.0 demanded 9.0.11.0 while a
10.11.0 server ships 9.0.10.0. A strong-named assembly found below the
referenced version is not found at all, so that server started the plugin,
failed to open the store, created no data folder and stored no play, while
build.yaml went on offering the archive to it. Issue #330.

The two floors are now declared once, in Directory.Build.props beside the
Jellyfin ones, with the reading that produced them: I copied
Microsoft.Data.Sqlite.dll out of four server images and read its assembly
version, and 10.11.0 gives 9.0.10.0 and 12.0-rc1 gives 10.0.9.0. The plugin
references them with ExcludeAssets runtime, so the project file now reads the
way it behaves, and no version is written in a project file.

The suite gains the two references as well. Nothing reached it through the
project reference once the plugin excluded the runtime asset, and every store
test failed to load the assembly until those lines existed. It takes the
floor rather than a later patch, unlike the Jellyfin references above it,
because nothing in the fakes stops it: the store is exercised against exactly
the assembly a floor server supplies.

What refuses a repeat, and each was proved by putting the mistake back:

- ThePluginBindsTheSqliteTheFloorServerShips reads the reference out of the
  built assembly and compares it against the table. Built with the floor
  raised one patch, it alone goes red, expected 9.0.10 and actual 9.0.11.
- EverySqliteFloorInTheTableIsTheFloorTheBuildUses compares the new column of
  docs/support-matrix.md against Directory.Build.props. With the column moved
  to 9.0.11 and the property left alone, both of those checks go red and the
  other five pass.
- The ABI floor job reads the resolved Microsoft.Data.Sqlite out of
  packages.lock.json against a floor of its own, the way it already reads the
  two Jellyfin packages. Against a lock file saying 9.0.11 it prints FAIL and
  exits 1.

The SQLitePCLRaw.lib.e_sqlite3 reference stays for the advisory and its
comment now says it ships nothing, since the native library that runs is the
server's.

I ran the invariant scan over the tree at this commit: 21 rule(s) checked,
no match.

Signed-off-by: Nils Lehnen <30603423+iderex@users.noreply.github.com>
@iderex iderex added bug Something isn't working area: build Project layout, packaging and build area: release Versioning, manifest and catalogue priority:high Do first labels Sep 5, 2026
@iderex iderex self-assigned this Sep 5, 2026
@iderex iderex added this to the 0.1.1.0 The repair milestone Sep 5, 2026
Comment thread Jellyfin.Plugin.Stats.Tests/SupportMatrixTests.cs Dismissed
@iderex
iderex merged commit 4a1cc7b into master Sep 5, 2026
26 checks passed
@iderex
iderex deleted the build/330-sqlite-at-the-floor branch September 5, 2026 12:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: build Project layout, packaging and build area: release Versioning, manifest and catalogue bug Something isn't working priority:high Do first

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bind the SQLite stack at the floor the manifest promises, on both lines

2 participants