Skip to content

Commit cf23e7d

Browse files
authored
feat: reconcile orphan objects from admin endpoint (#606)
1 parent 13fd5fe commit cf23e7d

35 files changed

+1769
-12
lines changed

src/admin-app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import { Registry } from 'prom-client'
44

55
const build = (opts: FastifyServerOptions = {}, appInstance?: FastifyInstance): FastifyInstance => {
66
const app = fastify(opts)
7+
app.register(plugins.signals)
78
app.register(plugins.adminTenantId)
89
app.register(plugins.logRequest({ excludeUrls: ['/status', '/metrics', '/health'] }))
910
app.register(routes.tenants, { prefix: 'tenants' })
11+
app.register(routes.objects, { prefix: 'tenants' })
1012
app.register(routes.migrations, { prefix: 'migrations' })
1113
app.register(routes.s3Credentials, { prefix: 's3' })
1214

src/http/plugins/db.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ export const db = fastifyPlugin(
9494

9595
interface DbSuperUserPluginOptions {
9696
disableHostCheck?: boolean
97+
maxConnections?: number
9798
}
9899

99100
export const dbSuperUser = fastifyPlugin<DbSuperUserPluginOptions>(
@@ -113,6 +114,7 @@ export const dbSuperUser = fastifyPlugin<DbSuperUserPluginOptions>(
113114
method: request.method,
114115
headers: request.headers,
115116
disableHostCheck: opts.disableHostCheck,
117+
maxConnections: opts.maxConnections,
116118
operation: () => request.operation?.type,
117119
})
118120
})

src/http/routes/admin/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export { default as migrations } from './migrations'
22
export { default as tenants } from './tenants'
33
export { default as s3Credentials } from './s3'
4+
export { default as objects } from './objects'

src/http/routes/admin/objects.ts

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import { FastifyInstance, RequestGenericInterface } from 'fastify'
2+
import apiKey from '../../plugins/apikey'
3+
import { dbSuperUser, storage } from '../../plugins'
4+
import { ObjectScanner } from '@storage/scanner/scanner'
5+
import { FastifyReply } from 'fastify/types/reply'
6+
7+
const listOrphanedObjects = {
8+
description: 'List Orphaned Objects',
9+
params: {
10+
type: 'object',
11+
properties: {
12+
tenantId: { type: 'string' },
13+
bucketId: { type: 'string' },
14+
},
15+
required: ['tenantId', 'bucketId'],
16+
},
17+
query: {
18+
type: 'object',
19+
properties: {
20+
before: { type: 'string' },
21+
keepTmpTable: { type: 'boolean' },
22+
},
23+
},
24+
} as const
25+
26+
const syncOrphanedObjects = {
27+
description: 'Sync Orphaned Objects',
28+
params: {
29+
type: 'object',
30+
properties: {
31+
tenantId: { type: 'string' },
32+
bucketId: { type: 'string' },
33+
},
34+
required: ['tenantId', 'bucketId'],
35+
},
36+
body: {
37+
type: 'object',
38+
properties: {
39+
deleteDbKeys: { type: 'boolean' },
40+
deleteS3Keys: { type: 'boolean' },
41+
tmpTable: { type: 'string' },
42+
},
43+
},
44+
optional: ['deleteDbKeys', 'deleteS3Keys'],
45+
} as const
46+
47+
interface ListOrphanObjectsRequest extends RequestGenericInterface {
48+
Params: {
49+
tenantId: string
50+
bucketId: string
51+
}
52+
Querystring: {
53+
before?: string
54+
keepTmpTable?: boolean
55+
}
56+
}
57+
58+
interface SyncOrphanObjectsRequest extends RequestGenericInterface {
59+
Params: {
60+
tenantId: string
61+
bucketId: string
62+
}
63+
Body: {
64+
deleteDbKeys?: boolean
65+
deleteS3Keys?: boolean
66+
before?: string
67+
tmpTable?: string
68+
keepTmpTable?: boolean
69+
}
70+
}
71+
72+
export default async function routes(fastify: FastifyInstance) {
73+
fastify.register(apiKey)
74+
fastify.register(dbSuperUser, {
75+
disableHostCheck: true,
76+
maxConnections: 5,
77+
})
78+
fastify.register(storage)
79+
80+
fastify.get<ListOrphanObjectsRequest>(
81+
'/:tenantId/buckets/:bucketId/orphan-objects',
82+
{
83+
schema: listOrphanedObjects,
84+
},
85+
async (req, reply) => {
86+
const bucket = req.params.bucketId
87+
let before = req.query.before ? new Date(req.query.before as string) : undefined
88+
89+
if (before && isNaN(before.getTime())) {
90+
return reply.status(400).send({
91+
error: 'Invalid date format',
92+
})
93+
}
94+
if (!before) {
95+
before = new Date()
96+
before.setHours(before.getHours() - 1)
97+
}
98+
99+
const scanner = new ObjectScanner(req.storage)
100+
const orphanObjects = scanner.listOrphaned(bucket, {
101+
signal: req.signals.disconnect.signal,
102+
before: before,
103+
keepTmpTable: Boolean(req.query.keepTmpTable),
104+
})
105+
106+
reply.header('Content-Type', 'application/json; charset=utf-8')
107+
108+
// Do not let the connection time out, periodically send
109+
// a ping message to keep the connection alive
110+
const respPing = ping(reply)
111+
112+
try {
113+
for await (const result of orphanObjects) {
114+
if (result.value.length > 0) {
115+
respPing.update()
116+
reply.raw.write(
117+
JSON.stringify({
118+
...result,
119+
event: 'data',
120+
})
121+
)
122+
}
123+
}
124+
} catch (e) {
125+
throw e
126+
} finally {
127+
respPing.clear()
128+
reply.raw.end()
129+
}
130+
}
131+
)
132+
133+
fastify.delete<SyncOrphanObjectsRequest>(
134+
'/:tenantId/buckets/:bucketId/orphan-objects',
135+
{
136+
schema: syncOrphanedObjects,
137+
},
138+
async (req, reply) => {
139+
if (!req.body.deleteDbKeys && !req.body.deleteS3Keys) {
140+
return reply.status(400).send({
141+
error: 'At least one of deleteDbKeys or deleteS3Keys must be set to true',
142+
})
143+
}
144+
145+
const bucket = `${req.params.bucketId}`
146+
let before = req.body.before ? new Date(req.body.before as string) : undefined
147+
148+
if (!before) {
149+
before = new Date()
150+
before.setHours(before.getHours() - 1)
151+
}
152+
153+
const respPing = ping(reply)
154+
155+
try {
156+
const scanner = new ObjectScanner(req.storage)
157+
const result = scanner.deleteOrphans(bucket, {
158+
deleteDbKeys: req.body.deleteDbKeys,
159+
deleteS3Keys: req.body.deleteS3Keys,
160+
signal: req.signals.disconnect.signal,
161+
before,
162+
tmpTable: req.body.tmpTable,
163+
})
164+
165+
for await (const deleted of result) {
166+
respPing.update()
167+
reply.raw.write(
168+
JSON.stringify({
169+
...deleted,
170+
event: 'data',
171+
})
172+
)
173+
}
174+
} catch (e) {
175+
throw e
176+
} finally {
177+
respPing.clear()
178+
reply.raw.end()
179+
}
180+
}
181+
)
182+
}
183+
184+
// Occasionally write a ping message to the response stream
185+
function ping(reply: FastifyReply) {
186+
let lastSend = undefined as Date | undefined
187+
const clearPing = setInterval(() => {
188+
const fiveSecondsEarly = new Date()
189+
fiveSecondsEarly.setSeconds(fiveSecondsEarly.getSeconds() - 5)
190+
191+
if (!lastSend || (lastSend && lastSend < fiveSecondsEarly)) {
192+
lastSend = new Date()
193+
reply.raw.write(
194+
JSON.stringify({
195+
event: 'ping',
196+
})
197+
)
198+
}
199+
}, 1000 * 10)
200+
201+
return {
202+
clear: () => clearInterval(clearPing),
203+
update: () => {
204+
lastSend = new Date()
205+
},
206+
}
207+
}

src/internal/concurrency/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './mutex'
22
export * from './async-abort-controller'
3+
export * from './merge-async-itertor'
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
type MergedYield<Gens extends Record<string, AsyncGenerator<any>>> = {
2+
[K in keyof Gens]: Gens[K] extends AsyncGenerator<infer V> ? { type: K; value: V } : never
3+
}[keyof Gens]
4+
5+
export async function* mergeAsyncGenerators<Gens extends Record<string, AsyncGenerator<any>>>(
6+
gens: Gens
7+
): AsyncGenerator<MergedYield<Gens>> {
8+
// Convert the input object into an array of [name, generator] tuples
9+
const entries = Object.entries(gens) as [keyof Gens, Gens[keyof Gens]][]
10+
11+
// Initialize an array to keep track of each generator's state
12+
const iterators = entries.map(([name, gen]) => ({
13+
name,
14+
iterator: gen[Symbol.asyncIterator](),
15+
done: false,
16+
}))
17+
18+
// Continue looping as long as at least one generator is not done
19+
while (iterators.some((it) => !it.done)) {
20+
// Prepare an array of promises to fetch the next value from each generator
21+
const nextPromises = iterators.map((it) =>
22+
it.done ? Promise.resolve({ done: true, value: undefined }) : it.iterator.next()
23+
)
24+
25+
// Await all the next() promises concurrently
26+
const results = await Promise.all(nextPromises)
27+
28+
// Iterate through the results and yield values with their corresponding names
29+
for (let i = 0; i < iterators.length; i++) {
30+
const it = iterators[i]
31+
const result = results[i]
32+
33+
if (!it.done && !result.done) {
34+
// Yield an object containing the generator's name and the yielded value
35+
yield { type: it.name, value: result.value } as MergedYield<Gens>
36+
}
37+
38+
if (!it.done && result.done) {
39+
// Mark the generator as done if it has no more values
40+
it.done = true
41+
}
42+
}
43+
}
44+
}

src/internal/database/client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { ERRORS } from '@internal/errors'
66
interface ConnectionOptions {
77
host: string
88
tenantId: string
9+
maxConnections?: number
910
headers?: Record<string, string | undefined | string[]>
1011
method?: string
1112
path?: string

src/internal/database/connection.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export const searchPath = ['storage', 'public', 'extensions', ...dbSearchPath.sp
6969
export class TenantConnection {
7070
public readonly role: string
7171

72-
constructor(protected readonly pool: Knex, protected readonly options: TenantConnectionOptions) {
72+
constructor(public readonly pool: Knex, protected readonly options: TenantConnectionOptions) {
7373
this.role = options.user.payload.role || 'anon'
7474
}
7575

@@ -101,7 +101,9 @@ export class TenantConnection {
101101
searchPath: isExternalPool ? undefined : searchPath,
102102
pool: {
103103
min: 0,
104-
max: isExternalPool ? 1 : options.maxConnections || databaseMaxConnections,
104+
max: isExternalPool
105+
? options.maxConnections || 1
106+
: options.maxConnections || databaseMaxConnections,
105107
acquireTimeoutMillis: databaseConnectionTimeout,
106108
idleTimeoutMillis: isExternalPool
107109
? options.idleTimeoutMillis || 100

src/internal/monitoring/logger.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export const baseLogger = pino({
3232
headers: whitelistHeaders(request.headers),
3333
hostname: request.hostname,
3434
remoteAddress: request.ip,
35-
remotePort: request.socket.remotePort,
35+
remotePort: request.socket?.remotePort,
3636
}
3737
},
3838
},

src/internal/queue/event.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,10 @@ export class Event<T extends Omit<BasePayload, '$version'>> {
8888
}
8989

9090
static batchSend<T extends Event<any>[]>(messages: T) {
91+
if (!pgQueueEnable) {
92+
return Promise.all(messages.map((message) => message.send()))
93+
}
94+
9195
return Queue.getInstance().insert(
9296
messages.map((message) => {
9397
const sendOptions = (this.getQueueOptions(message.payload) as PgBoss.JobInsert) || {}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
export async function eachParallel<T>(times: number, fn: (index: number) => Promise<T>) {
2+
const promises = []
3+
for (let i = 0; i < times; i++) {
4+
promises.push(fn(i))
5+
}
6+
7+
return Promise.all(promises)
8+
}
9+
10+
export function pickRandomFromArray<T>(arr: T[]): T {
11+
return arr[Math.floor(Math.random() * arr.length)]
12+
}
13+
14+
export function pickRandomRangeFromArray<T>(arr: T[], range: number): T[] {
15+
if (arr.length <= range) {
16+
return arr
17+
}
18+
19+
const result = new Set<T>()
20+
while (result.size < range) {
21+
result.add(pickRandomFromArray(arr))
22+
}
23+
24+
return Array.from(result)
25+
}

0 commit comments

Comments
 (0)