-
Notifications
You must be signed in to change notification settings - Fork 11
Fix RocksDB @expiresAt eviction sweeps #2155
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
kylebernhardy
wants to merge
46
commits into
main
Choose a base branch
from
codex/fix-rocks-expiration-eviction
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
46 commits
Select commit
Hold shift + click to select a range
65cc100
fix: restore Rocks index value iteration
kylebernhardy 5dcb7ae
test: strengthen Rocks expiration range coverage
kylebernhardy 6ba7c9e
fix: preserve unique index keys for expiration scans
kylebernhardy 3841fa2
test: cover expiration sweep handoff
kylebernhardy 409138c
fix: expose Rocks index values to expiration sweeps
kylebernhardy 3ae45a3
fix: harden RocksDB expiration sweeps
kylebernhardy 3ba9a19
fix: preserve sweeps when database drop is blocked
kylebernhardy f982e8f
fix: bound expiration sweep event-loop work
kylebernhardy 7455c80
fix: drain expiration sweeps before table drop
kylebernhardy 7efafa5
fix: await expiration cleanup before database close
kylebernhardy 1112e7d
fix: serialize expiration teardown
kylebernhardy 030a7f5
fix: bound destructive cleanup waits
kylebernhardy 7d97819
Merge remote-tracking branch 'origin/main' into codex/fix-rocks-expir…
kylebernhardy 1273f96
fix(storage): make expiration cleanup teardown-safe
kylebernhardy 533ffe1
fix(storage): preserve expiration across upgrades
kylebernhardy 96cf179
test(storage): clean up expiration fixtures
kylebernhardy 949c82a
fix(storage): align source expiration indexing
kylebernhardy 35a8f08
fix(storage): preserve source expiration precedence
kylebernhardy d35c3ae
Merge remote-tracking branch 'origin/main' into codex/fix-rocks-expir…
kylebernhardy d311c5e
test: replace expiration sweep sinon stubs
kylebernhardy 590052a
test: preserve cleanup invariant coverage
kylebernhardy d997b23
test: exercise eviction conflicts through sweep seam
kylebernhardy 93e2436
Preserve source-owned cache expiration
kylebernhardy a5714a7
Close expiration lifecycle races
kylebernhardy de8cb4a
Make expiration teardown atomic
kylebernhardy a741492
Finish expiration lifecycle safeguards
kylebernhardy 745e7a2
Preserve multi-root teardown contracts
kylebernhardy f004866
Harden expiration eviction lifecycle
kylebernhardy 8c6a04a
Close quiescence recovery gaps
kylebernhardy 6b24bdc
Finalize quiescence recovery fencing
kylebernhardy 0ded803
Restore availability after reconcile timeout
kylebernhardy 19ba24a
Refresh cached worker preloads after config changes
kylebernhardy 4d7a4df
Retry unresolved worker preloads
kylebernhardy 7079798
Release read snapshots after eviction
kylebernhardy b2986f7
Close every eviction transaction path
kylebernhardy 0c0088b
Pin eviction leak regression to RocksDB
kylebernhardy c68e36d
Await database handle closure during quiescence
kylebernhardy b5d41ae
Stabilize cleanup error drain regression
kylebernhardy ef2d5d0
Skip cross-worker quiescence test on Windows
kylebernhardy 11d9170
Release timed-out RocksDB transaction handles
kylebernhardy 879f243
Complete schema quiescence recovery safely
kylebernhardy 9e54d24
Refresh schema quiesce lease at commit
kylebernhardy c9db0c6
Keep schema teardown fenced while origin is live
kylebernhardy 0e0adef
Merge main and address schema teardown reviews
kylebernhardy 8b89fbc
Harden eviction metadata and commit admission
kylebernhardy 33e9bea
Improve commit admission diagnostics
kylebernhardy File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
135 changes: 135 additions & 0 deletions
135
integrationTests/database/expiration-drop-quiesce.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,135 @@ | ||
| import { after, before, suite, test } from 'node:test'; | ||
| import { strictEqual } from 'node:assert'; | ||
| import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; | ||
| import { tmpdir } from 'node:os'; | ||
| import { join, resolve } from 'node:path'; | ||
| import { setTimeout as delay } from 'node:timers/promises'; | ||
| import { setupHarperWithFixture, teardownHarper, type ContextWithHarper } from '@harperfast/integration-testing'; | ||
| import request from 'supertest'; | ||
| import { createApiClient } from '../apiTests/utils/client.mjs'; | ||
|
|
||
| const FIXTURE_PATH = resolve(import.meta.dirname, 'expiration-drop-quiesce'); | ||
| const CONTROL_DIRECTORY = mkdtempSync(join(tmpdir(), 'expiration-drop-quiesce-')); | ||
| const skipSuite = process.platform === 'win32'; // Windows cannot expose two HTTP workers on one port. | ||
|
|
||
| async function waitFor(predicate: () => boolean, message: string) { | ||
| const deadline = Date.now() + 30_000; | ||
| while (Date.now() < deadline) { | ||
| if (predicate()) return; | ||
| await delay(20); | ||
| } | ||
| throw new Error(message); | ||
| } | ||
|
|
||
| suite('cross-worker expiration cleanup quiesces destructive DDL', { skip: skipSuite }, (ctx: ContextWithHarper) => { | ||
| let client: ReturnType<typeof createApiClient>; | ||
| let workerIds: number[]; | ||
|
|
||
| before(async () => { | ||
| await setupHarperWithFixture(ctx, FIXTURE_PATH, { | ||
| config: { threads: { count: 2 }, logging: { level: 'error' } }, | ||
| env: { EXPIRATION_QUIESCE_CONTROL: CONTROL_DIRECTORY }, | ||
| } as any); | ||
| client = createApiClient(ctx.harper); | ||
| let ready = false; | ||
| const deadline = Date.now() + 120_000; | ||
| while (Date.now() < deadline) { | ||
| try { | ||
| const response = await request(client.restURL) | ||
| .post('/QuiesceControl/') | ||
| .set(client.headers) | ||
| .timeout(2_000) | ||
| .send({ action: 'probe' }); | ||
| if (response.status !== 404) { | ||
| ready = true; | ||
| break; | ||
| } | ||
| } catch { | ||
| // Workers are still loading the pre-installed component. | ||
| } | ||
| await delay(250); | ||
| } | ||
| strictEqual(ready, true, 'QuiesceControl resource did not become ready'); | ||
| const probe = await postControl({ action: 'probe' }); | ||
| workerIds = probe.body.workerIds; | ||
| strictEqual(workerIds.length, 2, 'the fixture must expose both HTTP workers for deterministic pinning'); | ||
| }); | ||
|
|
||
| after(async () => { | ||
| await teardownHarper(ctx); | ||
| }); | ||
|
|
||
| async function postControl(body: Record<string, unknown>) { | ||
| return request(client.restURL).post('/QuiesceControl/').set(client.headers).timeout(120_000).send(body); | ||
| } | ||
|
|
||
| async function postControlOnWorker(body: Record<string, unknown>, targetThreadId: number) { | ||
| const response = await postControl({ ...body, targetThreadId }); | ||
| strictEqual(response.body?.threadId, targetThreadId, `control action did not execute on worker ${targetThreadId}`); | ||
| return response; | ||
| } | ||
|
|
||
| async function proveDropWaitsForWorker(kind: 'indexed' | 'primary') { | ||
| const indexed = kind === 'indexed'; | ||
| const database = indexed ? 'quiesce_indexed' : 'quiesce_primary'; | ||
| const table = indexed ? 'IndexedExpiry' : 'PrimaryExpiry'; | ||
| const runId = `${kind}-${Date.now()}`; | ||
| const [sweepWorkerId, ddlWorkerId] = workerIds; | ||
| await postControl({ action: 'seed', kind, database, table, id: runId }).then((response) => | ||
| strictEqual(response.status, 200) | ||
| ); | ||
| const sweepRunId = `${runId}-sweep`; | ||
| const dropRunId = `${runId}-drop`; | ||
| const started = join(CONTROL_DIRECTORY, `${sweepRunId}.started`); | ||
| const release = join(CONTROL_DIRECTORY, `${runId}.release`); | ||
| const sweep = postControlOnWorker( | ||
| { action: 'sweep', kind, database, table, runId: sweepRunId, releaseRunId: runId }, | ||
| sweepWorkerId | ||
| ); | ||
| let dropSettled = false; | ||
| const dropStarted = join(CONTROL_DIRECTORY, `${dropRunId}.started`); | ||
| let drop: ReturnType<typeof postControlOnWorker> | undefined; | ||
| let dropped; | ||
| let primaryError: unknown; | ||
| const cleanupErrors: unknown[] = []; | ||
| try { | ||
| await waitFor(() => existsSync(started), `${kind} sweep did not reach its blocked commit`); | ||
| strictEqual(JSON.parse(readFileSync(started, 'utf8')).threadId, sweepWorkerId); | ||
| drop = postControlOnWorker({ action: 'drop', database, table, runId: dropRunId }, ddlWorkerId).then( | ||
| (response) => { | ||
| dropSettled = true; | ||
| return response; | ||
| } | ||
| ); | ||
| await waitFor(() => existsSync(dropStarted), `${kind} drop did not start on its pinned worker`); | ||
| strictEqual(JSON.parse(readFileSync(dropStarted, 'utf8')).threadId, ddlWorkerId); | ||
| strictEqual(sweepWorkerId === ddlWorkerId, false, 'DDL and cleanup must execute on different workers'); | ||
| await delay(100); | ||
| strictEqual(dropSettled, false, 'physical drop must wait for the blocked cleanup worker'); | ||
| } catch (error) { | ||
| primaryError = error; | ||
| } finally { | ||
| try { | ||
| writeFileSync(release, 'release'); | ||
| } catch (error) { | ||
| cleanupErrors.push(error); | ||
| } | ||
| const completions = drop ? [drop, sweep] : [sweep]; | ||
| const results = await Promise.allSettled(completions); | ||
| if (drop && results[0].status === 'fulfilled') dropped = results[0].value; | ||
| for (const result of results) if (result.status === 'rejected') cleanupErrors.push(result.reason); | ||
| } | ||
| if (primaryError) throw primaryError; | ||
| if (cleanupErrors.length) throw new AggregateError(cleanupErrors, `${kind} quiescence test cleanup failed`); | ||
| strictEqual(dropped.status, 200); | ||
| strictEqual(dropped.body.threadId, ddlWorkerId); | ||
| } | ||
|
|
||
| test('drop_table waits for a remote indexed expiration sweep', async () => { | ||
| await proveDropWaitsForWorker('indexed'); | ||
| }); | ||
|
|
||
| test('drop_table waits for a remote primary cleanup scan', async () => { | ||
| await proveDropWaitsForWorker('primary'); | ||
| }); | ||
| }); |
5 changes: 5 additions & 0 deletions
5
integrationTests/database/expiration-drop-quiesce/config.yaml
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| graphqlSchema: | ||
| files: '*.graphql' | ||
| jsResource: | ||
| files: resources.js | ||
| rest: true |
105 changes: 105 additions & 0 deletions
105
integrationTests/database/expiration-drop-quiesce/resources.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| import { existsSync, renameSync, writeFileSync } from 'node:fs'; | ||
| import { join } from 'node:path'; | ||
| import { setTimeout as delay } from 'node:timers/promises'; | ||
| import { threadId } from 'node:worker_threads'; | ||
|
|
||
| const controlDirectory = process.env.EXPIRATION_QUIESCE_CONTROL; | ||
| const CONTROL_REQUEST = 'expiration-quiesce-control-request'; | ||
| const CONTROL_RESPONSE = 'expiration-quiesce-control-response'; | ||
| const pendingRequests = new Map(); | ||
| let nextRequestId = 1; | ||
|
|
||
| function publishWorkerMarker(path) { | ||
| const temporaryPath = `${path}.${threadId}.tmp`; | ||
| writeFileSync(temporaryPath, JSON.stringify({ threadId })); | ||
| renameSync(temporaryPath, path); | ||
| } | ||
|
|
||
| async function performControl(body) { | ||
| const Table = databases[body.database]?.[body.table]; | ||
| if (!Table) return { available: false, threadId }; | ||
| if (body.action === 'seed') { | ||
| const expiresAt = Date.now() - 10_000; | ||
| if (body.kind === 'indexed') await Table.put(body.id, { id: body.id, expiresAt }); | ||
| else await Table.put(body.id, { id: body.id, value: 'expired' }, { expiresAt }); | ||
| return { seeded: true, threadId }; | ||
| } | ||
| const started = join(controlDirectory, `${body.runId}.started`); | ||
| const release = join(controlDirectory, `${body.releaseRunId ?? body.runId}.release`); | ||
| if (body.action === 'drop') { | ||
| publishWorkerMarker(started); | ||
| await Table.dropTable(); | ||
| return { completed: true, threadId }; | ||
| } | ||
| const hooks = { | ||
| beforeBatchCommit: async () => { | ||
| publishWorkerMarker(started); | ||
| while (!existsSync(release)) await delay(20); | ||
| }, | ||
| }; | ||
| if (body.kind === 'indexed') await Table.runRecordExpirationSweepForTests(hooks); | ||
| else await Table.runPrimaryCleanupScanForTests(hooks); | ||
| return { completed: true, threadId }; | ||
| } | ||
|
|
||
| threads.onMessageByType(CONTROL_REQUEST, async (message) => { | ||
| if (message.targetThreadId !== threadId) return; | ||
| try { | ||
| const result = await performControl(message.body); | ||
| threads.sendToThread(message.originThreadId, { type: CONTROL_RESPONSE, requestId: message.requestId, result }); | ||
| } catch (error) { | ||
| threads.sendToThread(message.originThreadId, { | ||
| type: CONTROL_RESPONSE, | ||
| requestId: message.requestId, | ||
| error: error?.stack ?? error?.message ?? String(error), | ||
| }); | ||
| } | ||
| }); | ||
|
|
||
| threads.onMessageByType(CONTROL_RESPONSE, (message) => { | ||
| const pending = pendingRequests.get(message.requestId); | ||
| if (!pending) return; | ||
| pendingRequests.delete(message.requestId); | ||
| clearTimeout(pending.timer); | ||
| if (message.error) pending.reject(new Error(message.error)); | ||
| else pending.resolve(message.result); | ||
| }); | ||
|
|
||
| function performControlOnWorker(body, targetThreadId) { | ||
| if (targetThreadId === threadId) return performControl(body); | ||
| return new Promise((resolve, reject) => { | ||
| const requestId = `${threadId}:${nextRequestId++}`; | ||
| const timer = setTimeout(() => { | ||
| pendingRequests.delete(requestId); | ||
| reject(new Error(`Timed out dispatching expiration quiesce control to worker ${targetThreadId}`)); | ||
| }, 120_000); | ||
| timer.unref(); | ||
| pendingRequests.set(requestId, { resolve, reject, timer }); | ||
| if ( | ||
| !threads.sendToThread(targetThreadId, { | ||
| type: CONTROL_REQUEST, | ||
| requestId, | ||
| originThreadId: threadId, | ||
| targetThreadId, | ||
| body, | ||
| }) | ||
| ) { | ||
| clearTimeout(timer); | ||
| pendingRequests.delete(requestId); | ||
| reject(new Error(`Worker ${targetThreadId} is not reachable`)); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| export class QuiesceControl extends Resource { | ||
| static loadAsInstance = false; | ||
|
|
||
| async post(_query, body) { | ||
| if (body.action === 'probe') { | ||
| const workerIds = [threadId, ...threads.map((port) => port.threadId)].filter((id) => id > 0); | ||
| return { ready: true, threadId, workerIds: [...new Set(workerIds)] }; | ||
| } | ||
| if (body.targetThreadId !== undefined) return performControlOnWorker(body, body.targetThreadId); | ||
| return performControl(body); | ||
| } | ||
| } |
9 changes: 9 additions & 0 deletions
9
integrationTests/database/expiration-drop-quiesce/schema.graphql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| type IndexedExpiry @table(database: "quiesce_indexed") @export { | ||
| id: ID! @primaryKey | ||
| expiresAt: Float @expiresAt | ||
| } | ||
|
|
||
| type PrimaryExpiry @table(database: "quiesce_primary", expiration: 1, eviction: 1) @export { | ||
| id: ID! @primaryKey | ||
| value: String | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.