Skip to content
Closed
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
12 changes: 7 additions & 5 deletions components/object/list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ import { downloadUrl } from "@/lib/export-file"
import { getAttachmentContentDisposition } from "@/lib/content-disposition"
import { formatBytes, formatDateTime } from "@/lib/functions"
import { cn } from "@/lib/utils"
import { PrefixSummaryLimitError } from "@/lib/prefix-summary"
import { PrefixSummaryLimitError, compareByteCounts } from "@/lib/prefix-summary"
import { normalizeDateToIso } from "@/lib/safe-date"
import { buildBucketPath } from "@/lib/bucket-path"
import {
Expand Down Expand Up @@ -116,7 +116,7 @@ interface ObjectRow {
type: "prefix" | "object"
Size: number
LastModified: string
summary?: { size: number; lastModified?: Date }
summary?: { size: number | bigint; lastModified?: Date }
deleted: boolean
}

Expand Down Expand Up @@ -187,9 +187,9 @@ export function ObjectList({
OBJECT_LIST_DEFAULT_PAGE_SIZE,
)
const deletedView = resolveObjectListDeletedView(storedDeletedView)
const [prefixSummaries, setPrefixSummaries] = React.useState<Record<string, { size: number; lastModified?: Date }>>(
{},
)
const [prefixSummaries, setPrefixSummaries] = React.useState<
Record<string, { size: number | bigint; lastModified?: Date }>
>({})
const [prefixScan, setPrefixScan] = React.useState<{
key: string
state: "loading" | "failed" | "cancelled" | "limit" | "success"
Expand Down Expand Up @@ -640,6 +640,8 @@ export function ObjectList({
id: "size",
header: () => t("Size"),
accessorFn: (row) => (row.type === "object" ? row.Size : (row.summary?.size ?? -1)),
sortingFn: (left, right, columnId) =>
compareByteCounts(left.getValue<number | bigint>(columnId), right.getValue<number | bigint>(columnId)),
cell: ({ row }) => {
if (row.original.type === "object") return formatBytes(row.original.Size)
const key = row.original.Key
Expand Down
14 changes: 13 additions & 1 deletion lib/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,19 @@ export function niceBytes(x: string, isK8sUnits = false): string {
return formatBytes(n, isK8sUnits)
}

export function formatBytes(n: number, isK8sUnits = false): string {
export function formatBytes(n: number | bigint, isK8sUnits = false): string {
if (typeof n === "bigint") {
if (n <= 0n) return "0 B"
const units = isK8sUnits ? K8S_UNITS : UNITS
let divisor = 1n
let unit = 0
while (n >= divisor * 1024n && unit < units.length - 1) {
divisor *= 1024n
unit++
}
const tenths = (n * 10n + divisor / 2n) / divisor
return `${tenths / 10n}.${tenths % 10n} ${units[unit]}`
}
const l = Math.floor(Math.log(n) / Math.log(1024)) || 0

if (l < 0) {
Expand Down
14 changes: 11 additions & 3 deletions lib/prefix-summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export async function summarizePrefix(
signal: AbortSignal,
) {
let count = 0
let size = 0
let size: number | bigint = 0
let lastModified: Date | undefined
let token: string | undefined
const seen = new Set<string>()
Expand All @@ -31,8 +31,11 @@ export async function summarizePrefix(
) {
throw new Error("Incomplete object metadata")
}
size += object.Size!
if (!Number.isSafeInteger(size)) throw new Error("Prefix size exceeds the supported range")
if (typeof size === "number" && object.Size! <= Number.MAX_SAFE_INTEGER - size) {
size += object.Size!
} else {
size = BigInt(size) + BigInt(object.Size!)
}
if (!lastModified || timestamp > lastModified.getTime()) lastModified = object.LastModified
}
if (response.IsTruncated === false) return { size, lastModified }
Expand All @@ -43,3 +46,8 @@ export async function summarizePrefix(
}
throw new PrefixSummaryLimitError("Prefix is too large to calculate in one scan")
}

/** Compare byte counts without converting large values to floating point. */
export function compareByteCounts(left: number | bigint, right: number | bigint): number {
return left < right ? -1 : left > right ? 1 : 0
}
61 changes: 55 additions & 6 deletions tests/lib/force-cleanup-components.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as React from "react"
import { createRoot } from "react-dom/client"
import { Window, type Element as HappyElement } from "happy-dom"
import ts from "typescript"
import { createTable, getCoreRowModel, getSortedRowModel, type ColumnDef } from "@tanstack/react-table"
import { isForceCleanupTarget } from "../../lib/permission-capabilities"
import { PrefixSummaryLimitError } from "../../lib/prefix-summary"
import type { ObjectList as ObjectListComponent } from "../../components/object/list"
Expand All @@ -27,6 +28,7 @@ type MockTable = {
resetRowSelection: () => void
}

let observedTable: MockTable | undefined
const mocksKey = Symbol.for("force-cleanup-components")
const dialogContext = React.createContext<(open: boolean) => void>(() => {})
const menuContext = React.createContext({
Expand Down Expand Up @@ -113,10 +115,13 @@ const mocks: Record<string, unknown> = {
),
),
),
useDataTable: (table: MockTable) => ({
table: { ...table, getColumn: () => ({ setFilterValue() {} }), resetRowSelection() {} },
selectedRowIds: [],
}),
useDataTable: (table: MockTable) => {
observedTable = table
return {
table: { ...table, getColumn: () => ({ setFilterValue() {} }), resetRowSelection() {} },
selectedRowIds: [],
}
},
useLocalStorage: (_key: string, initial: boolean) => React.useState(initial),
useObjectListPagination() {},
SearchInput: ({
Expand All @@ -133,7 +138,7 @@ const mocks: Record<string, unknown> = {
placeholder,
onInput: (event: React.FormEvent<HTMLInputElement>) => onChange(event.currentTarget.value),
}),
formatBytes: (bytes: number) => String(bytes),
formatBytes: (bytes: number | bigint) => String(bytes),
formatDateTime: (date: string | Date) => (date instanceof Date ? date.toISOString() : date),
}
for (const name of [
Expand Down Expand Up @@ -202,7 +207,10 @@ async function mount(
t: TestContext,
initialProps: ObjectListProps,
versioning: string = "Suspended",
calculatePrefixSummary?: (key: string, signal: AbortSignal) => Promise<{ size: number; lastModified?: Date }>,
calculatePrefixSummary?: (
key: string,
signal: AbortSignal,
) => Promise<{ size: number | bigint; lastModified?: Date }>,
) {
const browser = new Window()
const globals = { window: browser, document: browser.document, IS_REACT_ACT_ENVIRONMENT: true }
Expand Down Expand Up @@ -581,3 +589,44 @@ for (const [summary, dateText] of [
assert.equal(row?.textContent?.includes(String(summary.size)), false)
})
}

test("object and large prefix sizes sort numerically in the real table model", async (t) => {
await mount(t, props)
const sizeColumn = observedTable?.columns.find((column) => column.id === "size")
assert.ok(sizeColumn)
type SizeRow = { Key: string; type: "object" | "prefix"; Size: number; summary?: { size: number | bigint } }
const data: SizeRow[] = [
{ Key: "large/", type: "prefix", Size: 0, summary: { size: 9007199254740993n } },
{ Key: "object", type: "object", Size: Number.MAX_SAFE_INTEGER },
{ Key: "unknown/", type: "prefix", Size: 0 },
{ Key: "small/", type: "prefix", Size: 0, summary: { size: 4n } },
{ Key: "small-object", type: "object", Size: 4 },
{ Key: "smaller-large/", type: "prefix", Size: 0, summary: { size: 9007199254740992n } },
]
const table = createTable<SizeRow>({
data,
columns: [sizeColumn as unknown as ColumnDef<SizeRow>],
state: { sorting: [{ id: "size", desc: false }] },
onStateChange: () => {},
renderFallbackValue: null,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
})
assert.deepEqual(
table.getRowModel().rows.map((row) => row.original.Key),
["unknown/", "small/", "small-object", "object", "smaller-large/", "large/"],
)
table.setOptions((options) => ({ ...options, state: { sorting: [{ id: "size", desc: true }] } }))
assert.deepEqual(
table.getRowModel().rows.map((row) => row.original.Key),
["large/", "smaller-large/", "object", "small/", "small-object", "unknown/"],
)
})

test("large exact prefix size appears in both row and completed status", async (t) => {
const ui = await mount(t, props, "Suspended", async () => ({ size: 9007199254740993n }))
await ui.click(ui.findButton("Calculate folder statistics"))
const row = ui.container.querySelector(`[data-object-key="${fullKey}"]`)
assert.ok(row?.textContent?.includes("9007199254740993"))
assert.ok(ui.container.querySelector('[role="status"]')?.textContent?.includes("9007199254740993"))
})
18 changes: 18 additions & 0 deletions tests/lib/prefix-summary-format.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import test from "node:test"
import assert from "node:assert/strict"
import { formatBytes } from "../../lib/functions"
import { compareByteCounts } from "../../lib/prefix-summary"

test("formats large bigint byte counts with integer rounding and existing units", () => {
assert.equal(formatBytes(0n), "0 B")
assert.equal(formatBytes(1536n), "1.5 KiB")
assert.equal(formatBytes(9007199254740992n), "8.0 PiB")
assert.equal(formatBytes(9007199254740992n, true), "8.0 Pi")
assert.equal(formatBytes(1126n), "1.1 KiB")
})

test("compares numbers and bigint byte counts without precision loss", () => {
const bytes: Array<number | bigint> = [9007199254740993n, 10, 9007199254740992n, 0, -1, 1n]
assert.deepEqual(bytes.sort(compareByteCounts), [-1, 0, 1n, 10, 9007199254740992n, 9007199254740993n])
assert.equal(compareByteCounts(1, 1n), 0)
})
41 changes: 31 additions & 10 deletions tests/lib/prefix-summary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,13 @@ test("empty prefixes have zero size and no object time", async () => {
})
})

test("rejects missing metadata and unsafe totals", async () => {
test("rejects missing metadata and unsafe object sizes", async () => {
await assert.rejects(summarizePrefix(async () => ({ Contents: [{ Size: 2 }], IsTruncated: false }), signal()))
await assert.rejects(
summarizePrefix(
async () => ({
Contents: [object(Number.MAX_SAFE_INTEGER, "2026-01-01"), object(1, "2026-01-01")],
IsTruncated: false,
}),
signal(),
),
)
for (const size of [-1, 0.5, Number.NaN, Number.POSITIVE_INFINITY, Number.MAX_SAFE_INTEGER + 1]) {
await assert.rejects(
summarizePrefix(async () => ({ Contents: [object(size, "2026-01-01")], IsTruncated: false }), signal()),
)
}
})

test("rejects missing and repeated continuation tokens", async () => {
Expand Down Expand Up @@ -110,3 +106,28 @@ test("rejects a truncated 100th short page without presenting partial statistics
)
assert.equal(pages, 100)
})

test("promotes the total only when adding a value would exceed the safe integer range", async () => {
const fetch = (sizes: number[]) =>
summarizePrefix(
async () => ({ Contents: sizes.map((size) => object(size, "2026-01-01")), IsTruncated: false }),
signal(),
)
assert.equal((await fetch([Number.MAX_SAFE_INTEGER - 1, 1])).size, Number.MAX_SAFE_INTEGER)
assert.equal((await fetch([Number.MAX_SAFE_INTEGER, 1, 3])).size, 9007199254740995n)
})

test("sums 2048 large valid objects over three pages without losing bytes", async () => {
let page = 0
const objectSize = 4 * 1024 ** 4
const result = await summarizePrefix(async () => {
page++
return {
Contents: Array.from({ length: page < 3 ? 1000 : 48 }, () => object(objectSize, "2026-01-01")),
IsTruncated: page < 3,
NextContinuationToken: page < 3 ? String(page) : undefined,
}
}, signal())
assert.equal(result.size, 9007199254740992n)
assert.equal(page, 3)
})