file_keys: fill in the background, use it once complete
Test and deploy / test (push) Successful in 1m37s
Test and deploy / deploy (push) Successful in 2m23s

Copying all existing keys in the migration read the files rows from the
HDD for over half an hour and held up the deploy. gc:scheduler now copies
them in slices (FILE_KEYS_FILL_SECONDS per sweep) and FileStore checks
every partition until the fill is complete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daan MeijerandClaude Opus 5.5 committed 2026-10-06 14:54:12 +02:00
1 parent 693e6c3244
commit f0ff8e200f
8 files changed
+163 -18

No files matched your search

+17 -16
View File
@@ -4,20 +4,28 @@ import { BaseSchema } from '@adonisjs/lucid/schema'
* A 64-bit hash of every file_key, in one index (LOG.md 2026-10-06 (7)).
* file_key is unique per files partition only (D39), so FileStore.insert
* looked every key up in every partition: 7 partitions and growing, 35,000
* random index reads for a batch of 5,000 files. Now it asks file_keys
* first, and only keys whose hash is there (a duplicate, or rarely a
* collision) are looked up in files.
* random index reads for a batch of 5,000 files. With file_keys complete it
* asks file_keys first, and only keys whose hash is there (a duplicate, or
* rarely a collision) are looked up in files.
*
* Filled by the files insert statement trigger; no transaction, so that the
* trigger is live before the existing keys are copied and no file inserted
* meanwhile is missed (a missed key would let a duplicate file in).
* New keys come from the files insert statement trigger. The keys from
* before are copied in the background (FileKeys.fill, gc:scheduler):
* copying them here took over half an hour of HDD reads and held up the
* deploy. Until file_keys_fill.complete, FileStore checks every partition.
*/
export default class extends BaseSchema {
static disableTransactions = true
async up() {
const q = (sql: string) => this.db.rawQuery(sql)
await q(`CREATE TABLE IF NOT EXISTS file_keys (key_hash bigint PRIMARY KEY)`)
// One row: the fill's position in files.id, the id it must reach, and whether it got there.
await q(`
CREATE TABLE IF NOT EXISTS file_keys_fill (
id smallint PRIMARY KEY DEFAULT 1 CHECK (id = 1),
next_id bigint NOT NULL DEFAULT 0,
until_id bigint,
complete boolean NOT NULL DEFAULT false,
updated_at timestamptz NOT NULL DEFAULT now())`)
await q(`INSERT INTO file_keys_fill (id) VALUES (1) ON CONFLICT DO NOTHING`)
await q(`
CREATE OR REPLACE FUNCTION files_release_insert_statement() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
@@ -52,17 +60,10 @@ export default class extends BaseSchema {
ON CONFLICT DO NOTHING;
RETURN NULL;
END $$`)
// The keys from before; read from the file_key indexes (NVMe), not the files heap (HDD).
await q(`SET enable_seqscan = off`)
await q(`
INSERT INTO file_keys (key_hash)
SELECT DISTINCT hashtextextended(file_key, 0) FROM files WHERE file_key IS NOT NULL
ON CONFLICT DO NOTHING`)
await q(`RESET enable_seqscan`)
await q(`ANALYZE file_keys`)
}
async down() {
await this.db.rawQuery(`DROP TABLE IF EXISTS file_keys_fill`)
await this.db.rawQuery(`DROP TABLE IF EXISTS file_keys`)
}
}
+15
View File
@@ -181,6 +181,21 @@ export class FileKeySchema extends BaseModel {
declare keyHash: bigint | number
}
export class FileKeysFillSchema extends BaseModel {
static $columns = ['complete', 'id', 'nextId', 'untilId', 'updatedAt'] as const
$columns = FileKeysFillSchema.$columns
@column()
declare complete: boolean
@column({ isPrimary: true })
declare id: number
@column()
declare nextId: bigint | number
@column()
declare untilId: bigint | number | null
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime
}
export class FileSchema extends BaseModel {
static $columns = ['archive', 'contentType', 'createdAt', 'date', 'fileKey', 'filename', 'firstMessageId', 'groups', 'id', 'parts', 'poster', 'realName', 'releaseId', 'segments', 'size', 'updatedAt'] as const
$columns = FileSchema.$columns