Wikidata cache, the missing redirects

The Wikidata cache I built, and later made half the size, 404s on every id that Wikidata has ever merged into another one. wikidata-*-all.json.bz2 holds canonical entities only, so a merged item leaves no line behind and there is nothing for the conversion to store. Q116513636, for example is not deleted, but i redirects to Q106842805 The two are merged, but the cache doesn't know that yet.

Redirect and Pages

Redirects come from the wiki's own table dumps. wikidatawiki-latest-redirect.sql.gz is 36MB and holds roughly 5 million item redirects, about one for every 25 entities in the cache.

It is also only half of an answer. A row looks like this:

(3545,0,'Q92258250','','')

rd_title is the target and is already an entity id. rd_from is the source, and it is a page id. redirect is a per page attribute table, so it names its row the way every such table in MediaWiki does, by the page it belongs to, and the entity id appears nowhere in it.

The only place that mapping exists is the page dump (wikidatawiki-latest-page.sql.gz), which is 3.4GB. So the 36MB table costs a 3.4GB download for one join.

The import

The two rows that make up one redirect, one from each dump:

redirect  (3545,0,'Q92258250','','')
page      (3545,0,'Q2577',1,0,0.243262073359,'20250714122000','20240219185646',1511092905,62,'wikibase-item',NULL)

Page 3545 is Q2577, Messier 86, merged into Q92258250, a duplicate item for the same galaxy. The 1 in fourth position is page_is_redirect, and the page_len of 62 is the entire content of a redirect page. Since both rows open with a page id, a namespace and a title, one regular expression reads either dump.

Two passes. The first reads redirect into a scratch table, the second streams page and keeps only the rows whose page id appears in it. That filter is a Python set rather than a lookup against the scratch table, because it runs once per row of page, about 120 million times, and discards all but five million on that first test. The set costs 300MB and saves 120 million index probes.

307, not the entity

Special:EntityData answers a redirected id with the target entity, keyed under the target's id. The API could copy that, but then a caller that looks up the id it asked for finds nothing and stores an entity with no claims, and the response looks perfectly normal while it happens. A 307 to /Q106842805.json cannot be ignored by accident.

It does have to be opted into on the other side. httpx does not follow redirects unless told to:

client = httpx.Client(follow_redirects=True)

That alone is not enough. The cache keys its answer by the id in the path it ended up serving, so a request for Q116513636 comes back as:

{"entities": {"Q106842805": {...}}}

Every call site looked up entities[entity_id] with the id it started from, and that id is not in the response. The lookup has a default of {}, an empty entity has no claims, and storing an entity with no claims raises nothing and looks like an entity that simply has no data. They take the single entity out of the response now, whichever id it is keyed by.

Which table wins

Some of the merged ids still have an entity in the cache. They were canonical when an earlier pass imported them and were merged afterwards, and an upsert pass cannot see an absence, so nothing ever removed the blob. Every cycle adds the ids merged since the last one and none of them leave again.

Looking in entities first serves that stale copy for good and never reaches the redirect, so redirects is checked first instead.

The result

The two passes take 6.4 minutes and build just under 5 million redirects.

13 sources had no row in the page dump. That count is the check on whether the two dumps are from the same date, and 13 is a pass.

73 redirects point at another redirect and one of those chains runs three hops. Nothing in the client has to know that, since follow_redirects=True walks a chain the same as a single hop. No chain is a cycle and none of them ends on an id the cache does not have.

MediaWiki bots flatten double redirects, which is why it is 73 and not five figures. The first chain in the table is Q5485936 to Q27956032 to Q25506055, three items all called census of agriculture, and upstream it is already gone: both of the first two point straight at Q25506055 now. A dump only catches the chains created since the last bot pass.

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.