-
-
Notifications
You must be signed in to change notification settings - Fork 121
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: reconcile orphan objects from admin endpoint
- Loading branch information
Showing
35 changed files
with
1,748 additions
and
12 deletions.
There are no files selected for viewing
This file contains 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 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 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 |
---|---|---|
@@ -1,3 +1,4 @@ | ||
export { default as migrations } from './migrations' | ||
export { default as tenants } from './tenants' | ||
export { default as s3Credentials } from './s3' | ||
export { default as objects } from './objects' |
This file contains 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,199 @@ | ||
import { FastifyInstance, RequestGenericInterface } from 'fastify' | ||
import apiKey from '../../plugins/apikey' | ||
import { dbSuperUser, storage } from '../../plugins' | ||
import { ObjectScanner } from '@storage/scanner/scanner' | ||
import { FastifyReply } from 'fastify/types/reply' | ||
|
||
const listOrphanedObjects = { | ||
description: 'List Orphaned Objects', | ||
params: { | ||
type: 'object', | ||
properties: { | ||
tenantId: { type: 'string' }, | ||
bucketId: { type: 'string' }, | ||
}, | ||
required: ['tenantId', 'bucketId'], | ||
}, | ||
query: { | ||
type: 'object', | ||
properties: { | ||
before: { type: 'string' }, | ||
}, | ||
}, | ||
} as const | ||
|
||
const syncOrphanedObjects = { | ||
description: 'Sync Orphaned Objects', | ||
params: { | ||
type: 'object', | ||
properties: { | ||
tenantId: { type: 'string' }, | ||
bucketId: { type: 'string' }, | ||
}, | ||
required: ['tenantId', 'bucketId'], | ||
}, | ||
body: { | ||
type: 'object', | ||
properties: { | ||
deleteDbKeys: { type: 'boolean' }, | ||
deleteS3Keys: { type: 'boolean' }, | ||
}, | ||
}, | ||
optional: ['deleteDbKeys', 'deleteS3Keys'], | ||
} as const | ||
|
||
interface ListOrphanObjectsRequest extends RequestGenericInterface { | ||
Params: { | ||
tenantId: string | ||
bucketId: string | ||
} | ||
Querystring: { | ||
before?: string | ||
} | ||
} | ||
|
||
interface SyncOrphanObjectsRequest extends RequestGenericInterface { | ||
Params: { | ||
tenantId: string | ||
bucketId: string | ||
} | ||
Body: { | ||
deleteDbKeys?: boolean | ||
deleteS3Keys?: boolean | ||
before?: string | ||
} | ||
} | ||
|
||
export default async function routes(fastify: FastifyInstance) { | ||
fastify.register(apiKey) | ||
fastify.register(dbSuperUser, { | ||
disableHostCheck: true, | ||
maxConnections: 5, | ||
}) | ||
fastify.register(storage) | ||
|
||
fastify.get<ListOrphanObjectsRequest>( | ||
'/:tenantId/buckets/:bucketId/orphan-objects', | ||
{ | ||
schema: listOrphanedObjects, | ||
}, | ||
async (req, reply) => { | ||
const bucket = req.params.bucketId | ||
let before = req.query.before ? new Date(req.query.before as string) : undefined | ||
|
||
if (before && isNaN(before.getTime())) { | ||
return reply.status(400).send({ | ||
error: 'Invalid date format', | ||
}) | ||
} | ||
if (!before) { | ||
before = new Date() | ||
before.setHours(before.getHours() - 1) | ||
} | ||
|
||
const scanner = new ObjectScanner(req.storage) | ||
const orphanObjects = scanner.listOrphaned(bucket, { | ||
signal: req.signals.disconnect.signal, | ||
before: before, | ||
}) | ||
|
||
reply.header('Content-Type', 'application/json; charset=utf-8') | ||
|
||
// Do not let the connection time out, periodically send | ||
// a ping message to keep the connection alive | ||
const respPing = ping(reply) | ||
|
||
try { | ||
for await (const result of orphanObjects) { | ||
if (result.value.length > 0) { | ||
respPing.update() | ||
reply.raw.write( | ||
JSON.stringify({ | ||
...result, | ||
event: 'data', | ||
}) | ||
) | ||
} | ||
} | ||
} catch (e) { | ||
throw e | ||
} finally { | ||
respPing.clear() | ||
reply.raw.end() | ||
} | ||
} | ||
) | ||
|
||
fastify.delete<SyncOrphanObjectsRequest>( | ||
'/:tenantId/buckets/:bucketId/orphan-objects', | ||
{ | ||
schema: syncOrphanedObjects, | ||
}, | ||
async (req, reply) => { | ||
if (!req.body.deleteDbKeys && !req.body.deleteS3Keys) { | ||
return reply.status(400).send({ | ||
error: 'At least one of deleteDbKeys or deleteS3Keys must be set to true', | ||
}) | ||
} | ||
|
||
const bucket = `${req.params.bucketId}` | ||
let before = req.body.before ? new Date(req.body.before as string) : undefined | ||
|
||
if (!before) { | ||
before = new Date() | ||
before.setHours(before.getHours() - 1) | ||
} | ||
|
||
const respPing = ping(reply) | ||
|
||
try { | ||
const scanner = new ObjectScanner(req.storage) | ||
const result = scanner.deleteOrphans(bucket, { | ||
deleteDbKeys: req.body.deleteDbKeys, | ||
deleteS3Keys: req.body.deleteS3Keys, | ||
signal: req.signals.disconnect.signal, | ||
}) | ||
|
||
for await (const deleted of result) { | ||
respPing.update() | ||
reply.raw.write( | ||
JSON.stringify({ | ||
...deleted, | ||
event: 'data', | ||
}) | ||
) | ||
} | ||
} catch (e) { | ||
throw e | ||
} finally { | ||
respPing.clear() | ||
reply.raw.end() | ||
} | ||
} | ||
) | ||
} | ||
|
||
// Occasionally write a ping message to the response stream | ||
function ping(reply: FastifyReply) { | ||
let lastSend = undefined as Date | undefined | ||
const clearPing = setInterval(() => { | ||
const fiveSecondsEarly = new Date() | ||
fiveSecondsEarly.setSeconds(fiveSecondsEarly.getSeconds() - 5) | ||
|
||
if (!lastSend || (lastSend && lastSend < fiveSecondsEarly)) { | ||
lastSend = new Date() | ||
reply.raw.write( | ||
JSON.stringify({ | ||
event: 'ping', | ||
}) | ||
) | ||
} | ||
}, 1000 * 10) | ||
|
||
return { | ||
clear: () => clearInterval(clearPing), | ||
update: () => { | ||
lastSend = new Date() | ||
}, | ||
} | ||
} |
This file contains 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 |
---|---|---|
@@ -1,2 +1,3 @@ | ||
export * from './mutex' | ||
export * from './async-abort-controller' | ||
export * from './merge-async-itertor' |
This file contains 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,44 @@ | ||
type MergedYield<Gens extends Record<string, AsyncGenerator<any>>> = { | ||
[K in keyof Gens]: Gens[K] extends AsyncGenerator<infer V> ? { type: K; value: V } : never | ||
}[keyof Gens] | ||
|
||
export async function* mergeAsyncGenerators<Gens extends Record<string, AsyncGenerator<any>>>( | ||
gens: Gens | ||
): AsyncGenerator<MergedYield<Gens>> { | ||
// Convert the input object into an array of [name, generator] tuples | ||
const entries = Object.entries(gens) as [keyof Gens, Gens[keyof Gens]][] | ||
|
||
// Initialize an array to keep track of each generator's state | ||
const iterators = entries.map(([name, gen]) => ({ | ||
name, | ||
iterator: gen[Symbol.asyncIterator](), | ||
done: false, | ||
})) | ||
|
||
// Continue looping as long as at least one generator is not done | ||
while (iterators.some((it) => !it.done)) { | ||
// Prepare an array of promises to fetch the next value from each generator | ||
const nextPromises = iterators.map((it) => | ||
it.done ? Promise.resolve({ done: true, value: undefined }) : it.iterator.next() | ||
) | ||
|
||
// Await all the next() promises concurrently | ||
const results = await Promise.all(nextPromises) | ||
|
||
// Iterate through the results and yield values with their corresponding names | ||
for (let i = 0; i < iterators.length; i++) { | ||
const it = iterators[i] | ||
const result = results[i] | ||
|
||
if (!it.done && !result.done) { | ||
// Yield an object containing the generator's name and the yielded value | ||
yield { type: it.name, value: result.value } as MergedYield<Gens> | ||
} | ||
|
||
if (!it.done && result.done) { | ||
// Mark the generator as done if it has no more values | ||
it.done = true | ||
} | ||
} | ||
} | ||
} |
This file contains 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 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 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 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 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,25 @@ | ||
export async function eachParallel<T>(times: number, fn: (index: number) => Promise<T>) { | ||
const promises = [] | ||
for (let i = 0; i < times; i++) { | ||
promises.push(fn(i)) | ||
} | ||
|
||
return Promise.all(promises) | ||
} | ||
|
||
export function pickRandomFromArray<T>(arr: T[]): T { | ||
return arr[Math.floor(Math.random() * arr.length)] | ||
} | ||
|
||
export function pickRandomRangeFromArray<T>(arr: T[], range: number): T[] { | ||
if (arr.length <= range) { | ||
return arr | ||
} | ||
|
||
const result = new Set<T>() | ||
while (result.size < range) { | ||
result.add(pickRandomFromArray(arr)) | ||
} | ||
|
||
return Array.from(result) | ||
} |
Oops, something went wrong.