diff --git a/components/object/list.tsx b/components/object/list.tsx index 24239eec..148ee08c 100644 --- a/components/object/list.tsx +++ b/components/object/list.tsx @@ -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 { @@ -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 } @@ -187,9 +187,9 @@ export function ObjectList({ OBJECT_LIST_DEFAULT_PAGE_SIZE, ) const deletedView = resolveObjectListDeletedView(storedDeletedView) - const [prefixSummaries, setPrefixSummaries] = React.useState>( - {}, - ) + const [prefixSummaries, setPrefixSummaries] = React.useState< + Record + >({}) const [prefixScan, setPrefixScan] = React.useState<{ key: string state: "loading" | "failed" | "cancelled" | "limit" | "success" @@ -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(columnId), right.getValue(columnId)), cell: ({ row }) => { if (row.original.type === "object") return formatBytes(row.original.Size) const key = row.original.Key diff --git a/lib/functions.ts b/lib/functions.ts index 13386cbf..adf15540 100644 --- a/lib/functions.ts +++ b/lib/functions.ts @@ -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) { diff --git a/lib/prefix-summary.ts b/lib/prefix-summary.ts index f0cf0576..1bdfad05 100644 --- a/lib/prefix-summary.ts +++ b/lib/prefix-summary.ts @@ -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() @@ -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 } @@ -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 +} diff --git a/tests/lib/force-cleanup-components.test.ts b/tests/lib/force-cleanup-components.test.ts index 0d323302..2df978b7 100644 --- a/tests/lib/force-cleanup-components.test.ts +++ b/tests/lib/force-cleanup-components.test.ts @@ -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" @@ -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({ @@ -113,10 +115,13 @@ const mocks: Record = { ), ), ), - 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: ({ @@ -133,7 +138,7 @@ const mocks: Record = { placeholder, onInput: (event: React.FormEvent) => 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 [ @@ -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 } @@ -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({ + data, + columns: [sizeColumn as unknown as ColumnDef], + 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")) +}) diff --git a/tests/lib/prefix-summary-format.test.ts b/tests/lib/prefix-summary-format.test.ts new file mode 100644 index 00000000..8e1d4686 --- /dev/null +++ b/tests/lib/prefix-summary-format.test.ts @@ -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 = [9007199254740993n, 10, 9007199254740992n, 0, -1, 1n] + assert.deepEqual(bytes.sort(compareByteCounts), [-1, 0, 1n, 10, 9007199254740992n, 9007199254740993n]) + assert.equal(compareByteCounts(1, 1n), 0) +}) diff --git a/tests/lib/prefix-summary.test.ts b/tests/lib/prefix-summary.test.ts index 973d66af..4212fcd6 100644 --- a/tests/lib/prefix-summary.test.ts +++ b/tests/lib/prefix-summary.test.ts @@ -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 () => { @@ -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) +})