Reduce the size of the Wikidata cache

In Wikidata cache I converted the Wikidata dump into a 396GB SQLite database with one bz2 blob per entity. It has more than 120 million rows and a mean row of 2452 bytes. Two things could be improved: the page size and the compression.

Page size

SQLite does not write rows to the file one after another. It stores the database as a sequence of fixed size pages and packs rows into them, and the file is always a whole number of pages. The default page size is 4096 bytes.

A row that does not fit in the space left on a page starts a new page. A row too big for an entire page keeps part of itself there and puts the rest into a chain of extra pages, called overflow pages.

My mean row is 2452 bytes. Two of them are 4904 bytes and do not fit in a 4096 byte page, one of them does. So most pages hold a single row, and the remaining 1.6kB of each page is unusable.

The overflow pages are not where the waste is. SQLite chooses how much of a row to keep in the main page so that whatever spills over fills its overflow pages exactly. The leftover space in the ordinary pages is the whole problem, and a larger page makes it proportionally smaller, because more rows share the same leftover.

Sampling 20000 random rows and applying SQLite's layout rules to them gives the table for this database. leaf slack is the wasted space and overflow/row is how many extra pages reading one row has to follow:

page_size      table  leaf slack  overflow/row   vs now
      512     309.1G       10.6G           4.4
    1,024     318.7G       20.9G           1.9
    2,048     345.4G       47.6G           0.7
    4,096     384.6G       87.7G           0.1    +0.0%  <- now
    8,192     362.9G       67.2G           0.0    -5.6%
   16,384     328.5G       33.2G           0.0   -14.6%
   32,768     311.9G       16.6G           0.0   -18.9%
   65,536     303.8G        8.5G           0.0   -21.0%

88GB of the 396GB was dead space. Small pages avoid it as well, because then almost everything lands in overflow pages, which are packed exactly. But at 512 bytes every read follows 4.4 of them, so I went the other way, to 32768.

page_size can only be set on an empty database, so changing it means writing a new file. VACUUM INTO does that without touching the source:

sqlite3 wikidata-cache.db "PRAGMA page_size=32768; VACUUM INTO 'wikidata-cache-new.db';"

90 minutes, 396GB to 324GB, 72GB recovered. The old database served the API throughout and stayed the rollback until the new one was verified. The output of VACUUM INTO is not in WAL mode whatever the source was, so PRAGMA journal_mode=WAL has to be set again after the swap.

Compression

The old database compressed each entity on its own with bz2.compress(), which defaults to level 9. I picked that in the first version without comparing it to anything. Measured on 15000 entities sampled from across the dump:

codec                 total   ratio  vs bz2-9       comp     decomp
bz2-9                 35.4M    6.34     +0.0%      9.4MB/s     64.2MB/s
zstd-3                33.8M    6.65     -4.5%    321.9MB/s   1079.9MB/s
zstd-12               31.5M    7.14    -11.1%     22.4MB/s   1139.0MB/s
zstd-19               30.8M    7.30    -13.1%      3.4MB/s   1066.1MB/s
zstd-12+dict          17.4M   12.91    -50.8%     36.9MB/s   1780.0MB/s
zstd-19+dict          16.2M   13.90    -54.3%      3.7MB/s   1936.0MB/s
xz-6                  30.9M    7.27    -12.8%      5.7MB/s    151.5MB/s

bz2 is last on ratio and 34 times slower to compress than zstd-3. zstd-19 costs ten times the CPU of zstd-12 for two more points. xz-6 matches zstd-19 on ratio and decompresses eight times slower, which every API request would pay.

On ratio alone none of the plain codecs is worth much, 11 percent at best. The speed is the real difference. zstd-12 compresses 2.4 times faster than bz2 and decompresses 18 times faster, and zstd-3 compresses 34 times faster. Decompression runs on every API request, so 64MB/s against 1139MB/s is what shows up day to day, and it is also why xz is out despite the good ratio.

The size only moves with a dictionary, the two rows at the bottom of the zstd ones. The new database uses zstd level 12 with a dictionary, which is the next section.

The dictionary

A zstd dictionary is a fixed blob of typical data that the compressor is given up front. An entity compresses to a couple of kB, and much of that is the first occurrence of boilerplate like mainsnak or snaktype, which the rest of the entity then back-references. The dictionary pre-loads it, so it is never spelled out.

The size is a permanent decision, because every blob written with a dictionary is unreadable without it, so it is worth sweeping:

dict size     total   ratio  vs no dict   train
     none     31.5M    7.14
  112,640     17.4M   12.91      -44.7%     46s
  262,144     15.8M   14.19      -49.7%     59s
  524,288     15.4M   14.63      -51.2%     61s
1,048,576     15.3M   14.71      -51.5%     62s
2,097,152     15.4M   14.63      -51.2%     61s

The percentages are against zstd-12 without a dictionary, the 31.5M in the first row. The ratio peaks at 1MB and falls back at 2MB, so it is a real optimum and not the end of the range.

The zstd-12+dict row in the codec table used zstd's default size of 112640 bytes and produced 17.4M. At 1MB the same 15000 entities compress to 15.3M, so the default costs 12 percent. Against the 35.4M of bz2-9 that is 56.8 percent smaller.

zstd hands back a smaller dictionary than asked for when the training data does not support it, roughly 100 times the dictionary size being the rule of thumb. With 227MB of samples all five came back at the full requested size.

Sampling the dump

When benchmarking a dictionary on this dump the samples have to be spread out. Consecutive Q-ids are batch imports, thousands of near-identical scholarly articles, genes or asteroids in a row. 15000 consecutive lines made the dictionary look 60.5 percent better than bz2 instead of 50.8, because it memorised one batch and then scored itself on more of the same batch. The same window put the mean entity at 28.8kB instead of 14.6kB, since the head of the dump holds the old, heavily edited items. Sampling every 2000th line across half the dump fixed both.

The new conversion

The old version ran eight workers writing eight separate SQLite files and merged them afterwards, because SQLite allows only one writer. The new version keeps one writer but makes it the parent process: a pool of workers parses and compresses, the parent only inserts. That removes the merge, which used to write the whole dataset a second time.

The database stays readable during a conversion. Blobs identify themselves by their first bytes, zstd frames with 28 B5 2F FD and bz2 streams with BZh, so a half-converted table serves correctly and needs no format column:

def decompress(blob):
    if blob[:4] == ZSTD_MAGIC:
        dict_id = zstandard.get_frame_parameters(blob).dict_id
        return _decompressor(dict_id).decompress(blob)
    if blob[:3] == BZ2_MAGIC:
        return bz2.decompress(blob)

A zstd frame records which dictionary it needs, so a missing one raises instead of returning garbage.

The dictionary itself exists twice. It is 1MB, small enough to keep in the git repo, and that copy is what the conversion compresses with. The conversion also writes it into a codec_dict table in the database it builds, and that is the copy the API loads at startup. The database is self contained that way, so the container image does not need the file at all, and the blobs cannot end up somewhere without the dictionary that decodes them.

The number of lines read is committed in the same transaction as the rows it covers, so a crash loses at most one batch and a restart re-streams the prefix and skips it.

Serving while writing needed one change to the container. It used to mount the database file itself, read only, which worked as long as nothing was writing to it. In WAL mode the recent writes sit in wikidata-cache.db-wal next to the database, and a single file mount does not see that file. SQLite also needs to write the -shm index to read a WAL database at all, so a read only mount cannot open one. The volume is the directory now, and writable:

volumes:
  - ./data:/app/data

The read only part is done by the application instead, with PRAGMA query_only = ON on its connection.

The next dump

Part of the work can be skipped next time. modified is a column and not something inside the blob, so comparing it against the dump is one index lookup and no decompression. Once a full pass has finished, the workers drop the entities whose modified did not change. That saves the compression and the write, but not the bzip2 decompression of the dump and not the json parsing, and those are most of the CPU. The first update done this way skipped 116.3 million of 121.2 million entities and took 5.9 hours, about twice as fast as a full pass. The 121 million index lookups turned out to be cheap: the index stays in the page cache, so a lookup costs far less than the compression and the write it replaces.

The result

396GB to 141GB, so 2.8 times smaller. 72GB of that was the page size and the rest the codec with its dictionary. Per row the compressed entity went from 2604 bytes to 1166.

Reads are 28 times faster to decompress, 64MB/s against 1780MB/s, so an API request now spends its time on SQLite and on json parsing rather than on bz2. Compressing is 3.9 times faster too, which is what makes a full pass affordable at all.

The full conversion is 2.4 times faster than the old version that wrote eight databases and merged them, on the same machine. Another part that matters is that the database was readable the whole way through, apart from a container restart when the new file replaced the old one. I can update the database a lot faster now when a new wikidata dump is released.

Getting a sprite to talk to my tailnet-only Forgejo

I want to build a small project and run the whole thing -- code, data, hosting -- on a sprite, to find out whether I like the concept. A sprite is a persistent Linux VM from fly.io that pauses when idle and wakes on an inbound HTTP request. The first problem is not the app, it is git: my Forgejo is only reachable inside my tailnet.

The sprite now reaches it over SSH through my Hetzner box, with no Tailscale in the sprite at all. Tailscale does work on a sprite, it just does not survive a pause; that part is further down.

Git through a jump host

The VPS is always on and already in the tailnet, so it can be the jump host:

Host vps
  HostName <vps-hostname>
  User <user>
  IdentityFile ~/.ssh/id_ed25519_vps

Host forgejo
  HostName <forgejo-ip>
  User git
  ProxyJump vps
  IdentityFile ~/.ssh/id_ed25519_forgejo
  IdentitiesOnly yes

ProxyJump only makes the VPS open a TCP socket to port 22. The SSH session stays end-to-end, so the Forgejo deploy key authenticates directly and the VPS never sees plaintext git traffic.

id_ed25519_forgejo is a deploy key, added on the repository itself under Settings -> Deploy Keys with write access enabled. That scopes it to this one repo -- an account SSH key would hand the sprite every repository I have.

The sprite's key is restricted on the VPS side:

restrict,port-forwarding,permitopen="<forgejo-ip>:22" ssh-ed25519 AAAA... sprite-jump

restrict disables port forwarding as well, and permitopen only narrows forwarding that is already allowed -- it does not enable it. Without port-forwarding in that list the jump fails with administratively prohibited. With both, the sprite gets a TCP path to Forgejo and nothing else: no shell, no PTY, no other destination.

Services on a sprite

PID 1 is tini and there is no systemctl, which I first read as "no daemons". There is an in-VM CLI called sprite-env instead, and it does the part of systemd that matters here:

sprite-env services create <name> --cmd <binary> --args "a,b,c"
sprite-env services list | get | start | stop | restart | delete
sprite-env services signal <name> TERM

Services restart automatically on boot, which on a sprite means after every cold pause. --cmd takes the binary only, arguments go into a comma separated --args; there is also --env, --dir and --needs for dependencies. State comes back as JSON, and stdout and stderr land in /.sprite/logs/services/<name>.log:

{"name":"tailscaled","state":{"status":"running","pid":1167,
 "started_at":"2026-08-02T19:37:05Z","next_restart_at":"0001-01-01T00:00:00Z"}}

Exactly one service may claim --http-port, and that service is auto-started when an HTTP request arrives at the sprite's public URL -- the same request that wakes a paused sprite. The documentation is explicit that you should not start a background process yourself next to a service, because the service manager owns the process lifecycle.

Tailscale works until the sprite pauses

Kernel mode works, so no userspace networking is needed. /dev/net/tun exists and opens O_RDWR as the unprivileged sprite user, even though the mode has no read bits:

$ ls -l /dev/net/tun
c-w--wx-wT 1 root root 10, 200 Aug  2 19:27 /dev/net/tun

CAP_NET_ADMIN is in CapEff and ip link add dummy0 type dummy works. A fresh sprite has no egress policy -- /.sprite/policy/network.json does not exist -- and tailscale netcheck reports UDP: true.

So tailscaled becomes a service, wrapped in sudo because it needs root:

sprite-env services create tailscaled --cmd /usr/bin/sudo \
  --args "-n,/usr/sbin/tailscaled,--state=/var/lib/tailscale/tailscaled.state,--socket=/var/run/tailscale/tailscaled.sock,--port=41641"

Logging the node in is a separate step, and the sprite has no browser, so it needs an auth key:

sudo tailscale up --auth-key=tskey-auth-... --hostname=sprite

Use a non-ephemeral key -- an ephemeral node gets reaped while the sprite sleeps. The state lives in /var/lib/tailscale/tailscaled.state on the persistent disk, so this is a one-off.

/etc/resolv.conf sits on a read-only overlay, so tailscaled cannot install MagicDNS. *.ts.net names then fall through to public DNS, which answers with addresses that are not my tailnet and do reply to ping:

$ dig +short A forgejo.<tailnet>.ts.net @1.1.1.1
185.40.234.37
185.40.234.172
185.40.234.210

/etc/hosts is writable, so pin the peer there, or put the tailnet IP straight into ~/.ssh/config.

That holds until the first pause:

$ tailscale status | grep forgejo
<forgejo-ip>  forgejo  linux  active; relay "fra", tx 2340 rx 0

tx 2340 rx 0 -- the peer is still listed as active, packets go out, nothing comes back. Every connection hangs for 2m13s and then times out, and it does not recover on its own. tailscale ping still answers via DERP while real traffic is black-holed, so it is not a usable health check. Restarting the service fixes it, and it comes back with a direct connection instead of DERP:

$ tailscale ping forgejo
pong from forgejo (<forgejo-ip>) via <home-ip>:62264 in 22ms

This is not a Tailscale bug. A pause freezes the process and drops its TCP connections; tailscaled thaws with dead socket state. From inside the sprite you can only detect it and restart, never prevent it.

With the jump host there is no daemon left in the sprite that could keep broken sockets after a pause. When the sprite pauses, it aborts the git command that is running, and the next one opens a new SSH connection and works. That is what makes the extra hop the right trade here.

The NIWIS API and how low the Rhine actually is

NIWIS went online on 2026-07-15, run by the Bundesanstalt für Gewässerkunde. It is the first nationwide low-water information system for Germany. I wanted the current water level and discharge of the Rhine and the Neckar together with the long-term average, from one source. The API provides both.

Endpoints and stations

The API is documented on one page and lives at https://niwis-online.de/api/daten, no authentication, JSON, dates as YYYY-MM-DD, decimal point. Values are daily. For sub-daily data PEGELONLINE is the source, and the 70 WSV stations in NIWIS point back to it in their urlInstitution.

There are 698 stations, 397 of them on a named river with level or discharge data, the rest groundwater and springs. /messstelle lists them without the river name, so the river comes from /stammdaten, one request per station. I cache the result in a JSON file.

Derived quantities

/abgeleiteteGroesse returns a catalogue of 43 statistics. Each entry names the endpoint to call it from and the parameters it needs:

{
  "abgeleiteteGroesse": "MQ",
  "messgroesse": "ABFLUSS",
  "benoetigtReferenzzeitraum": true,
  "benoetigtZeitintervall": false,
  "endpunkt": "berechneEinzelwertNummer"
}

A client needs no hardcoded list of statistics, it can loop over the catalogue and build each request from the two boolean flags. The ones I use are MQ/MW (mean discharge and level), MNQ/MNW (mean annual low), Median Q pro Kalendertag and the deciles per calendar day. Reference period and year definition -- calendar year, hydrologisches Jahr or Wasserhaushaltsjahr -- are parameters.

The reference period cannot start before 1991. 1961--1990 returns 400 with Beginn des Referenzzeitraums darf nicht vor dem Jahr 1991 liegen, so a comparison against the older climate normal is not possible. Any window inside the allowed range works, 1991--2019 and 1996--2025 both compute.

In late summer the per-calendar-day median is the useful reference, not the annual mean.

The classification has four steps: "extrem niedrig", "sehr niedrig", "niedrig" and "Kein Niedrigwasser". The last one is open ended upwards and only means "not low water", so a gauge at its long-term average falls into it and so does a gauge in flood. NIWIS classifies low water and nothing else.

Missing values

Missing data appears in three different forms.

A statistic that cannot be computed returns 200 with an empty payload and hatZuvieleFehlwerte: true. That happens when more than 10% of the underlying values are missing, today for 49 of the 359 discharge stations.

Invalid parameters return 400 with a JSON message.

In the measurement series a missing value is -777 with a flag of Fehlwert or BfGAdded, not null:

{"datum": "2026-08-02", "messwert": -777.0, "einheit": "m³/s", "flag": "Fehlwert"}

Filtering on null does not catch these, and filtering by sign is wrong: -777 m³/s is impossible, a water level of -3 cm is not, and Emmerich is at -3 cm. In a sample of 60 stations every -777 carried a flag and no real value did, so the flag is the reliable signal.

Licences

lizenz is per station, with six different values across the 698 stations:

dl-zero-de/2.0   220
dl-by-de/2.0     210
cc-by/4.0        183
dl-de/by-2-0      46
ccbync/4.0        29
cc by-sa 3.0      10

dl-by-de/2.0 and dl-de/by-2-0 are the same Datenlizenz Deutschland spelled two ways, and 29 stations are non-commercial. Republishing values needs a per-station check.

A water level has no useful percentage

Every gauge counts from its own zero mark, the Pegelnullpunkt. That mark is neither the river bed nor sea level, it was fixed at some point in the past, and it sits at a different height at every gauge: 97.72 m above sea level at Maxau, 8.00 m at Emmerich. So 0 cm does not mean "no water", it means "the water is exactly at that mark".

A water level is therefore like a temperature in Celsius. You can work out what percentage of the yearly average today's 15 °C is, and the answer tells you nothing, because 0 °C is not "no temperature". The same thing happens on the Rhine:

station             W cm   %MW    Q m3/s   %MQ
Worms                  9    4%       480   35%
Duisburg-Ruhrort     149   36%       656   30%

Same river, same day, and NIWIS puts both in the worst class, "extrem niedrig". By water level one is at 4% of its average and the other at 36%, nine times as much. By discharge they are at 35% and 30%, which is what two gauges on one river should look like.

Discharge is different because 0 m³/s really does mean no water. That makes it the number to compare between gauges, and the water level the number a ship's captain reads.

Emmerich shows the problem from the other end: it is at -3 cm today, below its own mark, so its percentage comes out negative.

The Rhine on 2026-08-03

Against the 1991--2020 reference period:

station             Q m3/s   %MQ  %day    W cm
Maxau                  428   35%   37%     320
Speyer                 415   34%   36%     173
Worms                  480   35%   38%       9
Mainz                  532   33%   37%     126
Kaub                   557   34%   38%      28
Andernach              614   30%   39%      30
Bonn                   607   30%   39%      83
Köln                   638   31%   40%      69
Düsseldorf             649   31%   39%      22
Duisburg-Ruhrort       656   30%   38%     149
Wesel                  647   29%   38%      87
Rees                   666   30%   38%      33
Emmerich               695   31%   39%      -3

Q is the discharge, the volume of water passing the gauge every second. It grows downstream as tributaries join, which is why Emmerich carries more than Maxau. %MQ compares it to the long-term annual mean, %day to the median for this same date, and %day is the fairer number because a river runs lower in August than in March anyway.

Every gauge on the river is at 36 to 40% of its normal 3 August flow, so a bit over a third of the usual water, and NIWIS puts all 13 of them in its worst class, "extrem niedrig". Every one of them is also below the lowest discharge measured on a 3 August anywhere in the reference period, which makes today a record low for the date along the whole German Rhine. The decile endpoint supplies those minima, its outermost bounds being the measured extremes of the period. At Kaub, the gauge that Rhine shipping uses to work out how much cargo a vessel can load, the previous minimum for this date is 769 m³/s and the river is running 557.

The values are from 2 August, except the discharge at Emmerich, which is from 30 July because the series has gaps since.

Neckar: level and discharge disagree

Neckar
station                   Q m3/s   %MQ  %day Q class           W cm   %MW  %day W class
Horb                           3   22%   44% sehr niedrig        34   50%   72% niedrig
Wendlingen-Kläranlage         11   29%   56% niedrig             47   57%   75% niedrig
Plochingen                     -     -     - -                  154   93%   99% kein NW
Lauffen                        -     -     - -                  221   89%   95% kein NW
Rockenau SKA                  32   24%   48% sehr niedrig       214   91%   99% kein NW

NIWIS classifies water level and discharge separately, and at Rockenau the two verdicts contradict each other. By water level it is "kein Niedrigwasser", no low water at all. By discharge it is "sehr niedrig", and at 32.5 m³/s it is below the 34.5 m³/s that is the lowest discharge ever measured there on a 3 August. Same gauge, same day.

Below Plochingen the Neckar is canalized. The weirs hold the level while the discharge drops, and Plochingen and Lauffen report no discharge at all. The level measures how full the impoundments are, which is what navigation needs. The discharge measures what the catchment delivers, and at a quarter of the normal volume the water warms up faster and holds less oxygen, and less is available for cooling and abstraction.

Nationwide

Of the 359 stations with discharge data, 310 could be classified for today:

extrem niedrig       108   34.8%
sehr niedrig          64   20.6%
niedrig               81   26.1%
Kein Niedrigwasser    57   18.4%

55% of the classified stations are at "sehr niedrig" or worse. Three days earlier the same query returned 129 at "extrem niedrig" and 35 at "kein Niedrigwasser", and the upper Neckar gauges are on steigend again.

Missing features

A gewaesser filter on /messstelle would remove the 698 extra requests, and sub-daily values would remove the need for a second source. The reference period being a parameter instead of a fixed number baked into a published figure is the reason I would use NIWIS for this kind of question.

I explored the endpoints with Claude Code, one call per endpoint with the raw response dumped to a file. The -777 sentinel, the 1991 limit and the licence spread all came out of that and none of them are in the documentation.