files partitions: no deadlock, four ahead, and a failure no longer stops the GC sweep
Creating a partition deadlocked with file inserts 11 times in an hour, and each failure ended the GC sweep before its deletes. The parent is now locked first (lock_timeout 10 s), four partitions are kept ahead, and an error is logged while the sweep goes on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e3e3ecc93d
commit
f8ee7e8662
3 files changed
+29
-10
No files matched your search
@@ -3,13 +3,16 @@ import logger from '@adonisjs/core/services/logger'
|
||||
|
||||
/** Ids per partition (as the migration that partitioned files: …150). */
|
||||
export const FILE_PARTITION_SIZE = 4_000_000
|
||||
/** Ids kept covered beyond the newest file: two partitions. */
|
||||
const AHEAD = 2 * FILE_PARTITION_SIZE
|
||||
/**
|
||||
* Ids kept covered beyond the newest file: four partitions (16M ids, about a day of
|
||||
* seeding), so a few sweeps that can't create one are harmless.
|
||||
*/
|
||||
const AHEAD = 4 * FILE_PARTITION_SIZE
|
||||
|
||||
/**
|
||||
* `files` is partitioned by id (CONCEPT.md D39). An insert with an id no
|
||||
* partition covers fails, so gc:scheduler calls ensure() every sweep to keep
|
||||
* two partitions ready ahead of the newest id. Each new partition gets its
|
||||
* four partitions ready ahead of the newest id. Each new partition gets its
|
||||
* own unique index on file_key (FileStore.insert checks the older ones),
|
||||
* in the parent's tablespace (the HDD on homelab).
|
||||
*/
|
||||
@@ -17,8 +20,9 @@ class FilePartitions {
|
||||
async ensure(): Promise<string[]> {
|
||||
const created: string[] = []
|
||||
while (true) {
|
||||
// The sequence, not max(id): cheaper, and it counts ids handed out but not committed yet.
|
||||
const { rows } = await db.rawQuery(
|
||||
`SELECT (SELECT coalesce(max(id), 0) FROM files) AS max_id,
|
||||
`SELECT (SELECT last_value FROM files_id_seq) AS max_id,
|
||||
-- ''{0,1}, not ''?: knex reads ? as a binding.
|
||||
max((regexp_match(pg_get_expr(c.relpartbound, c.oid), 'TO \\(''{0,1}(\\d+)''{0,1}\\)'))[1]::bigint) AS upper
|
||||
FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhrelid
|
||||
@@ -28,8 +32,15 @@ class FilePartitions {
|
||||
const upper = Number(rows[0].upper)
|
||||
if (upper - maxId >= AHEAD) return created
|
||||
const name = `files_p${upper}`
|
||||
await db.rawQuery(`CREATE TABLE ${name} PARTITION OF files FOR VALUES FROM (${upper}) TO (${upper + FILE_PARTITION_SIZE})`)
|
||||
await db.rawQuery(`ALTER TABLE ${name} ADD CONSTRAINT ${name}_file_key_unique UNIQUE (file_key)`)
|
||||
// CREATE … PARTITION OF locks files and its partitions one by one, which deadlocked with
|
||||
// file inserts (11 failed sweeps on 2026-10-07). Taking the parent's lock first, in one
|
||||
// statement, can only wait; after 10 s it gives up until the next sweep.
|
||||
await db.transaction(async (trx) => {
|
||||
await trx.rawQuery(`SET LOCAL lock_timeout = '10s'`)
|
||||
await trx.rawQuery(`LOCK TABLE files IN ACCESS EXCLUSIVE MODE`)
|
||||
await trx.rawQuery(`CREATE TABLE ${name} PARTITION OF files FOR VALUES FROM (${upper}) TO (${upper + FILE_PARTITION_SIZE})`)
|
||||
await trx.rawQuery(`ALTER TABLE ${name} ADD CONSTRAINT ${name}_file_key_unique UNIQUE (file_key)`)
|
||||
})
|
||||
logger.info(`files: partition ${name} created (ids ${upper}–${upper + FILE_PARTITION_SIZE - 1}).`)
|
||||
created.push(name)
|
||||
}
|
||||
|
||||
@@ -79,8 +79,15 @@ export default class GcScheduler extends BaseCommand {
|
||||
}
|
||||
}
|
||||
|
||||
// Inserts fail without a partition for their id (D39).
|
||||
if (!this.dryRun) await FilePartitions.ensure()
|
||||
// Inserts fail without a partition for their id (D39). Four are kept ahead, so a failure here
|
||||
// can wait for the next sweep; it used to end the sweep, deletes and all (LOG.md 2026-10-07 (4)).
|
||||
if (!this.dryRun) {
|
||||
try {
|
||||
await FilePartitions.ensure()
|
||||
} catch (err: any) {
|
||||
this.logger.error(`files partitions: ${err.message}; trying again next sweep.`)
|
||||
}
|
||||
}
|
||||
|
||||
const trimmed = await this.trimBodyQueue()
|
||||
if (trimmed) this.logger.info(`Body queue: ${this.verb('drop', 'dropped')} ${trimmed} oldest waiting jobs.`)
|
||||
|
||||
@@ -39,10 +39,11 @@ test.group('files partitions', (group) => {
|
||||
)
|
||||
return Number(rows[0].upper)
|
||||
}
|
||||
await FilePartitions.ensure()
|
||||
const before = await upper()
|
||||
assert.deepEqual(await FilePartitions.ensure(), [])
|
||||
// A file near the end of the covered ids: one more partition is needed.
|
||||
await db.rawQuery(`SELECT setval('files_id_seq', ?, false)`, [before - FILE_PARTITION_SIZE - 10])
|
||||
// Ids handed out up to just inside the four partitions kept ahead: one more is needed.
|
||||
await db.rawQuery(`SELECT setval('files_id_seq', ?, false)`, [before - 4 * FILE_PARTITION_SIZE + 10])
|
||||
await FileStore.insert([file('e'.repeat(40), 'late.bin')])
|
||||
assert.deepEqual(await FilePartitions.ensure(), [`files_p${before}`])
|
||||
assert.equal(await upper(), before + FILE_PARTITION_SIZE)
|
||||
|
||||
Reference in new issue
Block a user