Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/internal/monitoring/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ export interface LifecycleEventLog extends RequestLogContext {
objectPath: string
objectVersion: string | null
bytesChanged: number
objectsChanged: number
resources: string[]
}

Expand Down
8 changes: 5 additions & 3 deletions src/storage/events/base-event.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ function webhookPayload(): ObjectRemovedEvent {
reqId: 'req-1',
sbReqId: 'sb-req-1',
bytesChanged: -42,
objectsChanged: -1,
}
}

Expand All @@ -215,7 +216,7 @@ describe('BaseEvent.sendWebhook', () => {
webhookSend.mockResolvedValue(undefined)
})

it('logs the billing event synchronously, before the webhook is enqueued', async () => {
it('logs the usage event synchronously, before the webhook is enqueued', async () => {
const TestEvent = await loadTestEvent()

await TestEvent.sendWebhook(webhookPayload(), Date.now())
Expand All @@ -228,6 +229,7 @@ describe('BaseEvent.sendWebhook', () => {
objectPath: 'tenant-a/bucket-a/path/file.png',
objectVersion: 'version-1',
bytesChanged: -42,
objectsChanged: -1,
resources: ['/tenant-a/bucket-a/path/file.png'],
tenantId: 'tenant-a',
project: 'tenant-a',
Expand All @@ -244,7 +246,7 @@ describe('BaseEvent.sendWebhook', () => {
expect(logOrder).toBeLessThan(sendOrder)
})

it('still logs the billing event exactly once when enqueuing the webhook fails', async () => {
it('still logs the usage event exactly once when enqueuing the webhook fails', async () => {
webhookSend.mockRejectedValue(new Error('queue unavailable'))
const TestEvent = await loadTestEvent()

Expand All @@ -253,7 +255,7 @@ describe('BaseEvent.sendWebhook', () => {
expect(logInfo).toHaveBeenCalledTimes(1)
})

it('still enqueues the webhook, without throwing, when logging the billing event fails', async () => {
it('still enqueues the webhook, without throwing, when logging the usage event fails', async () => {
logInfo.mockImplementation(() => {
throw new Error('serialization failed')
})
Expand Down
2 changes: 2 additions & 0 deletions src/storage/events/base-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export abstract class BaseEvent<T extends Omit<BasePayload, '$version'>> extends
name: string
version: string | null
bytesChanged: number
objectsChanged: number
},
>(this: StaticThis<TPayload>, payload: Omit<TPayload, '$version'>, applyTime: number) {
if (!Webhook) {
Expand All @@ -53,6 +54,7 @@ export abstract class BaseEvent<T extends Omit<BasePayload, '$version'>> extends
objectPath: path,
objectVersion: payload.version,
bytesChanged: payload.bytesChanged,
objectsChanged: payload.objectsChanged,
resources: ['/' + path],
tenantId: payload.tenant.ref,
project: payload.tenant.ref,
Expand Down
2 changes: 2 additions & 0 deletions src/storage/events/lifecycle/object-created.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ interface ObjectCreatedEventBase extends BasePayload {
uploadType: ObjectCreatedUploadType
// Net bytes this event added/removed to storage
bytesChanged: number
// Net objects this event added/removed. Delete markers never count
objectsChanged: number
// Set only when bytesChanged includes a row this write replaced in place
replacedObject?: ReplacedObjectSnapshot
}
Expand Down
1 change: 1 addition & 0 deletions src/storage/events/lifecycle/object-promoted.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export interface ObjectPromotedEvent extends BasePayload {
version: string | null
metadata?: Record<string, unknown> | null
bytesChanged: number
objectsChanged: number
}

export class ObjectPromoted extends BaseEvent<ObjectPromotedEvent> {
Expand Down
2 changes: 2 additions & 0 deletions src/storage/events/lifecycle/object-removed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export interface ObjectRemovedEvent extends BasePayload {
metadata?: Record<string, unknown> | null
// Net bytes this event added/removed to storage
bytesChanged: number
// Net objects this event added/removed. Delete markers never count
objectsChanged: number
// Set only when bytesChanged includes a row this write replaced in place
replacedObject?: ReplacedObjectSnapshot
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,58 @@ describe('ObjectAdminDeleteAllBefore.handle', () => {
}
})

it('reports objectsChanged by row, counting zero-byte objects and never delete markers', async () => {
const db = {
listObjects: vi
.fn()
.mockResolvedValue([{ id: 'id-1' }, { id: 'id-2' }, { id: 'id-3' }, { id: 'id-4' }]),
deleteObjects: vi.fn().mockResolvedValue([
{ id: 'id-1', name: 'sized.txt', version: 'v1', metadata: { size: 7 } },
{ id: 'id-2', name: 'empty.txt', version: 'v1', metadata: { size: 0 } },
{ id: 'id-3', name: 'nosize.txt', version: 'v1', metadata: null },
{ id: 'id-4', name: 'sized.txt', version: 'v2', is_delete_marker: true, metadata: null },
]),
destroyConnection: vi.fn(),
}
const storage = {
db,
backend: {
deleteObjectsDetailed: vi.fn((_bucket: string, keys: string[]) =>
Promise.resolve(keys.map((key) => ({ key, outcome: 'DELETED' as const })))
),
},
location: {
getRootLocation: vi.fn(() => 'root-bucket'),
getKeyLocation: vi.fn(() => 'tenant-id/bucket/key'),
},
}
vi.spyOn(
ObjectAdminDeleteAllBefore as unknown as { createStorage: () => Promise<unknown> },
'createStorage'
).mockResolvedValue(storage)
const sendWebhook = vi.spyOn(ObjectRemoved, 'sendWebhook').mockResolvedValue(undefined)

await ObjectAdminDeleteAllBefore.handle(createJob() as never)

const payloads = sendWebhook.mock.calls.map(
([payload]) =>
payload as { name: string; version: string; bytesChanged: number; objectsChanged: number }
)
expect(
payloads.map(({ version, name, bytesChanged, objectsChanged }) => ({
name,
version,
bytesChanged,
objectsChanged,
}))
).toEqual([
{ name: 'sized.txt', version: 'v1', bytesChanged: -7, objectsChanged: -1 },
{ name: 'empty.txt', version: 'v1', bytesChanged: 0, objectsChanged: -1 },
{ name: 'nosize.txt', version: 'v1', bytesChanged: 0, objectsChanged: -1 },
{ name: 'sized.txt', version: 'v2', bytesChanged: 0, objectsChanged: 0 },
])
})

it("captures a fresh applyTime per batch instead of reusing an earlier batch's", async () => {
let now = 1_700_000_000_000
const dateNowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now++)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { logger, logSchema } from '@internal/monitoring'
import { BasePayload } from '@internal/queue'
import { metadataSize } from '@storage/schemas'
import { metadataSize, objectsFreed } from '@storage/schemas'
import { Job, SendOptions, WorkOptions } from 'pg-boss'
import { getConfig } from '../../../config'
import { Storage } from '../../index'
Expand Down Expand Up @@ -113,6 +113,7 @@ export class ObjectAdminDeleteAllBefore extends BaseEvent<ObjectDeleteAllBeforeE
version: object.version ?? null,
metadata: object.metadata,
bytesChanged: -metadataSize(object.metadata) || 0,
objectsChanged: -objectsFreed(object) || 0,
},
committedAt
)
Expand Down
1 change: 1 addition & 0 deletions src/storage/object-delete.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -979,6 +979,7 @@ describe('ObjectStorage.deleteObjects', () => {
version: 'v1',
metadata: { size: 1 },
bytesChanged: 0,
objectsChanged: 0,
}),
expect.any(Number)
)
Expand Down
20 changes: 19 additions & 1 deletion src/storage/object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
Obj,
ObjectListEntry,
ObjectListingFilterMode,
objectsFreed,
} from '@storage/schemas'
import { FastifyRequest } from 'fastify/types/request'
import { StorageBackendAdapter } from './backend'
Expand Down Expand Up @@ -518,6 +519,7 @@ export class ObjectStorage {
sbReqId: this.db.sbReqId,
metadata: eventObject.metadata,
bytesChanged: -metadataSize(freed?.metadata) || 0,
objectsChanged: -objectsFreed(freed) || 0,
// A marker's bytes are freed from the null-version row its insert
// reused in place, which can differ from eventObject when the key has
// prior history under both SUSPENDED and ENABLED versioning.
Expand Down Expand Up @@ -552,6 +554,7 @@ export class ObjectStorage {
version: promoted.version ?? null,
metadata: promoted.metadata,
bytesChanged: 0,
objectsChanged: 0,
},
// +1 to ensure this event is applied after the remove above
committedAt + 1
Expand Down Expand Up @@ -844,7 +847,11 @@ export class ObjectStorage {
...applied.plain.map((deleted) => {
if (!deleted.is_delete_marker) {
// DISABLED: the deleted row's own bytes are freed.
return { object: deleted, bytesChanged: -metadataSize(deleted.metadata) || 0 }
return {
object: deleted,
bytesChanged: -metadataSize(deleted.metadata) || 0,
objectsChanged: -objectsFreed(deleted) || 0,
}
}
// A marker frees only the bytes of the null-version row it replaced in place (SUSPENDED)
// otherwise it archives the current row without freeing anything.
Expand All @@ -853,12 +860,14 @@ export class ObjectStorage {
return {
object: priorPlainObject(deleted) ?? deleted,
bytesChanged: -metadataSize(replaced?.metadata) || 0,
objectsChanged: -objectsFreed(replaced) || 0,
...replacedObjectField(replaced),
}
}),
...applied.versioned.map((deleted) => ({
object: deleted,
bytesChanged: -metadataSize(deleted.metadata) || 0,
objectsChanged: -objectsFreed(deleted) || 0,
})),
]

Expand Down Expand Up @@ -910,6 +919,7 @@ export class ObjectStorage {
version: event.object.version ?? null,
metadata: event.object.metadata,
bytesChanged: event.bytesChanged,
objectsChanged: event.objectsChanged,
...('replacedObject' in event && event.replacedObject
? { replacedObject: event.replacedObject }
: {}),
Expand All @@ -930,6 +940,7 @@ export class ObjectStorage {
version: object.version ?? null,
metadata: object.metadata,
bytesChanged: 0,
objectsChanged: 0,
},
// +1 to ensure this event is applied after the remove above
committedAt + 1
Expand Down Expand Up @@ -1187,6 +1198,7 @@ export class ObjectStorage {
metadata,
uploadType,
bytesChanged: metadataSize(metadata) - metadataSize(replaced?.metadata),
objectsChanged: 1 - objectsFreed(replaced),
...replacedObjectField(replaced),
reqId: this.db.reqId,
sbReqId: this.db.sbReqId,
Expand Down Expand Up @@ -1445,6 +1457,7 @@ export class ObjectStorage {
let destObject: WrittenObject
let shouldDeleteSourceContent = true
let sourceBytesFreed = metadataSize(sourceObject.metadata)
let sourceObjectsFreed = 1
let sourceReplacedObject: ReturnType<typeof replacedContent>
let promotedSource: Obj | undefined
const freedContent: { name: string; bucketId: string; version?: string }[] = []
Expand Down Expand Up @@ -1504,6 +1517,7 @@ export class ObjectStorage {
shouldDeleteSourceContent = false
const freed = replacedContent(deletedSource)
sourceBytesFreed = metadataSize(freed?.metadata)
sourceObjectsFreed = objectsFreed(freed)
sourceReplacedObject = freed
if (freed) {
freedContent.push({
Expand Down Expand Up @@ -1551,6 +1565,7 @@ export class ObjectStorage {
sourceVersion: sourceObject.version,
sourceMetadata: sourceObject.metadata,
sourceBytesFreed,
sourceObjectsFreed,
sourceReplacedObject,
promotedSource,
replacedDestination,
Expand Down Expand Up @@ -1586,6 +1601,7 @@ export class ObjectStorage {
version: moved.sourceVersion ?? null,
metadata: moved.sourceMetadata,
bytesChanged: -moved.sourceBytesFreed || 0,
objectsChanged: -moved.sourceObjectsFreed || 0,
// The marker reused the null-version row's slot in place (SUSPENDED),
// which can differ from the source version this event otherwise describes
...replacedObjectField(moved.sourceReplacedObject),
Expand All @@ -1605,6 +1621,7 @@ export class ObjectStorage {
// report its size here rather than reporting it as a separate event
bytesChanged:
metadataSize(metadata) - metadataSize(moved.replacedDestination?.metadata),
objectsChanged: 1 - objectsFreed(moved.replacedDestination),
...replacedObjectField(moved.replacedDestination),
oldObject: {
name: sourceObjectName,
Expand Down Expand Up @@ -1634,6 +1651,7 @@ export class ObjectStorage {
version: moved.promotedSource.version ?? null,
metadata: moved.promotedSource.metadata,
bytesChanged: 0,
objectsChanged: 0,
},
// +1 to ensure this event is applied last
committedAt + 1
Expand Down
8 changes: 8 additions & 0 deletions src/storage/schemas/object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@ export function metadataSize(metadata: Obj['metadata']): number {
return parseMetadataNumber(metadata?.size) ?? 0
}

/**
* How many objects a removed row accounts for: 1 for a row holding bytes, 0 for
* a delete marker or no row. Counts rows, not bytes, so zero-byte objects count.
*/
export function objectsFreed(row?: object | null): number {
return row && !(row as { is_delete_marker?: boolean | null }).is_delete_marker ? 1 : 0
}

export const objectListEntrySchema = {
$id: 'objectListEntrySchema',
type: 'object',
Expand Down
3 changes: 2 additions & 1 deletion src/storage/uploader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
replacedObjectField,
} from './events'
import { getFileSizeLimit, isEmptyFolder } from './limits'
import { metadataSize } from './schemas'
import { metadataSize, objectsFreed } from './schemas'
import { validateMimeType } from './validators/mime-type'
import { validateXRobotsTag } from './validators/x-robots-tag'

Expand Down Expand Up @@ -356,6 +356,7 @@ export class Uploader {
bucketId,
metadata: objectMetadata,
bytesChanged: objectMetadata.size - metadataSize(replaced?.metadata),
objectsChanged: 1 - objectsFreed(replaced),
...replacedObjectField(replaced),
reqId: this.db.reqId,
sbReqId: this.db.sbReqId,
Expand Down
2 changes: 2 additions & 0 deletions src/test/object-versioning-copies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ describe('object versioning - copyObject', () => {
expect(createdSpy).toHaveBeenCalledWith(
expect.objectContaining({
bytesChanged: content.length,
objectsChanged: 1,
}),
expect.any(Number)
)
Expand Down Expand Up @@ -95,6 +96,7 @@ describe('object versioning - copyObject', () => {
expect.objectContaining({
metadata: expect.objectContaining({ size: content.length }),
bytesChanged: content.length - overwrittenContent.length,
objectsChanged: 0,
replacedObject: expect.objectContaining({
metadata: expect.objectContaining({ size: overwrittenContent.length }),
}),
Expand Down
Loading
Loading