perf(db): shrink TL.db from 267 MB to 207 MB
The file was already tight -- freelist 0 pages, and a plain VACUUM reclaimed nothing -- so the saving had to come from schema rather than compaction. Profiled with dbstat and measured every step on copies of the live file. Three changes to Notes, applied 2026-08-07: - Rebuild as WITHOUT ROWID (-32 MB). The 5-column composite primary key was stored twice: once in the table, once in a 62 MB autoindex existing only to map key -> rowid. Keying the table b-tree on the primary key itself drops the second copy. ix_NoteBlogName01 grows 25 -> 58 MB in exchange, since a secondary index on such a table carries the whole primary key instead of a rowid; net -32 MB. - Drop Notes_idx_06e01ae3 on TimeStamp DESC (-14 MB). Barely earned its keep as a rowid index and would have cost 58 MB after the conversion, cancelling the entire exercise. The crawler's only TimeStamp filter (>= 1535778000) excludes 786 of 1,182,333 rows; Rolodex's default Notes sort carries a three-column tiebreaker forcing a full sort regardless; the reply-matching UPDATE uses ABS(TimeStamp - ?) <= 5, which no index on the column can serve. Cost is one path: Rolodex's Notes page with a date-range filter, 60 ms -> 164 ms. - Null the DatetimeCrawled placeholder (-13 MB). 1,148,077 rows stored the literal DDL default '2/12/26 12am', a backfill marker rather than a crawl time. UI-neutral: Rolodex reads the column through DateSql.Sortable, whose CASE matches neither format, so those rows already rendered as an em dash. No application code changed. The schema keeps the same tables, columns, types and constraints; WITHOUT ROWID is a storage-layout change behind the same SQL surface, and no consumer referenced rowid on Notes. Verified against all three consumers on the live file: integrity_check ok, row counts unchanged (1182333 / 22468 / 188620), journal_mode still wal, crawler INSERT OR IGNORE still dedupes, Rolodex's NoteBlogName filter still uses ix_NoteBlogName01, and exactly as many rows read as null through Sortable after the change as before it. TumblThree touches only Blogs, which is untouched. Deliberately not done: nulling Notes.DateCreated (a further -12 MB). Unlike DatetimeCrawled its value parses as a real date, so Rolodex displays and sorts by it; nulling would turn visible dates into em dashes. Note that DEFAULT '2/12/26 12am' remains on the column, so any writer inserting a note without naming it reintroduces the placeholder. Consumer-side date normalisation must stay. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -121,22 +121,56 @@ Notable:
|
||||
|
||||
```sql
|
||||
CREATE TABLE "Notes" (
|
||||
"RootBlogName" TEXT,
|
||||
"PostID" INTEGER,
|
||||
"NoteBlogName" TEXT,
|
||||
"TimeStamp" INTEGER,
|
||||
"Type" TEXT,
|
||||
"replyText" TEXT DEFAULT '.',
|
||||
"DatetimeCrawled" TEXT DEFAULT '2/12/26 12am',
|
||||
"DateModified" TEXT,
|
||||
"DateCreated" TEXT,
|
||||
"RootBlogName" TEXT,
|
||||
"PostID" INTEGER,
|
||||
"NoteBlogName" TEXT,
|
||||
"TimeStamp" INTEGER,
|
||||
"Type" TEXT,
|
||||
"replyText" TEXT DEFAULT '.',
|
||||
"DatetimeCrawled" TEXT DEFAULT '2/12/26 12am',
|
||||
"DateModified" TEXT,
|
||||
"DateCreated" TEXT,
|
||||
IsActive INTEGER NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY("RootBlogName","PostID","TimeStamp","Type","NoteBlogName")
|
||||
);
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE INDEX "Notes_idx_06e01ae3" ON "Notes" ("TimeStamp" DESC);
|
||||
CREATE INDEX "ix_NoteBlogName01" ON "Notes" ("NoteBlogName");
|
||||
CREATE INDEX "ix_NoteBlogName01" ON "Notes" ("NoteBlogName");
|
||||
```
|
||||
|
||||
**`WITHOUT ROWID`, since 2026-08-07.** The rows live in the primary key's b-tree
|
||||
rather than in a rowid table with a separate key index beside it. Nothing about the
|
||||
SQL surface changes — same columns, same types, same constraint — but two
|
||||
consequences are worth knowing before adding an index here:
|
||||
|
||||
- There is no `rowid` on this table. `SELECT rowid FROM Notes` is an error, and no
|
||||
code in any of the three apps relied on it.
|
||||
- A secondary index carries the whole five-column primary key as its row reference
|
||||
instead of a compact rowid, so indexes on this table are **expensive**.
|
||||
`ix_NoteBlogName01` costs 58 MB, up from 25 MB before the conversion. It earns
|
||||
that: Rolodex filters on `NoteBlogName` and the crawler joins on it.
|
||||
|
||||
**`Notes_idx_06e01ae3` on `TimeStamp DESC` was dropped at the same time.** It cost
|
||||
14 MB as a rowid index and would have cost 58 MB after the conversion. It was worth
|
||||
neither: the crawler's only `TimeStamp` filter (`>= 1535778000`) excludes 786 rows
|
||||
of 1.18M, Rolodex's default Notes sort carries a three-column tiebreaker that forces
|
||||
a full sort regardless, and the reply-matching `UPDATE` uses `ABS(TimeStamp - ?) <= 5`,
|
||||
which no index on `TimeStamp` can serve. The one path that got slower is Rolodex's
|
||||
Notes page with a date-range filter: 60 ms to 164 ms.
|
||||
|
||||
See `../shrink-db.sql` for the full rationale and the applied result.
|
||||
|
||||
**`DatetimeCrawled` is `NULL` on 1,148,077 rows, and that is the honest value.** Those
|
||||
rows previously stored the literal string `'2/12/26 12am'` — this column's own DDL
|
||||
default, written as a bulk backfill placeholder rather than as a crawl time. They were
|
||||
set to `NULL` on 2026-08-07, which is what consumers already displayed them as: the
|
||||
string parses as a date in neither format this schema writes.
|
||||
|
||||
Note the trap: **the `DEFAULT '2/12/26 12am'` clause is still in the DDL above.** Any
|
||||
`INSERT` that omits this column writes the placeholder straight back. The crawler names
|
||||
it explicitly on every insert, so nothing reintroduces it today, but a new writer that
|
||||
forgets to would — which is why consumers should keep treating an unparseable value here
|
||||
as "unknown" rather than assuming `NULL` is now the only such marker.
|
||||
|
||||
One row per engagement event. `TimeStamp` is **unix seconds** — unlike every date column
|
||||
elsewhere in the schema, which are text.
|
||||
|
||||
@@ -155,8 +189,11 @@ At 1.19M rows this is the table that dictates how the whole database has to be q
|
||||
- The only fast access paths are the primary key's leading columns (`RootBlogName`, then
|
||||
`PostID`) and `ix_NoteBlogName01` on `NoteBlogName`. "Notes received by a blog" and
|
||||
"notes given by a blog" are both cheap; almost nothing else is.
|
||||
- Ordering by anything but `TimeStamp` is a full sort of whatever the filters leave.
|
||||
- `replyText` is `'.'` on 1,174,706 rows — only `reply` notes carry real text.
|
||||
- **Every** ordering here is a full sort of whatever the filters leave, `TimeStamp`
|
||||
included. That was already true in practice of the default `TimeStamp` order, whose
|
||||
tiebreakers forced a sort even while `Notes_idx_06e01ae3` existed; since that index
|
||||
was dropped on 2026-08-07 it is true unconditionally. Filter first, then sort.
|
||||
- `replyText` is `'.'` on 1,167,464 rows — only `reply` notes carry real text.
|
||||
|
||||
### Referential integrity
|
||||
|
||||
@@ -178,7 +215,7 @@ consumer.
|
||||
|
||||
| Column | `'.'` rows |
|
||||
|---|--:|
|
||||
| `Notes.replyText` | 1,174,706 |
|
||||
| `Notes.replyText` | 1,167,464 |
|
||||
| `Posts.Title` | 13,144 |
|
||||
| `Posts.Body` | 172 |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user