From e643029ac11e261a20129db653734db568323ec9 Mon Sep 17 00:00:00 2001 From: Gian Date: Mon, 26 Oct 2026 22:01:21 +0800 Subject: [PATCH 1/9] Define console UI interaction and layout contracts --- .../console/design-crowdb-console-ui.md | 682 ++++++++++++++++-- 1 file changed, 629 insertions(+), 53 deletions(-) diff --git a/doc/design/console/design-crowdb-console-ui.md b/doc/design/console/design-crowdb-console-ui.md index d4128ad98..036d4afb8 100644 --- a/doc/design/console/design-crowdb-console-ui.md +++ b/doc/design/console/design-crowdb-console-ui.md @@ -50,9 +50,11 @@ does not establish that a native service operation succeeds. - **UI-01:** One Console serves one configured cluster. Opening the UI enters that cluster directly; no Connect dialog, endpoint input, or access-key form precedes ordinary browsing. -- **UI-02:** The seven top-level tabs are Cluster, KV, Chunk, Chunk-KV, Iceberg, - Dataset and S3. Each has an independent selected resource and query window. Cluster - contains Topology and Capacity views sharing selection and tree expansion. +- **UI-02:** The seven top-level tab labels are Cluster, PaxosKV, Chunk, ChunkKV, + Iceberg, Dataset and S3. KV and Chunk-KV remain their internal domain identities. + Each domain has an independent selected resource and query window. Cluster + shares topology/capacity selection and tree expansion without a view switch; + topology visibility follows CLU-05. - **UI-03:** Assume an administrator/root session until UI authentication is introduced. Available backend operations remain authoritative; capability restrictions are not inferred from a missing login screen. @@ -85,11 +87,16 @@ provide observable capacity and bitmap fixtures. - **NAV-01:** The header keeps the seven domains in a stable order. Domain changes preserve that domain's bounded query state and selected identity. - **NAV-02:** The left panel selects hierarchy/scope; the center presents the - selected resource or overview; the right panel presents selected properties. + selected resource or a paged child list; the right panel presents selected + properties. Resource operations follow the context-menu contract in §23. - **NAV-03:** Cluster row/card selection updates properties without replacing the center scope. Names enter that resource; closing properties retains the work area. Cluster breadcrumbs identify the current physical scope; they are navigation, not duplicate Back links for inline details. + This graph inspection distinction does not apply to ordinary resource tree/list + rows: activating their label, icon or row selects the same resource. Expansion + controls are separate. At DiskGroup scope, both Disk icon and Disk ID enter + the same Disk workspace, including from a DiskDB ownership reference. - **NAV-04:** Cross-links identify the exact destination resource. Returning restores the originating selection and page. A stale/deleted destination has an explicit state rather than silently selecting a different item. The header @@ -114,16 +121,16 @@ provide observable capacity and bitmap fixtures. diagrams scroll within their work area; action bars and pagination remain reachable. Resizing does not reset selection or trigger unbounded refetches. Capacity observations retain the last successful inventory, usage and scan - independently after a failed refresh. The shared header exposes each source's - observation time, cached state and error. Runtime usage is keyed by group/disk + independently after a failed refresh. Unknown values and refresh errors appear + with the affected capacity data; raw observation metadata is not a global strip. Runtime usage is keyed by group/disk scope; an unobserved scope remains unknown instead of borrowing other totals. - **NAV-07:** Every domain uses the shared Tree appearance and draggable - panel dividers. Left default width is 240 px; properties default is 320 px. + panel dividers. Cluster/KV left default width is 240 px; data workbenches use + 280 px. Properties default is 320 px. Cluster navigation resizes between 200 and 360 px, properties between 280 and 480 px, preserving 480 px for the center when space permits. Below - 1100 px, properties overlays the work area as a dismissible drawer; Escape - closes it and restores focus. Navigation can collapse and overlays the work - area below 720 px. Other domain dividers retain their 220–600 px bounds. + 1100 px in Cluster, properties overlays the work area as a dismissible drawer; Escape + closes it and restores focus. Navigation remains visible without a hide/show control. Other domain dividers retain their 220–600 px bounds. Switching domains introduces no panel replacement/slide animation. - **NAV-08:** Parent cards in center topology diagrams have separate named controls for opening the resource and collapsing or expanding children. Card bodies inspect @@ -133,9 +140,32 @@ provide observable capacity and bitmap fixtures. Hiding a topology panel interrupts viewport motion before zero-size geometry is observed. Rapid domain switches retain finite coordinates; centering runs only in a visible, measured canvas. - The optional Diagram initially expands Datacenter/Rack and stops at visible Node cards; + The default diagram initially expands Datacenter/Rack and stops at visible Node cards; service descendants appear when a Node is explicitly expanded. +The header shows the CrowDB brand, domain navigation, then Back/Forward and +Refresh in the right action area. Access instance `access-1` displays as `AS-1` +while API operations retain the original identifier. + +Shell layout (NAV-01/02/04/07): + +```text ++------------------------------------------------------------------------------------------------------+ +| CrowDB Health | Cluster PaxosKV Chunk ChunkKV Iceberg Dataset S3 | [Back] [Forward] [Refresh] | ++------------------------+------------------------------------------------------+----------------------+ +| Resource tree | Current scope | Properties | +| v Parent | Query / filter [Refresh] | Selected identity | +| Child | Primary content or paged child list | Structured values | +| | [Previous] [Next] | | ++------------------------+------------------------------------------------------+----------------------+ + draggable divider draggable divider +``` + +All wireframes show layout contracts, not runtime evidence. Names, IDs, counts +and statuses are illustrative. `>`/`v` denote collapsed/expanded branches, `*` +marks selection, and `[Auto]` denotes the accessible Auto icon. Menu entries +remain subject to the resource capabilities and lifecycle rules in the text. + ## 4. Visual language and service identity - **VIS-01:** Use muted colors with readable text and visible boundaries. @@ -165,8 +195,16 @@ provide observable capacity and bitmap fixtures. The left tree separates each Node into Services and Storage. Physical groups and disks belong under Storage regardless of their DiskDB owner; service instances remain under Services. Logical Paxos groups are managed in KV. - Capacity is a Cluster view; legacy Capacity links resolve to Cluster with the - Capacity view selected. History retains the view alongside the resource. + A DiskDB instance also expands to references to the DiskGroups it currently + owns. These use authoritative instance ownership, not physical Node membership. + They open the same resource as Storage references and update on ownership changes; + unknown ownership never assigns a group to an arbitrary instance. + Candidate nodes forms another Datacenter branch, alongside Racks, consistent + with disk candidates under Storage. Only unjoined candidates for this cluster + appear there; confirmed members appear only in their Rack. Raw discovery logs + are not rendered in the tree. Legacy Capacity links resolve to Cluster. + Add Rack and Add Store belong to the respective Datacenter context menu; + uninitialized KV exposes Initialize Cluster there. - **CLU-02:** Rack actions include Add Node. A Node context menu independently manages each service type and each existing instance, using the same lifecycle pattern as KV/DiskDB. It must not route other service types through KV APIs. @@ -215,15 +253,19 @@ provide observable capacity and bitmap fixtures. - Cluster selections never display or request Chunk ownership. - **CLU-04:** Cluster readiness is reported by capability: KV quorum, storage, Chunk-KV, and Access protocols. A PID or registry entry alone is not Ready. - Missing prerequisites link to the domain where they can be resolved. - -- **CLU-05:** Cluster/Rack Topology defaults to node cards grouped by Rack; - Diagram remains available explicitly. Cards show observed health, service - readiness, registered capacity, cached candidate/setup summaries and disk - alerts. Card bodies inspect properties; names open the Node workspace. Add - Node is scoped to its Rack. Capacity uses sortable/filterable node rows with - unknown values last in either numeric sort direction. Search, health and - observed-candidate filters retain their bounded history state. + Missing prerequisites link to the domain where they can be resolved. Remote + Access readiness uses the committed dedicated health listener on the owning + node; missing configuration remains Unknown and failed probes are Down. + +- **CLU-05:** The cluster overview and Datacenter/Rack/Node scopes show topology. + Datacenter/Rack/Node capacity appears below it. Storage, DiskGroup, Disk and + Zone scopes hide topology and release its space for storage content/capacity, + including when entered through a DiskDB ownership reference. Returning to a + higher scope restores topology. Service selections and an unselected cluster + do not show capacity. Node details and + configuration editing belong in the properties panel. Storage selection exposes + its disk operations without a second node summary. There are no Topology/Capacity + tabs, diagram toggle, or node-card toggle. - **CLU-06:** Auto join starts enabled for a new standard workspace and retains an explicit target. Three authenticated eligible nodes are ordered by UUID; the smallest UUID initiates Store 0 / Group 0 with those fixed three voters. @@ -231,6 +273,76 @@ provide observable capacity and bitmap fixtures. Group-0 voters. Explicit single mode auto-starts the same initialization lifecycle with one local voter. Turning Auto join off stops new triggers; accepted initialization continues and conflicting intents require recovery. + Eligible unjoined candidates default to Auto join, including later admissions + into a bound cluster. Each candidate can retain a durable explicit override. + Enabled candidates join the default Rack; later node editing can change its + Rack. Disabling one candidate stops its new admission triggers without changing + another candidate's policy or cancelling accepted work. Initialization + progress and failure recovery appear in Datacenter properties; completed + initialization is not a persistent banner. +- **CLU-07:** Each candidate is one ordinary selectable tree row labelled + `node-`. An Auto icon, accessible label and tooltip express the effective + policy; unloaded policy is Unknown, never an unchecked/disabled substitute. + Full identity, addresses, discovery/admission state, errors and policy appear in + properties. Enable/Disable Auto join is available in its context menu and + properties, backed by the same state. Manual admission, cancellation and + discarding a prepared review appear only when applicable, in that node's menu. + There are no row checkboxes, inline action stacks, raw discovery logs or + general-purpose Update node / Clean up Group 0 controls in the candidate tree. + +Cluster: Datacenter/Rack/Node scope (CLU-01/05): + +```text ++----------------------------+--------------------------------------------------------------+------------------------------+ +| v datacenter * | datacenter | Properties | +| v R-1 | Topology | Datacenter | +| > N-1 | [datacenter] | Name datacenter | +| > N-2 | : | Health observed | +| > N-3 | [R-1] | ... | +| > Candidate nodes | / : \ | | +| | [N-1] [N-2] [N-3] | Initialization progress | +| | ---------------------------------------------------- | (only when relevant) | +| | Capacity [Refresh] | | +| | Capacity / Allocated / Free / Unknown coverage | | +| | Resource Capacity Usage | | +| | R-1 ... ... | | ++----------------------------+--------------------------------------------------------------+------------------------------+ +``` + +Cluster: candidate selection and effective Auto policy (CLU-06/07): + +```text ++----------------------------+--------------------------------------------------------------+------------------------------+ +| v datacenter | Cluster overview | Properties | +| > R-1 | Topology remains the primary overview. | node-a1b2 | +| v Candidate nodes | | Discovery ID | +| node-a1b2 [Auto] * | Candidate details are in Properties. | Addresses | +| node-c3d4 | No inline candidate cards or operation log. | State Eligible | +| | | Auto join Enabled [v] | +| | | Target Rack Default Rack | +| | | Admission Waiting | +| | | Error None | ++----------------------------+--------------------------------------------------------------+------------------------------+ +``` + +Candidate menu and DiskDB ownership references: + +```text +Right click node-a1b2 Ownership reference Physical reference ++---------------------------+ N-1 N-2 +| Disable Auto join | Services Storage +| Join node... | DDB-1 DG-7 +| Cancel admission | DG-7 Disk-a +| Discard access review | Disk-a ++---------------------------+ \ / + same DG / Disk identity +Joined candidate -> default Rack -> normal Node tree item +Explicitly disabled candidate -> no new automatic admission +``` + +Candidate menu entries depend on admission state. A DiskDB ownership reference +can point to disks on another physical Node. Selecting its DG hides topology +just like selecting that DG below Storage. ## 6. Properties and cross-links @@ -315,6 +427,29 @@ provide observable capacity and bitmap fixtures. removing configuration, and deleting stored data are distinct operations. Reset lists its affected scope and stops queued deployment work before teardown. +Shared dialogs and operation feedback (FORM-01–05, ACT-05): + +```text +Parent menu: Create table... Resource menu: Drop table... ++----------------------------------+ +---------------------------------------+ +| Create table [X] | | Drop table [X] | +| Namespace: analytics | | Target: analytics.events | +| Name [.........................] | | Remove this Catalog reference. | +| Schema [field editor...........] | | Physical reclamation follows GC. | +| | | | +| Validation / request error | | Exact scope and blockers appear here. | +| [Cancel] [Create] | | [Cancel] [Drop table] | ++----------------------------------+ +---------------------------------------+ + +Dataset publication: Review -> Evaluate -> Prepare -> Publish -> Receipt +Operation feedback: exact target / operation ID / waiting / progress / error +Unknown outcome: reconcile the same operation before retrying +``` + +Forms open only after a menu action. Progress follows the actual operation; +simple creation need not acquire publication stages. Closing a dialog does not +cancel accepted work. + ## 10. Accessibility - All controls have semantic roles and unambiguous labels. Keyboard operation, @@ -344,8 +479,9 @@ provide observable capacity and bitmap fixtures. ## 12. KV -- **KV-01:** Left tree is Store → Group → Replica. Center overview lists groups - with exact Store/Group IDs, observed health, leader, and replica count. +- **KV-01:** Left tree is Datacenter → Store → Group → Replica. Store creation + belongs to the Datacenter context menu. The center is the bounded data browser; + exact Store/Group IDs, health, leader and replica membership remain in properties. - Before initialization, explain the dependency on deployed KV servers and offer Initialize Group 0. The three-node fixture selects all three eligible nodes. - **KV-02:** Store/group/replica creation, membership operations, and deletion @@ -355,7 +491,7 @@ provide observable capacity and bitmap fixtures. Overview/Data toggle. Membership and node placement remain in properties. Unknown leader is not rendered as zero. The right inspector does not poll or render generic internal metrics. -- **KV-04:** Data operations target an explicit Store/Group. Get/put/delete/scan +- **KV-04:** Read operations target an explicit Store/Group. Query and Scan show encoding and exact key/value interpretation. Scan is bounded and paged; empty values, missing keys, and request failures are distinguishable. - A selected replica or group may expose supported diagnostics; a generic @@ -366,9 +502,30 @@ provide observable capacity and bitmap fixtures. undisplayable characters and malformed bytes become uppercase hexadecimal runs without `0x`, distinguished by warm gold text, a dark background and border. Display conversion cannot change bytes sent in a mutation or continuation. -- **KV-06:** Get/Put/Delete occupy the shared collapsed Actions strip below - the heading/path. Its expanded content names Store and Group. System Store 0 / - Group 0 is read-only for ordinary KV mutations; management APIs own its state. +- **KV-06:** The KV data browser is read-only: Query performs an exact Get and + Scan reads a bounded window. There are no Put/Delete/demo injection controls. + Topology and membership operations remain in resource context menus and use + management APIs. A uniform menu style does not add data mutation capabilities. + +PaxosKV: Group data inspection (KV-01–06): + +```text ++----------------------------+--------------------------------------------------------------+------------------------------+ +| v datacenter | PaxosKV | Properties | +| v S-0 | Store [S-0 v] Group [G-1 v] | Group G-1 / selected Key | +| > G-0 | Prefix [........................] [Scan] | Store S-0 | +| v G-1 * | Exact key [.....................] [Query] | Leader LR-2 | +| LR-1 Follower | ---------------------------------------------------- | Members LR-1,2,3 | +| LR-2 Leader | Key Value | Key | +| LR-3 Follower | key-a | Value | +| | key-b | Encoding UTF-8 / hex | +| | ... | | +| | [First] [Previous] [Next] | | ++----------------------------+--------------------------------------------------------------+------------------------------+ +``` + +Create Store belongs to Datacenter; Group/Replica management belongs to its +resource menu. Data browsing is read-only; Query/Scan remain center query tools. ## 13. Service lifecycle @@ -380,7 +537,9 @@ provide observable capacity and bitmap fixtures. Lifecycle acceptance first checks the submitted Start/Restart response, then observes the new PID and process. An in-flight startup is not a completed operation and must not trigger a duplicate cleanup restart. -- Page unload stops its deployment runner. Cancellation of an old page's +- In the legacy session-owned deployment flow, page unload stops its runner. + Standard accepted node setup runs on the target monitor independently of the + browser, as defined in CLU-03. Cancellation of an old page's observation or progress request cannot publish a service failure. The next page reloads the persisted plan and reconciles interrupted deployments; restoring a cached history page also reloads before resuming its runner. @@ -454,10 +613,49 @@ provide observable capacity and bitmap fixtures. requires no retained media, owner or metadata binding; empty membership alone does not permit a cascade. Confirmation explains the blocking prerequisites. +Storage scope: inventory without topology (DISK-01/02, CLU-05): + +```text ++----------------------------+--------------------------------------------------------------+------------------------------+ +| v datacenter | Storage - N-1 [Refresh] | Properties | +| v R-1 | Capacity / Allocated / Free / Unknown coverage | Storage / selected DG | +| v N-1 | ---------------------------------------------------- | Physical Node N-1 | +| > Services | DiskGroup Owner Binding Status | DiskDB owner DDB-1 | +| v Storage * | DG-7 DDB-1 S-0 / G-1 Online | Binding S-0 / G-1 | +| > DG-7 | ... | Observed | +| > Disk candidates | [Previous] [Next] | | +| | | | +| | No topology canvas or reserved canvas space. | | ++----------------------------+--------------------------------------------------------------+------------------------------+ +``` + +DiskGroup: Disk icon and ID enter the same Disk (NAV-03, DISK-03): + +```text ++----------------------------+--------------------------------------------------------------+------------------------------+ +| v N-1 | DG-7 [Refresh] | Properties | +| v Storage | Capacity / Allocated / Free | DiskGroup DG-7 | +| v DG-7 * | ---------------------------------------------------- | Owner DDB-1 | +| Disk-a | Disks | Binding S-0 / G-1 | +| Disk-b | +------------+ +------------+ | Physical Node N-1 | +| | : Disk icon : : Disk icon : | Status Online | +| | : usage : : usage : | | +| | +------------+ +------------+ | | +| | Disk-a Disk-b | | +| | Online Offline | | +| | | | +| | Click icon or ID -> that Disk workspace | | ++----------------------------+--------------------------------------------------------------+------------------------------+ +``` + +The same view opens through DDB-1 or Storage. Supported storage lifecycle +operations remain in resource menus with the mode restrictions in §14. + ## 15. Capacity -- **CAP-01:** Cluster/Rack/Node/DiskGroup/Disk selections show relevant capacity, - used/free allocation, disks, and observation time. Missing statistics are not +- **CAP-01:** Datacenter/Rack/Node/Storage/DiskGroup/Disk selections show relevant + capacity, used/free allocation and disks. Observation time belongs with the + affected detail, not in a global strip. Missing statistics are not zero and must not produce an all-free disk map. - **CAP-02:** Disk detail retains its summary and paged zones. Default 32 zones, Prev/Next, current visible range, and optional cheap indexed zone lookup. @@ -468,6 +666,12 @@ provide observable capacity and bitmap fixtures. - **CAP-04:** Zone bitmap uses muted blue for used and muted green for free, with a legend, used/free counts, allocation unit, and zone geometry. Bit index maps to the correct block offset according to the protocol's decode order. + Bitmap counts and usage identify their scope, observation and denominator. + Partial windows or aggregation cannot imply that the whole Zone is full. + Comparable allocation counts reconcile with usage; differing scopes or times + are explicit. Allocation, reserved/preallocated space, logical data bytes and + backing-file usage are distinct measurements. Initial system allocations are + attributable to their actual sources, not described as user data by inference. - Hover/selection provides exact block index and offset. Large bitmaps use bounded drawing/tiling or labelled aggregation; an aggregate is not one block. - **CAP-05:** Refresh bitmap, scan/recalc, compact/rebuild, and status operations @@ -492,6 +696,31 @@ provide observable capacity and bitmap fixtures. Services remain visible. Default-service prerequisite inspection is explicit for its selected Node. +Disk and inline Zone bitmap (CAP-02–05): + +```text ++----------------------------+--------------------------------------------------------------+------------------------------+ +| v N-1 | Disk-a [Refresh] | Properties | +| v Storage | Capacity / Allocated / Free | Selected allocation block | +| v DG-7 | Zones: current bounded window | Disk Disk-a | +| Disk-a * | Zone Allocated Free Usage | Zone zone-1 | +| Disk-b | zone-0 ... ... ... | Index 17 | +| | zone-1 * ... ... ... | State Free | +| Tree stops at Disk. | [Previous zones] [Next] | Unit | +| | ---------------------------------------------------- | Zone offset | +| | zone-1: block window, not the entire Zone | Disk offset | +| | BBBBGGGGGG?? B=used/blue G=free/green ?=unknown | Observed | +| | BBBBGGGGGG?? Selected block: 17 | | +| | Window counts / Zone totals / unit / geometry | | +| | [Previous blocks] [Next] [Go to block] | | +| | | | ++----------------------------+--------------------------------------------------------------+------------------------------+ +``` + +Letters represent the bitmap colors. Rendering uses actual protocol bit order +and allocation geometry. Unknown/reserved data never becomes free. Free blocks +in another window are not visible in this one. + ## 16. Loading, failures, and recovery - **ERR-01:** Distinguish initial loading, refreshing existing data, empty, @@ -508,19 +737,37 @@ provide observable capacity and bitmap fixtures. - Polling and automatic dependency checks have bounded cadence and fan-out. Identical failures do not flood notifications; explicit retry remains usable. +Collection and refresh states (ERR-01–04): + +```text +Initial loading Confirmed empty collection ++--------------------------+ +--------------------------------------------+ +| Loading namespaces... | | No namespaces. | +| No invented zero totals. | | Right-click Catalog to create a namespace. | ++--------------------------+ +--------------------------------------------+ + +Refresh failed Mutation outcome unknown ++--------------------------+ +--------------------------------------------+ +| Last observed rows | | Target: exact resource | +| Stale - refresh failed | | Operation: accepted ID | +| Cause / dependency | | Outcome unknown - reconcile before retry | +| [Retry] | | Inputs / last confirmed progress retained | ++--------------------------+ +--------------------------------------------+ +``` + ## 17. Fixed-cluster operator flow 1. Open Cluster directly. Inspect/create Rack and Nodes; add each Node through one dialog with six default services and explicit waiting dependencies. 2. In KV, initialize three-replica Group 0 and create ordinary data groups. -3. In Cluster’s Capacity view, register DiskGroups/disks with owners and bindings; DiskIO becomes - ready. Observe capacity and inspect a zone bitmap. -4. Select Cluster’s Topology view to verify each service's deployment/readiness and resolve - dependencies. Inspect logical groups in KV and physical storage in Capacity. +3. In Cluster, select Storage to register DiskGroups/disks with owners and bindings; + DiskIO becomes ready. Enter Disk/Zone to inspect capacity and its bitmap. +4. Select a Datacenter/Rack/Node in Cluster to inspect topology and service readiness. + Resolve logical group dependencies in PaxosKV and physical storage in Cluster. This is a dependency guide, not a blocking wizard. A working capability remains usable while another is unavailable. No domain asks the operator to reconnect. -Data-browser flows for the four deferred domains are outside this specification. +Data-browser flows are covered by their domain sections below. ## 18. Acceptance scenarios and scope @@ -531,7 +778,8 @@ plan, never edited out of this specification to make a run pass. or access-key form; restoring a domain does not change cluster identity. - **A02 / create:** with one Rack, create three Nodes using valid defaults; verify one instance of every requested type per Node, conflict-free ports, - same-dialog progress, and safe retry after one injected service failure. + accepted operation progress, and safe retry after one injected service failure. + Closing a standard setup dialog does not stop accepted monitor work. - **A03 / lifecycle:** independently stop/start/restart an auxiliary instance from its Node menu; verify exact target, preserved data/configuration, updated PID, and readiness. All instance labels follow §4. @@ -559,6 +807,13 @@ plan, never edited out of this specification to make a run pass. nested schema, snapshots, paged manifests/files and zero data-page reads. - **A09 / S3:** bucket/object replacement pages, HEAD, bounded preview, upload/download/multipart, locations and return from Chunk to the source object. +- **A13 / resource interaction:** parent context menu creates a supported child; + selecting it reads details; its own context menu exposes supported lifecycle + operations. Verify tree/list/property refresh, keyboard access, readonly and + unknown capability/outcome states across Iceberg, Dataset and S3. +- **A14 / Dataset:** create/import, parent-linked Snapshots, paged Samples/Fields, + explicit verified preview, Split revisions and supported lifecycle operations. + No browser Session activity or default raw-JSON properties replace this content. Out of scope: UI login/role design and new data-plane protocols. Unsupported features are explicit rather than represented as working controls. @@ -569,8 +824,10 @@ Unsupported features are explicit rather than represented as working controls. KV branches expand Store → Group without Replicas. Chunk ownership has two independent maps over slots 0–1023: Serving assigns a ChunkDB instance; Storage assigns the metadata Store/Group. Chunk-KV ordered splits and payload - disk placement are separate concepts. Ownership hints appear on the relevant - tree rows; details are requested only when a real ChunkDB or replica target + disk placement are separate concepts. The left tree has no Serving ownership / + Storage ownership wrapper or repeated footer. Ownership meaning belongs in + selected-resource properties and the scoped center inspection. Details are + requested only when a real ChunkDB or replica target exists. Unprovisioned selections do not send ownership probes. - Selecting a CDB or Group displays its full 32×32 bitmap; parent selections display one combined bitmap per layer. Each cell is exactly one slot, ordered @@ -591,6 +848,15 @@ Unsupported features are explicit rather than represented as working controls. replace the page. Type is a protocol-defined selector; exact Chunk ID lookup sits above the list in the center. Arbitrary ID-prefix/global filtering is not required. Source failures identify partial coverage rather than an empty list. + Datacenter and narrower lists expose Type, Status, Strips, Size and Sealed. + Status uses the observed lifecycle state; Size reports Chunk capacity and Sealed + reports sealed length, both with explicit units matching the detailed metadata. + Sealed length is not a boolean, nor is Chunk capacity physical used space. + Unknown fields are explicit; valid zero lengths/counts remain visible. + List and detail refer to the same Chunk identity. + Aggregation deduplicates authoritative Chunk identities rather than counting + replicas as separate chunks. Window counts are not global totals. System and + user-data types remain distinct; a large initial count alone proves no leak. - **CHK-03:** Selected Chunk summary and layout appear below the list. Each Strip has a compact two-line identity: `Strip Mirror` or `Strip EC k+m`, followed by logical interval such as `[8M, 16M)`. Stable protocol identities @@ -605,6 +871,43 @@ Unsupported features are explicit rather than represented as working controls. Repair status reflects actual protection deficit, not lack of rack diversity alone. Missing placement retains known Disk IDs rather than invented targets. +Chunk: scope, ownership inspection and list fields (CHK-01/02): + +```text ++----------------------------+--------------------------------------------------------------+------------------------------+ +| v datacenter * | Chunk ownership - datacenter | Properties | +| v R-1 | Serving slots [32x32 map] [Refresh slots] | Selected resource / slot | +| v N-1 | Storage slots [32x32 map] [Refresh slots] | Full identity | +| CDB-1 | ---------------------------------------------------- | Owner / binding | +| v PKV-1 | Chunks Type [All v] [Refresh chunks] | Generation | +| v S-0 | Exact Chunk ID [...................] [Lookup] | Observation / coverage | +| G-1 | ID Type Status Strips Size(KiB) Sealed(KiB) | ... | +| | | | +| No ownership footers. | ... WAL Active ... ... ... | | +| | ... S3 Sealed ... ... ... | | +| | Coverage / scanned count [Previous] [Next] | | ++----------------------------+--------------------------------------------------------------+------------------------------+ +``` + +Chunk: selected Strip and block placement (CHK-03–05): + +```text ++----------------------------+--------------------------------------------------------------+------------------------------+ +| v datacenter | Chunk list stays above its selected detail. | Properties | +| v R-1 | ---------------------------------------------------- | Mirror 1 | +| v N-1 | Chunk / type / state / capacity | Node N-1 | +| CDB-1 | Strip 4 Mirror | DiskGroup DG-7 | +| | [0 MiB, 16 MiB) | Disk Disk-a [link] | +| | [Mirror 1 *] [Mirror 2] [Mirror 3] | Zone zone-1 | +| | Strip 5 EC 4+2 | Offset | +| | [16 MiB, 32 MiB) | Length | +| | [Data 1] [Data 2] [Data 3] [Data 4] [P1] [P2] | Protection | +| | | | +| | Select block -> Properties | | +| | Disk link -> Cluster/Disk; Back restores this page | | ++----------------------------+--------------------------------------------------------------+------------------------------+ +``` + ## 20. Chunk-KV - **CKV-01:** Left shared Tree is Datacenter → Rack → Node → CKV Server → Split. @@ -651,6 +954,29 @@ Unsupported features are explicit rather than represented as working controls. data weights. Browser assertions verify that Weight and weighted explanations are absent while tree/page inspection and range navigation remain functional. +ChunkKV: distribution graph and selected Split (CKV-01–08): + +```text ++----------------------------+--------------------------------------------------------------+------------------------------+ +| v datacenter | ChunkKV range distribution [Refresh catalog] | Properties | +| v R-1 | Catalog generation / page / loaded scope | Split | +| v N-1 | [ChunkKV] | Range [start,end) | +| v CKV-1 | / \ | Owner CKV-1 | +| split-a * | [CKV-1] [CKV-2] | Epoch | +| v N-2 | : : | Tree ID | +| CKV-2 | [split-a] [split-b] | Checkpoint | +| | : : | Selected extent / page | +| | [Tree ID] [Tree ID] | No Weight field | +| | Graph window controls [Fit All] | | +| | ---------------------------------------------------- | | +| | Selected split-a: [Tree] [Journal] | | +| | Bounded page / journal extent content | | ++----------------------------+--------------------------------------------------------------+------------------------------+ +``` + +Graph windows and catalog pages have separate continuation. Selecting a Tree +uses real bounded base-page inspection; the graph does not scan records for weights. + ## 21. Iceberg - **ICE-01:** The configured cluster Catalog loads automatically. Left shared @@ -662,6 +988,9 @@ Unsupported features are explicit rather than represented as working controls. a compact overview followed directly by a Snapshot table: time, operation, records and files. Overview/Schema/Files controls belong only to selected Table, disappear for other resources, and have no independent Snapshots tab. + Catalog root uses one Namespaces heading; Namespace uses its actual name. + No Catalog breadcrumb/type/title stack duplicates the left tree. Namespace + and Table lists page in the center with visible continuation controls. - **ICE-03:** Schema is an expanded tree table with field ID/name/type/required and hierarchy for structs, lists and maps. Element/key/value identities stay visible. Large schemas use bounded row windows; collapse is optional, not a @@ -675,9 +1004,14 @@ Unsupported features are explicit rather than represented as working controls. footer information and sized Row Group/column blocks. Selecting a column shows complete metadata in properties. Inspection reads footer/metadata only, not data pages. Unsupported file types show an explicit capability state. -- **ICE-06:** Actions follow selected Catalog, Namespace or Table. Catalog - actions disappear when a Table is selected. Paths, titles and summaries name - the focused item once; properties must not merge unrelated parent identities. +- **ICE-06:** Catalog context menu creates Namespace; Namespace context menu + creates Table and, when supported, child Namespace. A Namespace's own menu + edits its supported properties or drops that empty Namespace. A Table's menu + exposes supported Rename, Commit metadata and Drop table operations. Drop + removes the catalog reference; physical reclamation follows service GC. + Snapshots, Manifests and Files are inspected through table-managed metadata, + not given arbitrary Create/Delete actions. Demo operations are not primary + browsing controls. Properties describe only the selected identity. - **ICE-07:** Reference windows contain at most 100 entries; Parquet windows contain 20 Row Groups and 12 columns. Paging replaces the visible window. Column widths represent compressed sizes and file spans retain exact byte @@ -686,6 +1020,58 @@ Unsupported features are explicit rather than represented as working controls. Cached branches do not bypass revalidation. A failed or stale inspection preserves selection context but removes data and continuation actions. +Iceberg: Catalog and Namespace collections (ICE-01/02/06): + +```text ++----------------------------+--------------------------------------------------------------+------------------------------+ +| v Catalog | analytics | Properties | +| v analytics * | Filter loaded tables [................] [Refresh] | Namespace analytics | +| > events | ---------------------------------------------------- | Full name | +| > users | Name Type Summary | Properties | +| > sandbox | events Table ... | ... | +| | users Table ... | | +| | ... | No duplicate Catalog | +| | [Previous] [Next] | path/type/title stack. | +| | | | +| | Catalog selected -> title: Namespaces | | +| | Namespace selected -> title: actual name | | ++----------------------------+--------------------------------------------------------------+------------------------------+ +``` + +Iceberg context-menu targets: + +```text +Catalog Namespace Table ++----------------------+ +------------------------+ +------------------------+ +| Create namespace... | | Create table... | | Rename... | ++----------------------+ | Create namespace... (*)| | Commit metadata... | + | Edit properties... | | Drop table... | + | Drop namespace... | +------------------------+ + +------------------------+ +(*) Nested Namespace creation requires backend support. +``` + +Iceberg: Table, Snapshot and File inspection (ICE-02–05): + +```text ++----------------------------+--------------------------------------------------------------+------------------------------+ +| v Catalog | events [Refresh] | Properties | +| v analytics | [Overview] [Schema] [Files] | Selected Table / reference | +| v events * | Compact table summary | Full identifier | +| v snapshot-42 | Snapshot Time Operation Records Files | Schema / Snapshot identity | +| v manifest-a | 42 ... append ... ... | Manifest List path | +| file.parquet | ... | File format / size | +| | [Previous] [Next] | Selected column metadata | +| | ---------------------------------------------------- | | +| | Select Snapshot -> paged Manifests | | +| | Expand Manifest -> paged Files | | +| | Select Parquet -> footer, Row Groups, columns | | ++----------------------------+--------------------------------------------------------------+------------------------------+ +``` + +Schema uses a structured field tree table. File inspection decodes metadata +and footer structure; it does not imply reading data pages or SQL querying. + ## 22. S3 - **S3-01:** Left Tree is S3 → Bucket. A single namespace introduces no wrapper; @@ -694,12 +1080,17 @@ Unsupported features are explicit rather than represented as working controls. - **S3-02:** Selecting a Bucket displays an API page of at most 20 objects. Prefix changes reset cursor history; Previous/Next replace rows. Any bucket filter over already loaded metadata explicitly names that local scope. -- **S3-03:** Selecting an Object replaces the list with HEAD fields: key, size, +- **S3-03:** Selecting an Object reads HEAD fields into properties: key, size, ETag, last modified, content type, supplied version and user metadata. Missing - fields show `—`. Breadcrumbs restore the Bucket list and its cursor/prefix. + fields show `—`. The center presents the selected object's content inspection. + Tree selection and Back/Forward restore the Bucket list and its cursor/prefix. Preview is a separate bounded operation, not an automatic payload GET. -- **S3-04:** Root Actions create buckets/demo; Bucket Actions upload/delete and - multipart; Object Actions preview/download/delete. Exact bucket/key is visible. +- **S3-04:** S3 root context menu creates Bucket; a Bucket's menu uploads a child + Object, opens multipart uploads or deletes that empty Bucket. An Object's own + menu exposes Preview, Download, Replace and Delete. Object rows in the center + use the same menu contract without adding every Object to the left tree. + Prefix is a key filter, not a physical folder; Bucket deletion does not silently + cascade. Demo controls are not primary browsing actions. Exact bucket/key is visible. Multipart state distinguishes uploaded parts, completion, abort, failure and unknown outcome. Downloads stream; previews read at most the advertised cap. - **S3-05:** Object details include Storage locations below HEAD, 20 extents per @@ -735,20 +1126,140 @@ Unsupported features are explicit rather than represented as working controls. Selection is captured before following a Chunk link; return refetches metadata against the saved generation and restores the exact selected extent. +S3: Bucket object list (S3-01/02/04): + +```text ++----------------------------+--------------------------------------------------------------+------------------------------+ +| v S3 | photos | Properties | +| photos * | Prefix [...................] [Apply] [Refresh] | Bucket photos | +| exports | ---------------------------------------------------- | Name | +| logs | Key Size Modified | ... | +| | cat.jpg ... ... | | +| | dog.jpg ... ... | Selecting an Object reads | +| | ... | its HEAD and properties. | +| | [Previous] [Next] | | +| | | | +| | Objects stay in this bounded list. | | +| | Right-click Object -> its own resource menu. | | ++----------------------------+--------------------------------------------------------------+------------------------------+ +``` + +S3 context-menu targets: + +```text +S3 root Bucket Object list row / detail ++-------------------+ +-------------------------+ +------------------------+ +| Create bucket... | | Upload object... | | Preview | ++-------------------+ | Multipart uploads | | Download | + | Delete empty bucket... | | Replace... | + +-------------------------+ | Delete object... | + +------------------------+ +``` + +S3: Object inspection and multipart view (S3-03–08): + +```text ++----------------------------+--------------------------------------------------------------+------------------------------+ +| v S3 | cat.jpg [Refresh] | Properties | +| photos | Content inspection | Object | +| exports | Preview is read only after an explicit request. | Bucket photos | +| | ---------------------------------------------------- | Key cat.jpg | +| | Storage locations | Size | +| | Extent Object interval Chunk ID (link) | ETag | +| | ... [start,end) ... | Content type | +| | [Previous] [Next] | Modified | +| | | User metadata | +| | Bucket menu -> Multipart uploads view | Selected extent identity | +| | Upload key / ID / state; select -> paged parts | | +| | Upload menu -> Abort (when supported) | | ++----------------------------+--------------------------------------------------------------+------------------------------+ +``` + +Preview and Download are explicit reads from the resource menu. Selecting an +Object does not download its payload. Multipart inspection is a separate center +view; its state is not piled beneath the ordinary Object list. + ## 23. Actions and properties -- **ACT-01:** Resource title/path appear first, then one compact Actions strip, - collapsed by default, then primary content. Expanded Actions name their exact - operation target. Changing resource closes obsolete action forms. -- **ACT-02:** KV, Iceberg, Dataset and S3 use this shared structure and button style. - Ordinary buttons have a visible border; destructive buttons use muted red. - Refresh, Previous and Next belong with the query/list and do not hide inside - mutation forms. Readonly/container capabilities apply to every action. +- **ACT-01:** The left tree owns resource navigation and context menus. Create + child belongs to the parent menu; Delete belongs to the resource's own menu. + Selection is Read; child collections are automatic paged lists in the center. + There is no generic Update requirement. Additional operations use precise + resource-specific names and exist only where that node's capabilities allow. + A lifecycle operation such as Reclaim is not relabelled Delete. +- **ACT-02:** All domains share menu, dialog, button and property styles. The + center has one scope heading, query/filter controls, Refresh and content, with + pagination below its list. It has no duplicate CRUD toolbar, collapsed Actions + strip, inline creation forms or repeated path/type/title stack. Query controls + and bounded Preview/Download are distinct from resource mutations. + Ordinary buttons have visible borders; destructive menu items use muted red. + Readonly, deployment mode, backend capability and lifecycle restrictions apply + to every entry point. Unknown capability does not enable writes. - **ACT-03:** Right properties show complete information for the clicked item, including graph blocks/columns/extents. Optional diagnostics never replace primary details. Metrics panels, entries and polling are excluded from the UI. Future metrics publishing targets customer-managed time-series systems; it is outside this UI contract. +- **ACT-04:** Tree labels, icons and ordinary resource rows activate the same + identity. Expansion chevrons only expand. List-only resources use the same + context menu on their center row; a focused detail retains access to that exact + resource menu. Right-click targets the clicked resource, independent of prior + selection. Menu key / Shift+F10 opens the same menu; Escape restores focus. + Matrix cells and diagram blocks retain their documented inspection semantics. +- **ACT-05:** Menus open shared dialogs; long imports use step dialogs with the + same controls. The target stays explicit and cannot change under an open form. + Switching resources closes obsolete forms without claiming to cancel accepted + work. Successful operations refresh affected lists, tree references and properties; + failures retain input and unknown outcomes require reconciliation. +- **ACT-06:** Empty collections explain how to create a supported child from + the parent menu. Empty, loading, unavailable and denied states are distinct. + Browser Session activity is absent from domain workspaces. Operation feedback + stays with its flow; resource history such as Snapshots and publication receipts + remains available. Default properties are labelled structured fields, not JSON. + +The common layout and menu targets are: + +```text ++----------------------------+--------------------------------------------------------------+------------------------------+ +| Resource tree | Current scope | Properties | +| v Parent | Filter [.............] [Refresh] | Selected identity | +| Child | ------------------------------------- | Full path / ID | +| | Child name Type / summary | Structured fields | +| | ... | ... | +| | [Previous] [Next] | | ++----------------------------+--------------------------------------------------------------+------------------------------+ ++----------------------------+--------------------------------------------------------------+------------------------------+ ++----------------------------+--------------------------------------------------------------+------------------------------+ ++----------------------------+--------------------------------------------------------------+------------------------------+ +``` + +Domain-specific scope and menu mappings: + +```text +Iceberg Center on selection Context menu +Catalog Namespaces (paged) Create namespace + Namespace Tables/children (paged) Create table; Drop namespace + Table Overview/Schema/Files Rename; Commit metadata; Drop + Snapshot -> Manifest Paged metadata/files Inspection only + File Decoded metadata Supported inspection + +Dataset Center on selection Context menu +Datasets Datasets (paged) Create dataset + Dataset Snapshots (paged) Import / New Snapshot + Snapshot Samples x Fields Create child; Publish Splits; + Stable/Retain/Release/Reclaim + Splits Revision/member windows Supported revision operations + Sample/Field/Cell in center Selected scope/value Supported authoring/preview + +S3 Center on selection Context menu +S3 Buckets (paged) Create bucket + Bucket Objects (paged) Upload; Multipart; Delete empty + Object in center Content inspection Preview; Download; Replace; Delete +``` + +These mappings do not imply every listed action is available for every state. +Immutable references and backend-owned internal structures do not acquire generic +CRUD merely to match another domain. Dataset lifecycle support is defined in §25. ## 24. E2E organization and timing @@ -783,12 +1294,17 @@ Depends on: [Dataset control lifecycle](../access-server/dataset/design-dataset- comes from the configured authority, including SDK-created identities. Empty, missing configuration, unavailable service and denied access remain distinct. Credentials stay on the Console server; no browser endpoint or bearer editor. + The root center automatically lists Datasets. Root context menu creates a + Dataset; a Dataset's menu starts Import / New Snapshot. All use §23; there is + no Session activity region. Properties render named fields rather than JSON. - **DS-02 — Relationships:** left navigation follows declared parent links as a forest. Roots and each parent's children page independently; no ancestry scan or Snapshot-ID sorting reconstructs the tree. Latest and Stable are reference badges. A Snapshot's Splits group is a peer of its children. Samples and Fields remain in the center. Direct Snapshot navigation preserves the exact identity; bounded ancestor discovery reports incomplete paths explicitly. + Dataset and Snapshot inventory pagination is reachable in the center, independent + of left-tree continuation. Tree appearance and selection use shared conventions. - **DS-03 — Combined inspection:** one Samples matrix uses Schema definitions as columns, defaulting to twenty rows and twelve fields. Required/optional is the actual field definition; no inferred type, MIME or nested schema. Header, Sample @@ -841,11 +1357,71 @@ Depends on: [Dataset control lifecycle](../access-server/dataset/design-dataset- Snapshot. Backend protection wins over an observed eligibility hint. Reclaim distinguishes unknown, pending logical cleanup and completed retirement; physical cleanup may remain deferred. No Dataset delete or Split-history purge action. + Uniform creation/reading/menu placement does not imply unsupported deletion: + whole-Dataset deletion requires its own authoritative lifecycle capability. + Published Sample/Field changes produce a new Snapshot rather than modifying + the immutable selected one in place. - **DS-10 — Modes:** `modules.dataset=false` hides the domain and prevents Dataset observation. API prefixes apply to every request. Readonly disables editing and the Console API rejects direct writes; exact matrix POST remains an observation. Managed mode permits supported data operations without enabling topology changes. +Dataset: catalog and Snapshot inventory (DS-01/02/06): + +```text ++----------------------------+--------------------------------------------------------------+------------------------------+ +| v Datasets | images [Refresh] | Properties | +| v images * | Published Snapshots | Dataset images | +| v snapshot-a | Snapshot Parent Samples References | Dataset ID | +| > Splits | snapshot-a - ... Stable | Namespace dataset-ns | +| > snapshot-b | snapshot-b snapshot-a ... Latest | Latest snapshot-b | +| > audio | ... | Stable snapshot-a | +| | [Previous] [Next] | ... | +| | | | +| | Select Datasets root -> paged Dataset list | | +| | Select Snapshot -> Samples x Fields | | +| | No Session activity region. | | ++----------------------------+--------------------------------------------------------------+------------------------------+ +``` + +Dataset context-menu targets and immutable editing: + +```text +Datasets root Dataset images Snapshot ++--------------------+ +---------------------------+ +-------------------------+ +| Create dataset... | | Import / New Snapshot... | | Create child... | ++--------------------+ +---------------------------+ | Publish Splits... | + | Set stable | +Whole-Dataset Delete is unavailable without its | Retain / Release | +authoritative lifecycle capability (DS-09). | Reclaim... | + +-------------------------+ +Sample/Field edits -> reviewed candidate -> new published Snapshot +Published Snapshot data are never edited in place. +``` + +Dataset: Samples matrix and selected cell (DS-03–05/08): + +```text ++----------------------------+--------------------------------------------------------------+------------------------------+ +| v Datasets | snapshot-b [Refresh] | Properties | +| v images | Parent / Latest / Stable / inspection state | Selected Sample/Field cell | +| v snapshot-a | ---------------------------------------------------- | Sample ID | +| > Splits | Sample image label ... | Field image | +| v snapshot-b * | sample-001 cat | Required | +| v Splits | sample-002 * dog | Storage Payload | +| train | ... | Length | +| validation | [Previous rows] [Next rows] | Checksum | +| | [Previous fields] [Next fields] | [Preview field] | +| | | Verified complete value | +| | Select Split -> pinned revision member window | or explicit unavailable | +| | Select column / row / cell -> matching properties | | ++----------------------------+--------------------------------------------------------------+------------------------------+ +``` + +Preview is explicit and bounded to a complete verified value of at most 64 KiB. +Snapshot/revision/member history remains business data, independently of the +removed browser Session activity. + Local batch mapping uses version 1 JSON aligned with Sample/Field edit models, bounded at 8 MiB and 10000 Sample edits. `schema: null` inherits the parent Schema. IDs and inline bytes are canonical base64. File names identify exactly one selected From 2f6bceedd360edb349e0bd8911539f0d1b250714 Mon Sep 17 00:00:00 2001 From: Gian Date: Tue, 27 Oct 2026 09:04:29 +0800 Subject: [PATCH 2/9] Unify console resource workflows and correct storage capacity --- .githooks/pre-commit | 67 +-- app/crowdb-access-server/src/config.rs | 6 + app/crowdb-access-server/src/dataset.rs | 109 +--- .../src/dataset/api/error.rs | 3 + .../src/dataset/api/upload.rs | 7 +- .../src/dataset/api/v2.rs | 3 +- .../src/dataset/api/v2/auxiliary.rs | 25 + .../src/dataset/api/v2/uploads.rs | 15 +- .../src/dataset/cleanup.rs | 110 ++++ app/crowdb-access-server/tests/config_test.rs | 10 + .../dataset_composition_preparation_test.rs | 3 +- .../tests/dataset_deletion_contract_test.rs | 126 +++++ .../tests/dataset_publication_cost_test.rs | 3 +- .../tests/dataset_v2_contract_test.rs | 1 + app/crowdb-diskdb/Cargo.toml | 4 + app/crowdb-diskdb/src/recovery/compaction.rs | 35 +- .../src/recovery/compaction/schedule.rs | 22 + .../tests/compaction_schedule_test.rs | 27 + app/crowdb-diskdb/tests/config_reload_test.rs | 8 +- app/crowdb-web/src/diskdb/proxy.rs | 34 +- app/crowdb-web/src/lib.rs | 2 + app/crowdb-web/src/node/auto_join.rs | 53 +- app/crowdb-web/src/node/auto_join/runner.rs | 69 ++- app/crowdb-web/src/node/storage/files.rs | 17 +- app/crowdb-web/src/services/observation.rs | 28 +- .../src/services/observation/access.rs | 88 +++ app/crowdb-web/tests/auto_join_test.rs | 81 +++ app/crowdb-web/tests/diskdb_binding_test.rs | 45 ++ app/crowdb-web/tests/service_health_test.rs | 36 ++ app/crowdb-web/ui/src/App.test.tsx | 5 +- app/crowdb-web/ui/src/App.tsx | 164 ++---- .../ui/src/access/resourceMenus.test.tsx | 49 ++ app/crowdb-web/ui/src/api.ts | 5 +- app/crowdb-web/ui/src/chunk/ChunkBrowser.tsx | 2 +- .../ui/src/chunk/ChunkHierarchy.tsx | 1 - .../ui/src/components/ContextMenu.test.tsx | 10 + .../ui/src/components/ContextMenu.tsx | 12 +- app/crowdb-web/ui/src/components/Tree.tsx | 29 +- .../ui/src/components/resourceMenuKey.ts | 9 + .../ui/src/contexts/SelectionContext.tsx | 5 +- .../ui/src/data/useZoneBitmap.test.tsx | 27 + app/crowdb-web/ui/src/data/useZoneBitmap.ts | 20 +- app/crowdb-web/ui/src/dataset/Actions.tsx | 45 +- .../ui/src/dataset/DeleteDataset.test.tsx | 35 ++ .../ui/src/dataset/DeleteDataset.tsx | 31 ++ app/crowdb-web/ui/src/dataset/Properties.tsx | 5 +- .../ui/src/dataset/ResourceMenu.tsx | 22 + app/crowdb-web/ui/src/dataset/Tree.tsx | 34 +- app/crowdb-web/ui/src/dataset/editor.test.tsx | 22 +- .../ui/src/dataset/import/Editor.tsx | 6 +- .../ui/src/dataset/navigation.test.tsx | 60 ++ app/crowdb-web/ui/src/dataset/query.ts | 3 +- .../ui/src/dataset/splits/Editor.tsx | 6 +- app/crowdb-web/ui/src/iceberg/CatalogTree.tsx | 37 +- .../ui/src/iceberg/ReferenceTree.tsx | 2 +- .../ui/src/iceberg/ResourceMenu.tsx | 76 +++ .../ui/src/iceberg/useCatalogTree.ts | 56 ++ app/crowdb-web/ui/src/menus/context.ts | 3 + .../ui/src/menus/useClusterMenus.tsx | 14 +- app/crowdb-web/ui/src/panels/ZoneBitmap.tsx | 26 +- .../ui/src/panels/capacity/CapacityBar.tsx | 2 +- .../ui/src/panels/capacity/DiskGroupView.tsx | 8 +- .../ui/src/panels/capacity/DiskView.tsx | 32 +- app/crowdb-web/ui/src/s3/ResourceMenu.tsx | 64 +++ app/crowdb-web/ui/src/services/client.test.ts | 4 + app/crowdb-web/ui/src/services/client.ts | 3 +- .../ui/src/shell/CandidateNodes.test.tsx | 110 +++- .../ui/src/shell/CandidateNodes.tsx | 155 ++---- .../ui/src/shell/CandidateProperties.tsx | 23 + app/crowdb-web/ui/src/shell/Header.test.tsx | 10 +- app/crowdb-web/ui/src/shell/Header.tsx | 9 +- .../src/shell/InitializationProgress.test.tsx | 15 +- .../ui/src/shell/InitializationProgress.tsx | 16 +- app/crowdb-web/ui/src/shell/Inspector.tsx | 14 +- .../ui/src/shell/NodeAdmissionProperties.tsx | 31 ++ .../ui/src/shell/NodeCandidatesContext.tsx | 75 +++ app/crowdb-web/ui/src/shell/Sidebar.test.tsx | 35 +- app/crowdb-web/ui/src/shell/Sidebar.tsx | 48 +- .../ui/src/shell/useClusterLayout.ts | 7 +- .../src/storage/DiskLifecycleDialog.test.tsx | 7 +- app/crowdb-web/ui/src/views/DatasetView.tsx | 74 ++- app/crowdb-web/ui/src/views/IcebergView.tsx | 108 ++-- app/crowdb-web/ui/src/views/S3View.tsx | 108 ++-- .../node/runtime/setup/execution/storage.rs | 5 +- .../crowdb-monitor/tests/node_setup_test.rs | 1 + doc/backlog/R170-s3-cuobject-rdma.md | 147 ----- doc/backlog/backlog.md | 3 - .../dataset/design-crowdb-access-dataset.md | 32 +- .../dataset/design-dataset-control.md | 6 +- .../dataset/openapi-crowdb-dataset-v2.yaml | 66 ++- .../dataset/spec-crowdb-dataset.md | 24 +- .../design-crowdb-access-server.md | 19 +- .../s3/design-crowdb-access-s3-rdma.md | 171 ++---- .../console/design-crowdb-console-ui.md | 23 +- .../design-crowdb-diskdb-zone-management.md | 13 +- .../tree/design-crowdb-tree-chunk-storage.md | 13 +- doc/doc_index.md | 2 +- .../console-ui/e2e/fixtures/dataset.ts | 2 +- .../e2e/fixtures/disklessExpansion.ts | 2 +- .../console-ui/e2e/fixtures/resourceMenus.ts | 10 + .../console-ui/e2e/flows/00-shell.spec.ts | 24 +- .../e2e/flows/01-shell-ui-behaviors.spec.ts | 29 +- .../e2e/flows/10-cluster-rack-node.spec.ts | 6 +- .../flows/11-cluster-diskdb-lifecycle.spec.ts | 3 +- .../flows/11-cluster-server-lifecycle.spec.ts | 11 +- .../11-cluster-standalone-lifecycle.spec.ts | 3 +- .../e2e/flows/12-bridge-discovery.spec.ts | 88 +-- .../e2e/flows/12-cluster-discovery.spec.ts | 27 +- .../e2e/flows/20-kv-store-group.spec.ts | 3 +- .../e2e/flows/40-inspector-activity.spec.ts | 5 +- .../e2e/flows/41-canvas-fit-pan.spec.ts | 23 +- .../50-chunk-capacity-disk-group.spec.ts | 21 +- .../e2e/flows/51-chunk-capacity-disk.spec.ts | 58 +- .../flows/51-chunk-capacity-inspector.spec.ts | 37 +- .../e2e/flows/52-chunk-capacity-zone.spec.ts | 28 + .../flows/53-chunk-capacity-canvas.spec.ts | 12 +- .../e2e/flows/53-chunk-ownership.spec.ts | 4 +- .../e2e/flows/60-iceberg-catalog.spec.ts | 39 +- .../console-ui/e2e/flows/70-s3-object.spec.ts | 6 +- .../console-ui/e2e/flows/71-s3-native.spec.ts | 12 +- .../e2e/flows/72-managed-native.spec.ts | 67 ++- .../e2e/flows/80-dataset-browse.spec.ts | 57 +- .../e2e/flows/81-dataset-import.spec.ts | 23 +- .../e2e/flows/82-dataset-splits.spec.ts | 13 +- .../e2e/flows/91-navigation-state.spec.ts | 2 +- .../e2e/flows/92-three-node-data.spec.ts | 19 +- e2e-test/console_native_fixture.py | 15 +- e2e-test/scenarios.py | 1 + e2e-test/tests/console/native_browser.rs | 6 + lib/crowdb-access-dataset/src/authority.rs | 25 +- .../src/authority/access.rs | 15 +- .../src/authority/access/reclaim.rs | 2 +- .../src/authority/dataset_lifecycle.rs | 94 ++++ .../authority/dataset_lifecycle/admission.rs | 95 ++++ .../authority/dataset_lifecycle/emptiness.rs | 88 +++ .../src/authority/dataset_lifecycle/record.rs | 87 +++ .../src/authority/dataset_lifecycle/sweep.rs | 89 +++ .../src/authority/discovery.rs | 16 +- .../src/authority/head.rs | 6 +- .../src/authority/publication/opaque.rs | 2 + .../src/authority/publication/resource.rs | 2 + lib/crowdb-access-dataset/src/lib.rs | 7 +- .../src/split/retirement/admission.rs | 2 +- lib/crowdb-access-dataset/src/upload.rs | 28 +- .../src/upload/expiry/discovery.rs | 3 + .../src/upload/record.rs | 3 + .../tests/common/access_store.rs | 9 + .../tests/composition_resource_test.rs | 3 +- .../tests/dataset_deletion_test.rs | 298 ++++++++++ lib/crowdb-protocol/src/mgmt/node/storage.rs | 17 + .../tests/managed_file_geometry_test.rs | 28 + lib/crowdb-sdk-native/README.md | 9 + lib/crowdb-sdk-native/src/dataset/control.rs | 22 + lib/crowdb-sdk-native/src/dataset/upload.rs | 5 + .../backend/chunk/chunk_manifest_build.cpp | 185 +++++++ .../backend/chunk/chunk_materialization.cpp | 241 ++++++++ .../src/backend/chunk/chunk_pack_pipeline.cpp | 7 +- .../src/backend/chunk/chunk_page_store.cpp | 358 ------------ .../src/backend/chunk/chunk_transport.cpp | 12 + .../src/backend/chunk/chunk_transport.h | 5 +- .../src/backend/chunk/rpc_chunk_allocation.h | 5 +- .../src/backend/chunk/rpc_chunk_growth.cpp | 136 +++++ .../src/backend/chunk/rpc_chunk_transport.cpp | 521 +----------------- .../src/backend/chunk/rpc_chunk_transport.h | 1 + .../chunk/rpc_chunk_transport_internal.h | 494 +++++++++++++++++ .../integration/rpc_chunk_growth_test.cpp | 165 ++++++ .../integration/rpc_chunk_transport_test.cpp | 10 +- tools/cpp-checks/clang-tidy.py | 2 + 168 files changed, 4812 insertions(+), 2282 deletions(-) create mode 100644 app/crowdb-access-server/src/dataset/cleanup.rs create mode 100644 app/crowdb-access-server/tests/dataset_deletion_contract_test.rs create mode 100644 app/crowdb-diskdb/src/recovery/compaction/schedule.rs create mode 100644 app/crowdb-diskdb/tests/compaction_schedule_test.rs create mode 100644 app/crowdb-web/src/services/observation/access.rs create mode 100644 app/crowdb-web/tests/diskdb_binding_test.rs create mode 100644 app/crowdb-web/ui/src/access/resourceMenus.test.tsx create mode 100644 app/crowdb-web/ui/src/components/resourceMenuKey.ts create mode 100644 app/crowdb-web/ui/src/data/useZoneBitmap.test.tsx create mode 100644 app/crowdb-web/ui/src/dataset/DeleteDataset.test.tsx create mode 100644 app/crowdb-web/ui/src/dataset/DeleteDataset.tsx create mode 100644 app/crowdb-web/ui/src/dataset/ResourceMenu.tsx create mode 100644 app/crowdb-web/ui/src/dataset/navigation.test.tsx create mode 100644 app/crowdb-web/ui/src/iceberg/ResourceMenu.tsx create mode 100644 app/crowdb-web/ui/src/iceberg/useCatalogTree.ts create mode 100644 app/crowdb-web/ui/src/s3/ResourceMenu.tsx create mode 100644 app/crowdb-web/ui/src/shell/CandidateProperties.tsx create mode 100644 app/crowdb-web/ui/src/shell/NodeAdmissionProperties.tsx create mode 100644 app/crowdb-web/ui/src/shell/NodeCandidatesContext.tsx delete mode 100644 doc/backlog/R170-s3-cuobject-rdma.md create mode 100644 e2e-test/clients/console-ui/e2e/fixtures/resourceMenus.ts create mode 100644 lib/crowdb-access-dataset/src/authority/dataset_lifecycle.rs create mode 100644 lib/crowdb-access-dataset/src/authority/dataset_lifecycle/admission.rs create mode 100644 lib/crowdb-access-dataset/src/authority/dataset_lifecycle/emptiness.rs create mode 100644 lib/crowdb-access-dataset/src/authority/dataset_lifecycle/record.rs create mode 100644 lib/crowdb-access-dataset/src/authority/dataset_lifecycle/sweep.rs create mode 100644 lib/crowdb-access-dataset/tests/dataset_deletion_test.rs create mode 100644 lib/crowdb-protocol/tests/managed_file_geometry_test.rs create mode 100644 lib/crowdb-tree/src/backend/chunk/chunk_manifest_build.cpp create mode 100644 lib/crowdb-tree/src/backend/chunk/chunk_materialization.cpp create mode 100644 lib/crowdb-tree/src/backend/chunk/rpc_chunk_growth.cpp create mode 100644 lib/crowdb-tree/src/backend/chunk/rpc_chunk_transport_internal.h create mode 100644 lib/crowdb-tree/tests/integration/rpc_chunk_growth_test.cpp diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 40f98579c..50a543345 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -1,7 +1,5 @@ #!/bin/bash -repo_root=$(git rev-parse --show-toplevel) - echo "Running pre-commit checks..." # Check Rust formatting @@ -18,7 +16,7 @@ if ! pixi run cargo fmt --all -- --check; then fi # Collect staged C++ files -staged_cpp=$(git diff --cached --name-only --diff-filter=ACM -- 'lib/crowdb-tree/src/*.cpp' 'lib/crowdb-tree/include/crowdb-tree/*.h') +staged_cpp=$(git diff --cached --name-only --diff-filter=ACM -- 'lib/crowdb-tree/*.cpp' 'lib/crowdb-tree/*.h') # Check C++ formatting (only staged files) if [ -n "$staged_cpp" ]; then @@ -39,63 +37,14 @@ if [ $? -ne 0 ]; then exit 1 fi -# Run clang-tidy (only on staged .cpp files) -staged_cpp_src=$(git diff --cached --name-only --diff-filter=ACM -- 'lib/crowdb-tree/src/*.cpp') -if [ -n "$staged_cpp_src" ] && [ -d lib/crowdb-tree/build ]; then - echo "Running clang-tidy on staged C++ files..." - # Mirror CMake's liburing gate: reactor.cpp, block_async_page_store.cpp, - # and any file that includes reactor.h require liburing headers; on macOS - # (and anywhere liburing is missing) they are excluded from the build, so - # clang-tidy must skip them too. clang-tidy uses clang (not GCC) and can't - # find liburing.h in the pixi env, so skip these files when liburing.h is - # not at a system path. Most crowdb-tree source files transitively include - # reactor.h via crowdb-tree.h → options.h, so when liburing.h is not at a - # system path, skip clang-tidy entirely for crowdb-tree sources. - have_liburing=false - if [ -f /opt/homebrew/include/liburing.h ] || [ -f /usr/local/include/liburing.h ] || [ -f /usr/include/liburing.h ]; then - have_liburing=true - fi - cpp_files=() - for f in $staged_cpp_src; do - case "$f" in - */reactor.cpp | */file_async_page_store.cpp | */block_async_page_store.cpp) - $have_liburing && cpp_files+=("$f") - ;; - *) - if $have_liburing; then - cpp_files+=("$f") - else - # Skip: clang-tidy can't find liburing.h (transitively - # included via crowdb-tree.h → options.h → reactor.h). - echo " Skipping clang-tidy for $f (liburing.h not at system path)" - fi - ;; - esac - done - if [ ${#cpp_files[@]} -gt 0 ]; then - tmpdir="$repo_root/.crowdb-runtime/artifacts/pre-commit/clang-tidy-$$" - mkdir -p "$tmpdir" - fail=0 - for f in "${cpp_files[@]}"; do - base=$(basename "$f") - (pixi run clang-tidy "$f" -p lib/crowdb-tree/build 2>&1 | grep -E '^(error:|warning:|/.*: error:|/.*: warning:)' > "$tmpdir/$base.out") & - done - wait - for f in "${cpp_files[@]}"; do - base=$(basename "$f") - if [ -s "$tmpdir/$base.out" ]; then - cat "$tmpdir/$base.out" - fail=1 - fi - done - rm -rf "$tmpdir" - if [ $fail -ne 0 ]; then - echo "❌ clang-tidy failed. Fix warnings before committing." - exit 1 - fi +# Use the same compile database, dependency include paths and changed-header +# scope as the repository C++ gate. Preserve warnings as commit failures. +if [ -n "$staged_cpp" ]; then + echo "Running tree-lint on staged C++ files..." + if ! CT_LINT_WARNINGS_AS_ERRORS=1 pixi run tree-lint $staged_cpp; then + echo "❌ tree-lint failed. Fix diagnostics before committing." + exit 1 fi -elif [ -n "$staged_cpp_src" ] && [ ! -d lib/crowdb-tree/build ]; then - echo "⚠️ Skipping clang-tidy: lib/crowdb-tree/build not found. Run 'pixi run build' first." fi echo "✅ All checks passed!" diff --git a/app/crowdb-access-server/src/config.rs b/app/crowdb-access-server/src/config.rs index 524b5901e..7607fb693 100644 --- a/app/crowdb-access-server/src/config.rs +++ b/app/crowdb-access-server/src/config.rs @@ -247,6 +247,8 @@ impl std::fmt::Debug for IcebergLocatorSigningConfig { #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(default)] pub struct DatasetConfig { + /// Delay before reclaiming marked Datasets and Snapshots. + pub reclaim_grace_seconds: u64, pub listen: Option, pub max_in_flight: usize, pub max_batch_size: usize, @@ -256,6 +258,7 @@ pub struct DatasetConfig { impl Default for DatasetConfig { fn default() -> Self { Self { + reclaim_grace_seconds: 20 * 60, listen: None, max_in_flight: 8, max_batch_size: 10_000, @@ -425,6 +428,9 @@ impl BaseConfig for AccessConfig { impl AccessConfig { fn validate_dataset(&self) -> Result<(), String> { + if self.dataset.reclaim_grace_seconds == 0 { + return Err("dataset.reclaim_grace_seconds must be positive".into()); + } if self.dataset.max_in_flight == 0 || self.dataset.max_in_flight > 64 { return Err("dataset.max_in_flight must be between 1 and 64".into()); } diff --git a/app/crowdb-access-server/src/dataset.rs b/app/crowdb-access-server/src/dataset.rs index 76c1aeab1..526be7263 100644 --- a/app/crowdb-access-server/src/dataset.rs +++ b/app/crowdb-access-server/src/dataset.rs @@ -2,6 +2,8 @@ //! semantics are delegated to `crowdb-access-dataset`. mod api; +mod cleanup; +use cleanup::start_cleanup; mod legacy; mod models; @@ -11,7 +13,7 @@ use std::time::Duration; use crowdb_access_dataset::{ AuthorityError, ChunkKvDatasetStore, ChunkPayloadStorage, DatasetAuthority, DatasetReadService, - DatasetSnapshotRequest, DatasetUploads, UploadExpiryCursor, UploadedChunkReader, + DatasetSnapshotRequest, DatasetUploads, UploadedChunkReader, }; use crowdb_chunk_kv_client::{ ChunkKvClient, ChunkKvRpcTransport, ClientConfig as ChunkKvConfig, Group0ChunkKvRangeCatalogSource, @@ -91,6 +93,7 @@ fn status_for_authority(error: &AuthorityError) -> StatusCode { match error { AuthorityError::Invalid(_) => StatusCode::BAD_REQUEST, AuthorityError::AlreadyExists + | AuthorityError::DatasetNotEmpty | AuthorityError::ParentConflict | AuthorityError::HeadConflict | AuthorityError::SnapshotHeadConflict { .. } @@ -184,10 +187,10 @@ pub async fn run_with_shutdown( store.clone(), Arc::new(ChunkPayloadStorage::new(chunks.clone())), )); - let authority = Arc::new(DatasetAuthority::with_chunk_reader( - store, - Arc::new(UploadedChunkReader::new((*uploads).clone())), - )); + let authority = Arc::new( + DatasetAuthority::with_chunk_reader(store, Arc::new(UploadedChunkReader::new((*uploads).clone()))) + .with_reclaim_grace_seconds(access.dataset.reclaim_grace_seconds), + ); let identity = crowdb_access_dataset::DatasetIdentity::new( crowdb_access_dataset::NamespacePath::root(), std::env::var("CROWDB_DATASET_NAME").unwrap_or_else(|_| "default".into()), @@ -223,102 +226,6 @@ pub async fn run_with_shutdown( Ok(()) } -fn start_cleanup( - authority: Arc, - uploads: Option>, -) -> tokio::task::JoinHandle<()> { - tokio::spawn(async move { - let mut snapshot_cursor = crowdb_access_dataset::SnapshotReclaimSweep::default(); - let mut next_snapshot = tokio::time::Instant::now(); - let mut candidate_cursor = crowdb_access_dataset::SnapshotExpirySweep::default(); - let mut next_candidate = tokio::time::Instant::now(); - let mut composition_cursor = crowdb_access_dataset::composition::CompositionReclaimSweep::default(); - let mut next_composition = tokio::time::Instant::now(); - let mut split_cursor = crowdb_access_dataset::split::SplitReclaimSweep::default(); - let mut next_split = tokio::time::Instant::now(); - let mut revision_cursor = crowdb_access_dataset::split::SplitRevisionReclaimSweep::default(); - let mut next_revision = tokio::time::Instant::now(); - let mut upload_cursor = UploadExpiryCursor::default(); - let mut next_upload = tokio::time::Instant::now(); - loop { - tokio::select! { - () = tokio::time::sleep_until(next_revision) => { - let result = authority.reclaim_split_revisions_page(&mut revision_cursor, - &crowdb_access_dataset::ReadCancellation::new()).await; - let delay = if revision_cursor.has_more() { - if result.is_ok() { Duration::from_millis(50) } else { Duration::from_millis(500) } - } else { Duration::from_secs(15) }; - next_revision = tokio::time::Instant::now() + delay; - if let Err(error) = result { - tracing::error!(%error, "Dataset Split revision cleanup failed; deletion marks retained for retry"); - } - } - () = tokio::time::sleep_until(next_candidate) => { - let result = authority.expire_snapshot_candidates_page(&mut candidate_cursor, - &crowdb_access_dataset::ReadCancellation::new()).await; - let delay = if candidate_cursor.has_more() { - if result.is_ok() { Duration::from_millis(50) } else { Duration::from_millis(500) } - } else { Duration::from_secs(15) }; - next_candidate = tokio::time::Instant::now() + delay; - if let Err(error) = result { - tracing::error!(%error, "Dataset candidate expiry failed; resource deadlines retained for retry"); - } - } - () = tokio::time::sleep_until(next_split) => { - let result = authority.reclaim_splits_page(&mut split_cursor, - &crowdb_access_dataset::ReadCancellation::new()).await; - let delay = if split_cursor.has_more() { - if result.is_ok() { Duration::from_millis(50) } else { Duration::from_millis(500) } - } else { Duration::from_secs(15) }; - next_split = tokio::time::Instant::now() + delay; - if let Err(error) = result { - tracing::error!(%error, "Dataset legacy Split cleanup failed; resource facts retained for retry"); - } - } - () = tokio::time::sleep_until(next_composition), if uploads.is_some() => { - let Some(uploads) = &uploads else { continue }; - let result = authority.reclaim_compositions_page(uploads, &mut composition_cursor, - &crowdb_access_dataset::ReadCancellation::new()).await; - let delay = if composition_cursor.has_more() { - if result.is_ok() { Duration::from_millis(50) } else { Duration::from_millis(500) } - } else { Duration::from_secs(15) }; - next_composition = tokio::time::Instant::now() + delay; - if let Err(error) = result { - tracing::error!(%error, "Dataset legacy Composition cleanup failed; resource facts retained for retry"); - } - } - () = tokio::time::sleep_until(next_snapshot), if uploads.is_some() => { - let Some(uploads) = &uploads else { continue }; - let result = authority.reclaim_due_page(uploads, &mut snapshot_cursor, - &crowdb_access_dataset::ReadCancellation::new()).await; - let delay = if snapshot_cursor.has_more() { - if result.is_ok() { Duration::from_millis(50) } else { Duration::from_millis(500) } - } else { Duration::from_secs(15) }; - next_snapshot = tokio::time::Instant::now() + delay; - if let Err(error) = result { - if matches!(error, crowdb_access_dataset::SnapshotReclaimError::Limit) { - tracing::debug!("Dataset snapshot cleanup yielded at its work deadline"); - } else { - tracing::error!(%error, "Dataset snapshot cleanup failed; durable deletion marks retained for retry"); - } - } - } - () = tokio::time::sleep_until(next_upload), if uploads.is_some() => { - let Some(uploads) = &uploads else { continue }; - let result = uploads.expire_step(unix_seconds(), &mut upload_cursor).await; - let delay = if upload_cursor.has_more() { - if result.is_ok() { Duration::from_millis(50) } else { Duration::from_millis(500) } - } else { Duration::from_secs(15) }; - next_upload = tokio::time::Instant::now() + delay; - if let Err(error) = result { - tracing::error!(%error, "Dataset upload cleanup failed; retaining cleanup records for retry"); - } - } - } - } - }) -} - /// Starts the real bounded cleanup loop without opening a listener. #[cfg(feature = "test-util")] #[must_use] diff --git a/app/crowdb-access-server/src/dataset/api/error.rs b/app/crowdb-access-server/src/dataset/api/error.rs index 8c721fc70..f8c5cb6f3 100644 --- a/app/crowdb-access-server/src/dataset/api/error.rs +++ b/app/crowdb-access-server/src/dataset/api/error.rs @@ -96,6 +96,9 @@ impl From for ApiError { } impl From for ApiError { fn from(error: AuthorityError) -> Self { + if matches!(error, AuthorityError::DatasetNotEmpty) { + return Self::new(StatusCode::CONFLICT, "dataset_not_empty"); + } if let AuthorityError::SnapshotHeadConflict { observed } = error { return Self::conflict().with_observed_head(serde_json::json!({ "latest":observed.latest.map(|id|id.to_string()), diff --git a/app/crowdb-access-server/src/dataset/api/upload.rs b/app/crowdb-access-server/src/dataset/api/upload.rs index 80f212966..9e6aa94d9 100644 --- a/app/crowdb-access-server/src/dataset/api/upload.rs +++ b/app/crowdb-access-server/src/dataset/api/upload.rs @@ -43,8 +43,13 @@ impl DatasetHttpService { match (request.method().clone(), tail) { (Method::POST, ["uploads"]) => { let input: UploadRequest = read_json(request).await?; - let location = DatasetUploads::location(identity, operation(&input.id)?)?; let md5 = digest(&input.md5)?; + DatasetUploads::validate_creation(input.length, input.part_size)?; + let location = DatasetUploads::location(identity, operation(&input.id)?)?; + self.reads + .authority() + .reserve_upload_creation(identity, operation(&input.id)?) + .await?; let record = uploads .create( &location, diff --git a/app/crowdb-access-server/src/dataset/api/v2.rs b/app/crowdb-access-server/src/dataset/api/v2.rs index 1f9014cfe..df03d9c1f 100644 --- a/app/crowdb-access-server/src/dataset/api/v2.rs +++ b/app/crowdb-access-server/src/dataset/api/v2.rs @@ -48,6 +48,7 @@ impl DatasetHttpService { "bounded-inspection", "inspection-preparation", "dataset-management", + "dataset-delete", "split-revisions", "split-revision-delete", "split-preparation", @@ -76,7 +77,7 @@ impl DatasetHttpService { if matches!( tail.as_slice(), [] | ["names", "stable"] - | ["commits", _] + | ["deletion" | "commits", _] | ["snapshots", _, "retain" | "release" | "reclaim"] | ["split-drafts", _, "cleanup"] ) { diff --git a/app/crowdb-access-server/src/dataset/api/v2/auxiliary.rs b/app/crowdb-access-server/src/dataset/api/v2/auxiliary.rs index dcfa8fa71..cb902e4a1 100644 --- a/app/crowdb-access-server/src/dataset/api/v2/auxiliary.rs +++ b/app/crowdb-access-server/src/dataset/api/v2/auxiliary.rs @@ -14,6 +14,18 @@ use serde::Deserialize; struct Stable { snapshot: String, } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct DeleteDataset { + id: String, +} +fn dataset_id(value: &str) -> Result { + crowdb_access_dataset::DatasetId::from_bytes(operation(value)?.as_bytes()) + .map_err(|_| ApiError::invalid()) +} +fn deletion(value: &crowdb_access_dataset::DatasetDeletion) -> serde_json::Value { + serde_json::json!({"id":value.id.to_string(), "reclaim_after":value.reclaim_after.to_string(), "complete":value.complete}) +} impl DatasetHttpService { pub(super) async fn v2_auxiliary( &self, @@ -24,6 +36,19 @@ impl DatasetHttpService { Query::parse(request.uri().query(), &[])?; let authority = self.reads.authority(); match (request.method().clone(), tail) { + (Method::DELETE, []) => { + let input: DeleteDataset = read_json(request).await?; + let result = authority + .delete_dataset(identity, dataset_id(&input.id)?, &ReadCancellation::new()) + .await?; + return json(&deletion(&result)); + } + (Method::GET, ["deletion", id]) => { + read_body(request, false).await?; + return json( + &serde_json::json!({"deletion":authority.dataset_deletion(identity, dataset_id(id)?).await?.as_ref().map(deletion)}), + ); + } (Method::PUT, []) => { read_body(request, false).await?; authority.create_dataset(identity.clone()).await?; diff --git a/app/crowdb-access-server/src/dataset/api/v2/uploads.rs b/app/crowdb-access-server/src/dataset/api/v2/uploads.rs index b75af7594..19d88cc56 100644 --- a/app/crowdb-access-server/src/dataset/api/v2/uploads.rs +++ b/app/crowdb-access-server/src/dataset/api/v2/uploads.rs @@ -31,15 +31,16 @@ impl DatasetHttpService { let now = crate::dataset::unix_seconds(); if tail == ["uploads"] && request.method() == Method::POST { let input: Create = read_json(request).await?; + let length = decimal(&input.length)?; + let md5 = digest(&input.md5)?; + DatasetUploads::validate_creation(length, input.part_size)?; let location = DatasetUploads::location(identity, operation(&input.id)?)?; + self.reads + .authority() + .reserve_upload_creation(identity, operation(&input.id)?) + .await?; let record = uploads - .create( - &location, - decimal(&input.length)?, - input.part_size, - digest(&input.md5)?, - now.saturating_add(86400), - ) + .create(&location, length, input.part_size, md5, now.saturating_add(86400)) .await?; return json(&wire(&input.id, record)); } diff --git a/app/crowdb-access-server/src/dataset/cleanup.rs b/app/crowdb-access-server/src/dataset/cleanup.rs new file mode 100644 index 000000000..9e59cfb33 --- /dev/null +++ b/app/crowdb-access-server/src/dataset/cleanup.rs @@ -0,0 +1,110 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. +use super::{unix_seconds, DatasetAuthority, DatasetUploads}; +use crowdb_access_dataset::UploadExpiryCursor; +use std::{sync::Arc, time::Duration}; + +fn next_delay(more: bool, success: bool, idle: Duration) -> Duration { + if more { + Duration::from_millis(if success { 50 } else { 500 }) + } else { + idle + } +} + +pub(super) fn start_cleanup( + authority: Arc, + uploads: Option>, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let reclaim_poll = Duration::from_secs(authority.reclaim_grace_seconds().clamp(1, 15)); + let mut dataset_cursor = crowdb_access_dataset::DatasetDeletionSweep::default(); + let mut next_dataset = tokio::time::Instant::now(); + let mut snapshot_cursor = crowdb_access_dataset::SnapshotReclaimSweep::default(); + let mut next_snapshot = tokio::time::Instant::now(); + let mut candidate_cursor = crowdb_access_dataset::SnapshotExpirySweep::default(); + let mut next_candidate = tokio::time::Instant::now(); + let mut composition_cursor = crowdb_access_dataset::composition::CompositionReclaimSweep::default(); + let mut next_composition = tokio::time::Instant::now(); + let mut split_cursor = crowdb_access_dataset::split::SplitReclaimSweep::default(); + let mut next_split = tokio::time::Instant::now(); + let mut revision_cursor = crowdb_access_dataset::split::SplitRevisionReclaimSweep::default(); + let mut next_revision = tokio::time::Instant::now(); + let mut upload_cursor = UploadExpiryCursor::default(); + let mut next_upload = tokio::time::Instant::now(); + loop { + tokio::select! { + () = tokio::time::sleep_until(next_dataset) => { + let result = authority.reclaim_datasets_page(&mut dataset_cursor, + &crowdb_access_dataset::ReadCancellation::new()).await; + let delay = next_delay(dataset_cursor.has_more(), result.is_ok(), reclaim_poll); + next_dataset = tokio::time::Instant::now() + delay; + if let Err(error) = result { + tracing::error!(%error, "Dataset namespace cleanup failed; durable deletion marks retained for retry"); + } + } + + () = tokio::time::sleep_until(next_revision) => { + let result = authority.reclaim_split_revisions_page(&mut revision_cursor, + &crowdb_access_dataset::ReadCancellation::new()).await; + let delay = next_delay(revision_cursor.has_more(), result.is_ok(), reclaim_poll); + next_revision = tokio::time::Instant::now() + delay; + if let Err(error) = result { + tracing::error!(%error, "Dataset Split revision cleanup failed; deletion marks retained for retry"); + } + } + () = tokio::time::sleep_until(next_candidate) => { + let result = authority.expire_snapshot_candidates_page(&mut candidate_cursor, + &crowdb_access_dataset::ReadCancellation::new()).await; + let delay = next_delay(candidate_cursor.has_more(), result.is_ok(), Duration::from_secs(15)); + next_candidate = tokio::time::Instant::now() + delay; + if let Err(error) = result { + tracing::error!(%error, "Dataset candidate expiry failed; resource deadlines retained for retry"); + } + } + () = tokio::time::sleep_until(next_split) => { + let result = authority.reclaim_splits_page(&mut split_cursor, + &crowdb_access_dataset::ReadCancellation::new()).await; + let delay = next_delay(split_cursor.has_more(), result.is_ok(), Duration::from_secs(15)); + next_split = tokio::time::Instant::now() + delay; + if let Err(error) = result { + tracing::error!(%error, "Dataset legacy Split cleanup failed; resource facts retained for retry"); + } + } + () = tokio::time::sleep_until(next_composition), if uploads.is_some() => { + let Some(uploads) = &uploads else { continue }; + let result = authority.reclaim_compositions_page(uploads, &mut composition_cursor, + &crowdb_access_dataset::ReadCancellation::new()).await; + let delay = next_delay(composition_cursor.has_more(), result.is_ok(), Duration::from_secs(15)); + next_composition = tokio::time::Instant::now() + delay; + if let Err(error) = result { + tracing::error!(%error, "Dataset legacy Composition cleanup failed; resource facts retained for retry"); + } + } + () = tokio::time::sleep_until(next_snapshot), if uploads.is_some() => { + let Some(uploads) = &uploads else { continue }; + let result = authority.reclaim_due_page(uploads, &mut snapshot_cursor, + &crowdb_access_dataset::ReadCancellation::new()).await; + let delay = next_delay(snapshot_cursor.has_more(), result.is_ok(), reclaim_poll); + next_snapshot = tokio::time::Instant::now() + delay; + if let Err(error) = result { + if matches!(error, crowdb_access_dataset::SnapshotReclaimError::Limit) { + tracing::debug!("Dataset snapshot cleanup yielded at its work deadline"); + } else { + tracing::error!(%error, "Dataset snapshot cleanup failed; durable deletion marks retained for retry"); + } + } + } + () = tokio::time::sleep_until(next_upload), if uploads.is_some() => { + let Some(uploads) = &uploads else { continue }; + let result = uploads.expire_step(unix_seconds(), &mut upload_cursor).await; + let delay = next_delay(upload_cursor.has_more(), result.is_ok(), Duration::from_secs(15)); + next_upload = tokio::time::Instant::now() + delay; + if let Err(error) = result { + tracing::error!(%error, "Dataset upload cleanup failed; retaining cleanup records for retry"); + } + } + } + } + }) +} diff --git a/app/crowdb-access-server/tests/config_test.rs b/app/crowdb-access-server/tests/config_test.rs index 5cd0a826b..4c7d5558c 100644 --- a/app/crowdb-access-server/tests/config_test.rs +++ b/app/crowdb-access-server/tests/config_test.rs @@ -218,3 +218,13 @@ fn local_one_and_three_node_mirrors_are_explicit_and_never_relax_production() { ); } } + +#[test] +fn dataset_reclaim_grace_is_configurable() { + let mut config = crowdb_access_server::config::AccessConfig::default(); + assert_eq!(config.dataset.reclaim_grace_seconds, 1200); + config.dataset.reclaim_grace_seconds = 1; + config.validate().unwrap(); + config.dataset.reclaim_grace_seconds = 0; + assert!(config.validate().unwrap_err().contains("reclaim_grace_seconds")); +} diff --git a/app/crowdb-access-server/tests/dataset_composition_preparation_test.rs b/app/crowdb-access-server/tests/dataset_composition_preparation_test.rs index 7f5311cd4..21f1e2e20 100644 --- a/app/crowdb-access-server/tests/dataset_composition_preparation_test.rs +++ b/app/crowdb-access-server/tests/dataset_composition_preparation_test.rs @@ -41,7 +41,8 @@ async fn complete_resource_reuses_the_snapshot_id_across_restart_without_advanci let before = counts(&store); let prepared = response(client.put(&url).json(&input()), 200).await; let after = counts(&store); - assert_eq!((after.0 - before.0, after.1 - before.1), (12, 16)); + // Creation reserves the Dataset root: two admission reads and one root CAS. + assert_eq!((after.0 - before.0, after.1 - before.1), (14, 17)); assert_eq!(prepared["snapshot"], id); assert_eq!(prepared["sample_count"], "1"); assert!(prepared["initial_split"].is_string()); diff --git a/app/crowdb-access-server/tests/dataset_deletion_contract_test.rs b/app/crowdb-access-server/tests/dataset_deletion_contract_test.rs new file mode 100644 index 000000000..fb3715fdc --- /dev/null +++ b/app/crowdb-access-server/tests/dataset_deletion_contract_test.rs @@ -0,0 +1,126 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. +#[path = "common/dataset.rs"] +mod dataset; +use crowdb_access_dataset::*; +use dataset::{TestDatasetHttp, TestStore}; +use reqwest::{Client, StatusCode}; +use serde_json::{json, Value}; +use std::sync::Arc; + +#[tokio::test] +async fn http_delete_hides_dataset_and_retries_preserve_receipt() { + let service = TestDatasetHttp::start_payload(Arc::new(TestStore::default())).await; + let client = Client::new(); + let url = format!( + "{}/api/dataset/v2/namespaces/dataset-ns/datasets/empty", + service.origin + ); + let created: Value = client + .put(&url) + .bearer_auth("secret") + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json() + .await + .unwrap(); + let id = created["id"].as_str().unwrap(); + // Rejected input must not leave a durable reservation that makes an empty Dataset nonempty. + for (length, part_size, md5) in [ + ("0", 4, "1".repeat(32)), + ("4", 0, "1".repeat(32)), + ("4", 4, "bad".into()), + ] { + let invalid = client.post(format!("{url}/uploads")).bearer_auth("secret") + .json(&json!({"id": OperationId::random().to_string(), "length":length, "part_size":part_size, "md5":md5})) + .send().await.unwrap(); + assert_eq!(invalid.status(), StatusCode::BAD_REQUEST); + } + let response = client + .delete(&url) + .bearer_auth("secret") + .json(&json!({"id":id})) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let mark: Value = response.json().await.unwrap(); + assert_eq!(mark["reclaim_after"], "2200"); + assert_eq!( + client + .get(&url) + .bearer_auth("secret") + .send() + .await + .unwrap() + .status(), + StatusCode::NOT_FOUND + ); + let list: Value = client + .get(url.rsplit_once('/').unwrap().0) + .bearer_auth("secret") + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(list["entries"], json!([])); + let retry: Value = client + .delete(&url) + .bearer_auth("secret") + .json(&json!({"id":id})) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(retry, mark); + let status: Value = client + .get(format!("{url}/deletion/{id}")) + .bearer_auth("secret") + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(status["deletion"], mark); + drop(client); + service.stop().await; +} + +#[tokio::test] +async fn real_background_worker_obeys_one_second_grace() { + let store = Arc::new(TestStore::default()); + let authority = Arc::new(DatasetAuthority::new(store).with_reclaim_grace_seconds(1)); + let name = DatasetIdentity::new(NamespacePath::root(), "short-grace").unwrap(); + let id = authority.create_dataset(name.clone()).await.unwrap(); + let cancel = ReadCancellation::new(); + let mark = authority.delete_dataset(&name, id, &cancel).await.unwrap(); + assert!(!mark.complete); + let worker = crowdb_access_server::dataset::start_dataset_cleanup_for_tests(authority.clone(), None); + tokio::time::timeout(std::time::Duration::from_secs(3), async { + loop { + if authority + .dataset_deletion(&name, id) + .await + .unwrap() + .unwrap() + .complete + { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + }) + .await + .unwrap(); + worker.abort(); + let _ = worker.await; + authority.create_dataset(name).await.unwrap(); +} diff --git a/app/crowdb-access-server/tests/dataset_publication_cost_test.rs b/app/crowdb-access-server/tests/dataset_publication_cost_test.rs index 85617fc09..5cabdf541 100644 --- a/app/crowdb-access-server/tests/dataset_publication_cost_test.rs +++ b/app/crowdb-access-server/tests/dataset_publication_cost_test.rs @@ -67,7 +67,8 @@ async fn inline_publication_and_identical_replay_have_bounded_metadata_cost() { let after = counts(&store); assert_eq!( (after.0 - before.0, after.1 - before.1), - (12, 11 + 3 * u64::from(samples)) + // Dataset deletion admission adds two reads and one root CAS. + (14, 12 + 3 * u64::from(samples)) ); println!( "cold publication: {} reads, {} mutations", diff --git a/app/crowdb-access-server/tests/dataset_v2_contract_test.rs b/app/crowdb-access-server/tests/dataset_v2_contract_test.rs index f6fed5b2b..b2ee6e163 100644 --- a/app/crowdb-access-server/tests/dataset_v2_contract_test.rs +++ b/app/crowdb-access-server/tests/dataset_v2_contract_test.rs @@ -38,6 +38,7 @@ async fn capabilities_use_existing_auth_and_match_configured_operations() { "bounded-inspection", "inspection-preparation", "dataset-management", + "dataset-delete", "split-revisions", "split-revision-delete", "split-preparation", diff --git a/app/crowdb-diskdb/Cargo.toml b/app/crowdb-diskdb/Cargo.toml index 55a33a79b..3f0451ab8 100644 --- a/app/crowdb-diskdb/Cargo.toml +++ b/app/crowdb-diskdb/Cargo.toml @@ -163,3 +163,7 @@ path = "tests/retained_recovery_test.rs" [[test]] name = "online_activation_test" path = "tests/online_activation_test.rs" + +[[test]] +name = "compaction_schedule_test" +path = "tests/compaction_schedule_test.rs" diff --git a/app/crowdb-diskdb/src/recovery/compaction.rs b/app/crowdb-diskdb/src/recovery/compaction.rs index 728a4f266..a7285d074 100644 --- a/app/crowdb-diskdb/src/recovery/compaction.rs +++ b/app/crowdb-diskdb/src/recovery/compaction.rs @@ -4,9 +4,9 @@ //! Snapshot compaction engine (R73 strategy 3) + preparatory thread. //! //! `CompactionEngine` runs as a background task, periodically -//! compacting zones whose `uncompacted_free_record_count` exceeds the -//! threshold. `PreparatoryThread` pre-compacts the next batch of zones -//! in the rotation order so rotation is instant. Both call the shared +//! compacting non-active zones when their free-record backlog reaches the +//! threshold or the full sweep cadence expires. `PreparatoryThread` pre-compacts +//! the next batch of zones in the rotation order so rotation is instant. Both call the shared //! `compact_zone` function. //! //! Crash safety: the `ZoneValue` snapshot + free-record deletes are one @@ -29,6 +29,10 @@ use crate::model::records::ZoneRecords; use crate::model::zone::DdbZone; use crate::recovery::ZoneLoadError; +mod schedule; +#[cfg(feature = "test-util")] +pub use schedule::CompactionSchedule as CompactionScheduleForTests; + /// RAII guard that clears the `compacting` flag on drop. Ensures the /// flag is cleared even on panic or early return (HIGH-5). struct CompactingGuard<'a>(&'a AtomicBool); @@ -209,10 +213,11 @@ pub fn matching_free_records_for_tests( pub struct CompactionEngine { kv: Arc, config: CompactionConfig, + started: std::time::Instant, + schedule: schedule::CompactionSchedule, /// Optional shared config handle for live-apply of the timer - /// cadence. When set, `trigger()` returns `TimerFn` reading - /// `persistence.compaction_cadence_secs` from this handle each - /// tick; when `None`, falls back to the fixed snapshot. + /// cadence. Polling checks the threshold at most every five seconds; + /// the independent sweep deadline comes from the current persistence config. config_handle: Option>>, } @@ -223,6 +228,8 @@ impl CompactionEngine { Self { kv, config, + started: std::time::Instant::now(), + schedule: schedule::CompactionSchedule::default(), config_handle: None, } } @@ -312,7 +319,13 @@ impl CompactionEngine { impl BackgroundTask for CompactionEngine { fn run_cycle<'a>(&'a self, ctx: &'a BgCtx) -> CycleFut<'a> { Box::pin(async move { - let threshold = ctx.config.load().persistence.snapshot_compaction_threshold; + let config = ctx.config.load(); + let threshold = self.schedule.threshold( + self.started.elapsed().as_secs(), + u64::from(config.persistence.compaction_cadence_secs), + config.persistence.snapshot_compaction_threshold, + ); + drop(config); self.compaction_cycle(&ctx.container, threshold, &ctx.metrics) .await; Ok(()) @@ -325,11 +338,15 @@ impl BackgroundTask for CompactionEngine { let handle = Arc::clone(handle); Trigger::TimerFn(Box::new(move || { std::time::Duration::from_secs(u64::from( - handle.load().persistence.compaction_cadence_secs, + handle.load().persistence.compaction_cadence_secs.min(5), )) })) } - None => Trigger::Timer(self.config.compaction_cadence), + None => Trigger::Timer( + self.config + .compaction_cadence + .min(std::time::Duration::from_secs(5)), + ), } } diff --git a/app/crowdb-diskdb/src/recovery/compaction/schedule.rs b/app/crowdb-diskdb/src/recovery/compaction/schedule.rs new file mode 100644 index 000000000..632c14c18 --- /dev/null +++ b/app/crowdb-diskdb/src/recovery/compaction/schedule.rs @@ -0,0 +1,22 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +use std::sync::atomic::{AtomicU64, Ordering}; + +/// Threshold polls must not postpone the periodic sweep of small backlogs. +#[derive(Default)] +pub struct CompactionSchedule { + last_sweep_seconds: AtomicU64, +} + +impl CompactionSchedule { + pub fn threshold(&self, elapsed_seconds: u64, cadence_seconds: u64, threshold: u32) -> u32 { + let last = self.last_sweep_seconds.load(Ordering::Acquire); + if elapsed_seconds.saturating_sub(last) >= cadence_seconds { + self.last_sweep_seconds.store(elapsed_seconds, Ordering::Release); + 1 + } else { + threshold.max(1) + } + } +} diff --git a/app/crowdb-diskdb/tests/compaction_schedule_test.rs b/app/crowdb-diskdb/tests/compaction_schedule_test.rs new file mode 100644 index 000000000..cd4716c72 --- /dev/null +++ b/app/crowdb-diskdb/tests/compaction_schedule_test.rs @@ -0,0 +1,27 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +use crowdb_diskdb::recovery::compaction::CompactionScheduleForTests; + +#[test] +fn frequent_threshold_polls_do_not_starve_small_zone_backlogs() { + let schedule = CompactionScheduleForTests::default(); + for elapsed in (5..300).step_by(5) { + assert_eq!(schedule.threshold(elapsed, 300, 4096), 4096); + } + // A 256-block zone cannot accumulate 4096 distinct frees. The deadline + // must still admit its smallest nonempty backlog to the compaction pass. + assert_eq!(schedule.threshold(300, 300, 4096), 1); + assert_eq!(schedule.threshold(305, 300, 4096), 4096); + assert_eq!(schedule.threshold(600, 300, 4096), 1); +} + +#[test] +fn live_cadence_changes_and_delayed_wakeups_keep_periodic_cleanup() { + let schedule = CompactionScheduleForTests::default(); + assert_eq!(schedule.threshold(20, 300, 64), 64); + assert_eq!(schedule.threshold(30, 30, 64), 1); + assert_eq!(schedule.threshold(1000, 30, 64), 1); + assert_eq!(schedule.threshold(1005, 60, 128), 128); + assert_eq!(schedule.threshold(1060, 60, 128), 1); +} diff --git a/app/crowdb-diskdb/tests/config_reload_test.rs b/app/crowdb-diskdb/tests/config_reload_test.rs index 04103c4da..95d3abc3a 100644 --- a/app/crowdb-diskdb/tests/config_reload_test.rs +++ b/app/crowdb-diskdb/tests/config_reload_test.rs @@ -100,12 +100,12 @@ fn compaction_engine_trigger_uses_timer_fn_when_config_handle_set() { let engine = CompactionEngine::new(Arc::clone(&ddb_kv), cfg).with_config_handle(Arc::clone(&handle)); match engine.trigger() { Trigger::TimerFn(f) => { - assert_eq!(f(), Duration::from_secs(30)); + assert_eq!(f(), Duration::from_secs(5)); // Simulate a config reload. let mut new_config = DdbConfig::default(); - new_config.persistence.compaction_cadence_secs = 15; + new_config.persistence.compaction_cadence_secs = 2; handle.store(Arc::new(new_config)); - assert_eq!(f(), Duration::from_secs(15)); + assert_eq!(f(), Duration::from_secs(2)); } _other => panic!("expected TimerFn"), } @@ -123,7 +123,7 @@ fn compaction_engine_trigger_uses_fixed_timer_without_config_handle() { }; let engine = CompactionEngine::new(Arc::clone(&ddb_kv), cfg); match engine.trigger() { - Trigger::Timer(d) => assert_eq!(d, Duration::from_secs(120)), + Trigger::Timer(d) => assert_eq!(d, Duration::from_secs(5)), _other => panic!("expected Timer"), } } diff --git a/app/crowdb-web/src/diskdb/proxy.rs b/app/crowdb-web/src/diskdb/proxy.rs index f0ca07056..63097731b 100644 --- a/app/crowdb-web/src/diskdb/proxy.rs +++ b/app/crowdb-web/src/diskdb/proxy.rs @@ -24,7 +24,7 @@ use crowdb_protocol::diskdb::rpc::{ }; use crate::error::{err_400, err_404, err_502, ErrorBody}; -use crate::mgmt::{build_hardware_client, rpc_endpoint_for_node}; +use crate::mgmt::build_hardware_client; use crate::state::AppState; // ── lazy DiskdbClient init ─────────────────────────────────────── @@ -33,35 +33,9 @@ use crate::state::AppState; /// `build_hardware_client`). Returns `None` when no group-0 endpoint /// is known. pub(crate) async fn build_diskdb_client(state: &AppState) -> Option { - let snap = state.monitor_cache.snapshot().await; - if snap.is_empty() && !state.managed_mode { - return None; - } - // Use the shared kv_client so topology discovery seeds are - // consistent with the rest of the web server. Creating a standalone - // client with empty seeds here caused "no seeds configured" errors - // when refresh_endpoints triggered a topology refresh. - let kv = state.kv_client().await; - // Prefer the live group-0 leader endpoint from the monitor cache - // (the actual crowdb-rpc listener for store 0 on the leader node). - // Fall back to per-node rpc_endpoint_for_node for any running node. - if let Some(ep) = state.monitor_cache.group0_leader_endpoint().await { - let bare = crate::mgmt::strip_scheme(crate::mgmt::remap_zero_host(&ep)); - kv.seed_leader(0, 0, bare); - } else { - for node_id in snap.keys() { - // Skip stopped servers — no runtime pid means the process is - // not running. Contacting it only wastes time on - // connection-refused. - if state.runtime_pid(*node_id).is_none() { - continue; - } - if let Some(ep) = rpc_endpoint_for_node(state, *node_id, 0).await { - kv.seed_leader(0, 0, ep); - break; - } - } - } + // Resolve the same authority as capacity inventory. Admitted services may + // be remote and absent from the legacy process monitor cache. + build_hardware_client(state).await?; // Use the shared ServiceDiscoveryClient's ServiceRegistryClient so // the discovery cache + connection pool are shared with the rest // of the web server (R128). diff --git a/app/crowdb-web/src/lib.rs b/app/crowdb-web/src/lib.rs index 8cdd235ef..47522a48b 100644 --- a/app/crowdb-web/src/lib.rs +++ b/app/crowdb-web/src/lib.rs @@ -33,6 +33,8 @@ mod standalone; pub mod state; pub use node::observation::{start_node_observer, NodeObserver}; +#[cfg(feature = "test-util")] +pub use services::observation::remote_access_health_for_tests; pub use state::AppState; #[cfg(feature = "test-util")] diff --git a/app/crowdb-web/src/node/auto_join.rs b/app/crowdb-web/src/node/auto_join.rs index 9e7bfd3b9..22ad23963 100644 --- a/app/crowdb-web/src/node/auto_join.rs +++ b/app/crowdb-web/src/node/auto_join.rs @@ -6,7 +6,7 @@ mod formation; mod runner; -use std::fs; +use std::{collections::BTreeMap, fs}; use axum::{extract::State, http::StatusCode, Json}; use serde::{Deserialize, Serialize}; @@ -28,6 +28,8 @@ struct Policy { target_cluster_id: Option, #[serde(default)] local_draft_selected: bool, + #[serde(default)] + candidates: BTreeMap, } impl Default for Policy { @@ -37,6 +39,7 @@ impl Default for Policy { enabled: true, target_cluster_id: None, local_draft_selected: false, + candidates: BTreeMap::new(), } } } @@ -45,16 +48,10 @@ fn read(state: &AppState) -> Result { let path = state.runtime_root.join("auto-join.json"); let metadata = match fs::symlink_metadata(&path) { Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - return Ok(Policy { - enabled: deployment(state)?.mode - == crowdb_protocol::mgmt::node::deployment::DeploymentMode::Local, - ..Policy::default() - }) - } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Policy::default()), Err(error) => return Err(err_502(error.to_string())), }; - if !metadata.is_file() || metadata.len() > 4096 { + if !metadata.is_file() || metadata.len() > 65536 { return Err(err_409("Invalid Auto join policy")); } let policy: Policy = serde_json::from_slice(&fs::read(path).map_err(|error| err_502(error.to_string()))?) @@ -105,7 +102,7 @@ pub(crate) async fn status(State(state): State) -> Result, Err(error) => return Err(err_502(error.to_string())), }; Ok(Json( - json!({"enabled": policy.enabled, "target_cluster_id": policy.target_cluster_id, + json!({"enabled": policy.enabled, "candidates": policy.candidates, "target_cluster_id": policy.target_cluster_id, "mode": "standard", "deployment_mode": deployment.mode, "cluster": deployment.cluster, "required_voters": deployment.initial_voters, "observation": observation}), )) } @@ -115,6 +112,7 @@ pub(crate) async fn status(State(state): State) -> Result, pub(crate) struct Update { enabled: bool, target: Option, + discovery_id: Option, } #[derive(Deserialize)] @@ -132,7 +130,7 @@ pub(crate) async fn update( return Err(err_400("Workspace is readonly")); } let mut resources = vec!["node/auto-join".into()]; - if body.target.is_some() { + if body.target.is_some() || body.discovery_id.is_some() { resources.extend(["cluster/init".into(), "node/admission".into()]); } let _operation = crate::services::Operation::claim(&state, resources)?; @@ -162,7 +160,38 @@ pub(crate) async fn update( policy.target_cluster_id = None; policy.local_draft_selected = true; } - policy.enabled = body.enabled; + if let Some(id) = body.discovery_id { + if body.target.is_some() || uuid::Uuid::parse_str(&id).is_err() { + return Err(err_400( + "Candidate Auto join requires a discovery UUID and no target override", + )); + } + let snapshot = super::candidates(State(state.clone())) + .await + .map_err(|_| err_502("Discovery unavailable"))? + .0; + let candidate = snapshot + .candidates + .iter() + .find(|node| node.advertisement.discovery_id == id) + .ok_or_else(|| err_409("Candidate is no longer discovered"))?; + if candidate.state != crowdb_protocol::mgmt::node::CandidateState::Unbound { + return Err(err_409("Auto join can only be changed for an unbound candidate")); + } + if body.enabled + && candidate.advertisement.formation.as_ref() != Some(&deployment(&state)?.formation()) + { + return Err(err_409( + "Candidate formation differs from this cluster; review admission manually", + )); + } + if policy.candidates.len() >= 256 && !policy.candidates.contains_key(&id) { + return Err(err_409("Candidate Auto join policy is full")); + } + policy.candidates.insert(id, body.enabled); + } else { + policy.enabled = body.enabled; + } super::save(&state.runtime_root.join("auto-join.json"), &policy)?; status(State(state.clone())).await } diff --git a/app/crowdb-web/src/node/auto_join/runner.rs b/app/crowdb-web/src/node/auto_join/runner.rs index f83841a06..ab4ae78e5 100644 --- a/app/crowdb-web/src/node/auto_join/runner.rs +++ b/app/crowdb-web/src/node/auto_join/runner.rs @@ -57,7 +57,7 @@ async fn step( node::save(&state.runtime_root.join("auto-join.json"), &policy)?; } } - if !policy.enabled { + if !policy.enabled && !policy.candidates.values().any(|enabled| *enabled) { return Ok(json!({"phase": "disabled"})); } let authority = node::status(State(state.clone())).await.0; @@ -93,9 +93,7 @@ async fn admit_candidates( attempts: &mut BTreeMap, bound: bool, ) -> Result { - if bound { - return finish(state, true, Vec::new(), Vec::new()).await; - } + let policy = super::read(state)?; let deployment = super::deployment(state)?; let snapshot = node::candidates(State(state.clone())) .await @@ -108,9 +106,37 @@ async fn admit_candidates( ) })? .0; - let selected = super::formation::candidates(&snapshot, &deployment)?; - if let Some(waiting) = super::formation::waiting_for_peers(state, &snapshot, &selected, &deployment)? { - return Ok(waiting); + let selected: Vec<_> = if bound { + snapshot + .candidates + .iter() + .filter(|candidate| { + candidate.state == crowdb_protocol::mgmt::node::CandidateState::Unbound + && policy + .candidates + .get(&candidate.advertisement.discovery_id) + .copied() + .unwrap_or(policy.enabled) + && candidate.advertisement.formation.as_ref() == Some(&deployment.formation()) + }) + .collect() + } else { + super::formation::candidates(&snapshot, &deployment)? + .into_iter() + .filter(|candidate| { + policy + .candidates + .get(&candidate.advertisement.discovery_id) + .copied() + .unwrap_or(policy.enabled) + }) + .collect() + }; + if !bound { + if let Some(waiting) = super::formation::waiting_for_peers(state, &snapshot, &selected, &deployment)? + { + return Ok(waiting); + } } super::formation::prepare_local_rack(state, &deployment).await?; let config = state @@ -181,11 +207,29 @@ async fn finish( json!({"phase": "waiting", "members": eligible, "required_voters": required, "needs_access": needs_access}), ); } - if !super::read(state)?.enabled { - return Ok(json!({"phase": "disabled"})); + let _operation = crate::services::Operation::claim( + state, + vec![ + "cluster/init".into(), + "node/admission".into(), + "node/auto-join".into(), + ], + )?; + let policy = super::read(state)?; + let records = node::records(state)?; + if eligible.iter().any(|id| { + !records.iter().any(|record| { + record.node_id == *id + && !record.cancelled + && policy + .candidates + .get(&record.discovery_id) + .copied() + .unwrap_or(policy.enabled) + }) + }) { + return Ok(json!({"phase": "waiting", "reason": "Candidate Auto join selection changed"})); } - let _operation = - crate::services::Operation::claim(state, vec!["cluster/init".into(), "node/admission".into()])?; let progress = node::initialization::trigger(state, &eligible, Some(false)).await?; Ok(json!({"phase": "initializing", "members": eligible, "operation": progress})) } @@ -222,7 +266,8 @@ async fn admit_candidate( setup: None, expected_formation: Some(deployment.formation()), }; - if !super::read(state)?.enabled { + let policy = super::read(state)?; + if !policy.candidates.get(id).copied().unwrap_or(policy.enabled) { return Ok(Some(json!({"phase": "disabled"}))); } match node::admit(State(state.clone()), Json(body)).await { diff --git a/app/crowdb-web/src/node/storage/files.rs b/app/crowdb-web/src/node/storage/files.rs index 9b6cc9f5d..18a150013 100644 --- a/app/crowdb-web/src/node/storage/files.rs +++ b/app/crowdb-web/src/node/storage/files.rs @@ -11,7 +11,7 @@ use axum::{ use crowdb_console_shared::deployment::admission::remote_control; use crowdb_protocol::mgmt::node::{ storage::{ - FileDiskAdmission, FilePreparation, FilePreparationPage, PrepareDiskFile, + managed_file_zone_bytes, FileDiskAdmission, FilePreparation, FilePreparationPage, PrepareDiskFile, DEFAULT_FILE_CAPACITY_BYTES, DEFAULT_FILE_RESERVE_BYTES, }, NodeControl, @@ -26,7 +26,6 @@ use crate::{ use super::{remote_node, Failure}; const UNIT_SIZE: u32 = 1024 * 1024; -const ZONE_SIZE: u64 = 32 * 1024 * 1024 * 1024; #[derive(Deserialize)] #[serde(deny_unknown_fields)] @@ -64,15 +63,9 @@ pub(crate) async fn create( ) -> Result<(StatusCode, Json), Failure> { let node = remote_node(&state, node_id)?; canonical_operation(&body.operation_id)?; - if body.capacity_bytes == 0 - || body.capacity_bytes > i64::MAX as u64 - || body.capacity_bytes % (64 * u64::from(UNIT_SIZE)) != 0 - || (body.capacity_bytes > ZONE_SIZE && body.capacity_bytes % ZONE_SIZE != 0) - { - return Err(err_400( - "File capacity must be a positive multiple of 64 MiB within signed 64-bit bounds; above 32 GiB it must contain whole 32 GiB zones", - )); - } + let zone_size_bytes = managed_file_zone_bytes(body.capacity_bytes) + .filter(|_| i64::try_from(body.capacity_bytes).is_ok()) + .ok_or_else(|| err_400("File capacity must be a positive multiple of 64 MiB within the supported zone-count and signed 64-bit bounds"))?; let binding = crate::node::cluster_binding(&state)? .ok_or_else(|| err_409("File disk creation is waiting for Group 0"))?; let request = PrepareDiskFile { @@ -86,7 +79,7 @@ pub(crate) async fn create( rack_id: node.rack_id, disk_group_id: body.disk_group_id, unit_size_bytes: UNIT_SIZE, - zone_size_bytes: ZONE_SIZE.min(body.capacity_bytes), + zone_size_bytes, media_type: None, }), }; diff --git a/app/crowdb-web/src/services/observation.rs b/app/crowdb-web/src/services/observation.rs index 6cbb729c6..aa0f2fea2 100644 --- a/app/crowdb-web/src/services/observation.rs +++ b/app/crowdb-web/src/services/observation.rs @@ -1,6 +1,10 @@ // Copyright 2026-present Gian // Licensed under the Apache License, Version 2.0. +mod access; +#[cfg(feature = "test-util")] +pub use access::remote_access_health_for_tests; + use crate::state::AppState; use axum::{extract::State, Json}; use crowdb_console_shared::{cluster::NodeHealth, config::ServiceType}; @@ -72,6 +76,7 @@ pub async fn http_list_servers(State(state): State) -> Json { @@ -96,7 +101,7 @@ pub async fn http_list_servers(State(state): State) -> Json, snap: &std::collections::BTreeMap, + intents: &[crowdb_protocol::mgmt::node::NodeServiceIntent], ) -> NodeHealth { if s.service_type == ServiceType::AccessServer { - if let Some(url) = cfg - .local_launches - .get(&s.id) - .and_then(crowdb_console_shared::config::LocalLaunchSpec::access_health_url) - { - let healthy = reqwest::Client::new() - .get(url) - .timeout(std::time::Duration::from_secs(1)) - .send() - .await - .is_ok_and(|response| response.status().is_success()); - if healthy { - NodeHealth::Up - } else { - NodeHealth::Down - } - } else { - NodeHealth::Unknown - } + access::observe(cfg, s, state.node_monitor_url.is_some(), intents).await } else if s.service_type == ServiceType::PaxosKv && pid.is_some() && s.node_id.is_some_and(|id| { diff --git a/app/crowdb-web/src/services/observation/access.rs b/app/crowdb-web/src/services/observation/access.rs new file mode 100644 index 000000000..48ef887bb --- /dev/null +++ b/app/crowdb-web/src/services/observation/access.rs @@ -0,0 +1,88 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +//! Access uses its dedicated readiness listener, including remote deployments. + +use crowdb_console_shared::{cluster::NodeHealth, config::ServerEntry, ConsoleConfig}; +use crowdb_protocol::mgmt::node::{NodeServiceAction, NodeServiceIntent}; + +pub(super) async fn observe( + config: &ConsoleConfig, + service: &ServerEntry, + remote: bool, + intents: &[NodeServiceIntent], +) -> NodeHealth { + let url = if remote { + let Some(intent) = intents.iter().find(|intent| { + intent.kind == "access" + && intent.service_id == service.id + && Some(intent.node_id) == service.node_id + }) else { + return NodeHealth::Unknown; + }; + if matches!(intent.action, NodeServiceAction::Stop | NodeServiceAction::Delete) { + return NodeHealth::Down; + } + config + .node(intent.node_id) + .and_then(|node| remote_url(&intent.configuration, &node.host)) + } else { + config + .local_launches + .get(&service.id) + .and_then(crowdb_console_shared::config::LocalLaunchSpec::access_health_url) + }; + probe(url.as_deref()).await +} + +fn remote_url(configuration: &str, host: &str) -> Option { + let config: toml::Value = toml::from_str(configuration).ok()?; + let mut address: std::net::SocketAddr = config.get("health")?.get("listen")?.as_str()?.parse().ok()?; + let owner: std::net::IpAddr = host.parse().ok()?; + if address.port() == 0 || (!address.ip().is_unspecified() && address.ip() != owner) { + return None; + } + for protocol in ["s3", "iceberg", "dataset"] { + if config + .get(protocol) + .and_then(|value| value.get("listen")) + .and_then(toml::Value::as_str) + .and_then(|value| value.parse::().ok()) + .is_some_and(|listener| listener.port() == address.port()) + { + return None; + } + } + address.set_ip(owner); + Some(format!("http://{address}/_crowdb/health/ready")) +} + +async fn probe(url: Option<&str>) -> NodeHealth { + let Some(url) = url else { + return NodeHealth::Unknown; + }; + let Ok(client) = reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .timeout(std::time::Duration::from_secs(1)) + .build() + else { + return NodeHealth::Unknown; + }; + if client + .get(url) + .send() + .await + .is_ok_and(|response| response.status().is_success()) + { + NodeHealth::Up + } else { + NodeHealth::Down + } +} + +/// Exercise remote URL validation and the same readiness probe as the server inventory. +#[cfg(feature = "test-util")] +pub async fn remote_access_health_for_tests(configuration: &str, host: &str) -> NodeHealth { + probe(remote_url(configuration, host).as_deref()).await +} diff --git a/app/crowdb-web/tests/auto_join_test.rs b/app/crowdb-web/tests/auto_join_test.rs index 128da7d21..6597a4d40 100644 --- a/app/crowdb-web/tests/auto_join_test.rs +++ b/app/crowdb-web/tests/auto_join_test.rs @@ -331,3 +331,84 @@ async fn completed_cleanup_retires_console_credentials_before_another_cluster_is "observing an acknowledged cleanup must preserve the new cluster's credentials" ); } + +#[tokio::test] +async fn candidate_choices_are_independent_durable_and_do_not_enable_other_nodes() { + use axum::{routing::get, Json}; + use crowdb_protocol::mgmt::node::{ + CandidateNode, CandidateSnapshot, CandidateState, NodeAdvertisement, NodeHandshake, NodeHardware, + NODE_PROTOCOL_VERSION, + }; + let workspace = TestWorkspace::new(); + let ids = [ + "00000000-0000-4000-8000-000000000001", + "00000000-0000-4000-8000-000000000002", + ]; + let candidates: Vec<_> = ids + .iter() + .map(|id| CandidateNode { + advertisement: NodeAdvertisement { + discovery_id: (*id).into(), + protocol_version: NODE_PROTOCOL_VERSION, + monitor_endpoints: vec![], + cluster_id: None, + formation: Some(workspace.formation()), + }, + state: CandidateState::Unbound, + }) + .collect(); + let local = NodeHandshake { + advertisement: candidates[0].advertisement.clone(), + physical_host_id: "host".into(), + rack_hint: None, + hardware: NodeHardware::default(), + }; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + let monitor = Router::new() + .route("/node", get(move || async move { Json(local) })) + .route( + "/candidates", + get(move || async move { + Json(CandidateSnapshot { + nodes: candidates, + discovery_diagnostics: vec![], + }) + }), + ); + let server = tokio::spawn(async move { + axum::serve(listener, monitor).await.unwrap(); + }); + let state = || workspace.state().with_node_monitor(&endpoint).unwrap(); + assert_eq!( + request(router(state()), Some(json!({"enabled":false}))).await.0, + StatusCode::OK + ); + let (status, body) = request( + router(state()), + Some(json!({"enabled":true,"discovery_id":ids[0]})), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["enabled"], false); + assert_eq!(body["candidates"][ids[0]], true); + assert!(body["candidates"][ids[1]].is_null()); + let (status, _) = request( + router(state()), + Some(json!({"enabled":false,"discovery_id":ids[1]})), + ) + .await; + assert_eq!(status, StatusCode::OK); + let (_, restarted) = request(router(state()), None).await; + assert_eq!(restarted["candidates"][ids[0]], true); + assert_eq!(restarted["candidates"][ids[1]], false); + let (status, _) = request( + router(state()), + Some(json!({"enabled":true,"discovery_id":"00000000-0000-4000-8000-000000000003"})), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert!(!workspace.0.join("console/prepared-bootstrap.json").exists()); + server.abort(); + let _ = server.await; +} diff --git a/app/crowdb-web/tests/diskdb_binding_test.rs b/app/crowdb-web/tests/diskdb_binding_test.rs new file mode 100644 index 000000000..c9e179224 --- /dev/null +++ b/app/crowdb-web/tests/diskdb_binding_test.rs @@ -0,0 +1,45 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. + +#[path = "common/cluster_target.rs"] +mod cluster_target; +#[path = "common/memory_kv.rs"] +mod memory_kv; + +use axum::{body::Body, http::Request}; +use cluster_target::TestClusterTarget; +use tower::ServiceExt; + +#[tokio::test] +async fn disk_drilldown_uses_persisted_binding_without_local_process_observation() { + let fixture = TestClusterTarget::start().await; + assert_eq!(fixture.prepared.identity, fixture.binding.bootstrap); + assert!(fixture.root.path().join("node-binding.json").exists()); + assert!(fixture + .kv + .get( + 0, + 0, + b"/deployment/cluster", + crowdb_kv_client::ReadMode::Linearizable, + None + ) + .await + .is_ok()); + assert!(fixture.state.monitor_cache.snapshot().await.is_empty()); + let response = crowdb_web::router(fixture.state.clone()) + .oneshot( + Request::builder() + .uri("/api/diskdb/usage?dg=1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + // This fixture has no DiskDB owner. Authority resolution still succeeds; + // the request must reach DiskDB routing rather than claim no cluster exists. + let body = axum::body::to_bytes(response.into_body(), 8192).await.unwrap(); + let text = String::from_utf8(body.to_vec()).unwrap(); + assert!(!text.contains("cluster not initialized"), "{text}"); + assert!(fixture.state.diskdb_client.read().await.is_some()); +} diff --git a/app/crowdb-web/tests/service_health_test.rs b/app/crowdb-web/tests/service_health_test.rs index 3d339f619..5f13141e6 100644 --- a/app/crowdb-web/tests/service_health_test.rs +++ b/app/crowdb-web/tests/service_health_test.rs @@ -153,3 +153,39 @@ async fn node_mode_never_interprets_a_cached_pid_in_the_web_host_namespace() { Some(std::process::id()) ); } + +#[tokio::test] +async fn remote_access_uses_committed_health_listener_without_local_launch_metadata() { + use crowdb_console_shared::cluster::NodeHealth; + use crowdb_web::remote_access_health_for_tests; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let app = axum::Router::new().route("/_crowdb/health/ready", axum::routing::get(|| async { "ready" })); + let task = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let config = format!("[health]\nlisten = '{address}'\n[iceberg]\nlisten = '127.0.0.1:1'\n"); + assert_eq!( + remote_access_health_for_tests(&config, "127.0.0.1").await, + NodeHealth::Up + ); + assert_eq!( + remote_access_health_for_tests(&config, "192.0.2.1").await, + NodeHealth::Unknown + ); + let shared = format!("[health]\nlisten = '{address}'\n[s3]\nlisten = '{address}'\n"); + assert_eq!( + remote_access_health_for_tests(&shared, "127.0.0.1").await, + NodeHealth::Unknown + ); + assert_eq!( + remote_access_health_for_tests("[iceberg]\nlisten = '127.0.0.1:1'", "127.0.0.1").await, + NodeHealth::Unknown + ); + task.abort(); + let _ = task.await; + assert_eq!( + remote_access_health_for_tests(&config, "127.0.0.1").await, + NodeHealth::Down + ); +} diff --git a/app/crowdb-web/ui/src/App.test.tsx b/app/crowdb-web/ui/src/App.test.tsx index df1f6955b..9b71c631b 100644 --- a/app/crowdb-web/ui/src/App.test.tsx +++ b/app/crowdb-web/ui/src/App.test.tsx @@ -5,6 +5,7 @@ import { afterEach, expect, it, vi } from 'vitest'; import App from './App'; import { Domain } from './types'; import * as api from './api'; +vi.mock('./shell/NodeCandidatesContext', () => ({ CandidateProvider: ({ children }: { children: import('react').ReactNode }) => children })); vi.mock('./api', async importOriginal => ({ ...await importOriginal(), @@ -42,7 +43,8 @@ it('waits for initial node authority before offering store actions', async () => await act(async () => {}); expect(screen.queryByRole('button', { name: 'Add Store' })).not.toBeInTheDocument(); await act(async () => resolve({ available: true, phase: 'active' })); - fireEvent.click(screen.getByRole('button', { name: 'Add Store' })); + fireEvent.contextMenu(screen.getByRole('button', { name: /^datacenter$/ })); + fireEvent.click(screen.getByRole('menuitem', { name: 'Add Store' })); expect(screen.getByRole('dialog', { name: 'Add KV Store' })).toBeVisible(); }); @@ -54,4 +56,5 @@ it('shows a read-only console when the initial authority check fails', async () expect(screen.queryByText('Loading cluster authority…')).not.toBeInTheDocument(); expect(screen.getByRole('complementary', { name: 'Cluster tree sidebar' })).toBeVisible(); expect(screen.queryByRole('button', { name: 'Add Store' })).not.toBeInTheDocument(); + expect(screen.getByRole('alert')).toHaveTextContent('Group 0 unavailable · confirmed view is stale'); }); diff --git a/app/crowdb-web/ui/src/App.tsx b/app/crowdb-web/ui/src/App.tsx index 140127f87..69d36634e 100644 --- a/app/crowdb-web/ui/src/App.tsx +++ b/app/crowdb-web/ui/src/App.tsx @@ -1,8 +1,6 @@ import { PanelDivider } from './components/PanelDivider'; -import { ClusterViewSwitch } from './shell/ClusterViewSwitch'; import { ClusterBreadcrumb } from './shell/ClusterBreadcrumb'; import { useClusterLayout } from './shell/useClusterLayout'; -import { CapacityObservations } from './shell/CapacityObservations'; // Copyright 2026-present Gian // Licensed under the Apache License, Version 2.0. @@ -26,6 +24,7 @@ import { ToastContainer } from './components/ToastContainer'; import { TreeNode } from './components/Tree'; import { ContextMenu, useContextMenu } from './components/ContextMenu'; import type { MenuTarget } from './topology/TopologyCanvas'; +import { CandidateProvider } from './shell/NodeCandidatesContext'; import { AddRackDialog, AddNodeDialog, @@ -51,14 +50,9 @@ import { buildCrowdbKVServers, crowdbKvServerNodeIds } from './data/crowdbKvServ import { isCrowdbKVServerAvailable } from './data/crowdbKvServers'; import { toUiHealth } from './utils/entityDisplay'; import { ClusterView } from './views/ClusterView'; -import { ClusterOverview } from './storage/ClusterOverview'; +import { NodeStorage } from './storage/NodeStorage'; import { CreateFileDiskDialog } from './storage/CreateFileDiskDialog'; -import { useNavigationSnapshot } from './contexts/DomainContext'; -import { NodeWorkspace } from './storage/NodeWorkspace'; -import type { NodeSetupSummary } from './shell/nodeSetup'; import type { CandidateSummary } from './storage/types'; -import { InitializationProgress } from './shell/InitializationProgress'; -import { AutoJoin } from './shell/AutoJoin'; import { KvView } from './views/KvView'; import { CapacityView } from './views/CapacityView'; import { ChunkBrowser } from './chunk/ChunkBrowser'; @@ -88,17 +82,17 @@ export interface CrowdbConsoleProps { } function AppContent({ apiPrefix = '/api', readonly = false, modules, onEvent, managed = false, nodeManagement = false }: CrowdbConsoleProps) { - const { domain: activeDomain, clusterView, setClusterView, setDomain } = useDomain(); + const { domain: activeDomain, setDomain } = useDomain(); const { selectedEntity, scopeEntity, scopeForDomain, selectionForDomain, selectEntity, clearSelection } = useSelection(); - const [diagram, setDiagram] = useState(false); const [addNodeRequest, setAddNodeRequest] = useState<{ rackId: number; nonce: number }>(); - useNavigationSnapshot(Domain.Cluster, 'cluster-diagram', () => { - const saved = diagram; return () => setDiagram(saved); - }); - const overviewScope = !scopeEntity || scopeEntity.type === 'Datacenter' || scopeEntity.type === 'Rack'; - const storageDetail = scopeEntity?.type === 'Disk' || scopeEntity?.type === 'DiskGroup'; - const domain = activeDomain === Domain.Cluster && (clusterView === 'capacity' || storageDetail) - ? Domain.Capacity : activeDomain; + const openNodeCandidates = useCallback((rackId: number) => setAddNodeRequest({ rackId, nonce: Date.now() }), []); + const domain = activeDomain; + const capacityActive = activeDomain === Domain.Cluster && !!scopeEntity + && ['Datacenter', 'Rack', 'Node', 'DiskGroup', 'Disk'].includes(scopeEntity.type) + && scopeEntity.section !== 'services'; + const topologyActive = activeDomain === Domain.Cluster + && !['DiskGroup', 'Disk'].includes(scopeEntity?.type ?? '') + && scopeEntity?.section !== 'storage' && scopeEntity?.section !== 'candidates'; const { success, error } = useToast(); const { log } = useActivity(); @@ -114,11 +108,6 @@ function AppContent({ apiPrefix = '/api', readonly = false, modules, onEvent, ma const [lastRefreshTime, setLastRefreshTime] = useState(new Date()); const [refreshing, setRefreshing] = useState(false); const [candidateSummaries, setCandidateSummaries] = useState>({}); - const [setupSummaries, setSetupSummaries] = useState>({}); - const observeSetup = useCallback((nodeId: number, summary: NodeSetupSummary) => { - setSetupSummaries(previous => Object.fromEntries(Object.entries({ ...previous, [nodeId]: summary }) - .sort((a, b) => b[1].observed_at_ms - a[1].observed_at_ms).slice(0, 64))); - }, []); const observeCandidates = useCallback((nodeId: number, summary: CandidateSummary) => { setCandidateSummaries(previous => { if (JSON.stringify(previous[nodeId]) === JSON.stringify(summary)) return previous; @@ -148,14 +137,13 @@ function AppContent({ apiPrefix = '/api', readonly = false, modules, onEvent, ma const { menuState, openMenu, closeMenu } = useContextMenu(); const nodeAuthority = useNodeAuthority(nodeManagement); - useEffect(() => { setCandidateSummaries({}); setSetupSummaries({}); }, [nodeAuthority?.cluster_id]); + useEffect(() => { setCandidateSummaries({}); }, [nodeAuthority?.cluster_id]); const managementAuthorized = !nodeManagement || nodeAuthority?.available === true; const draftEditable = nodeAuthority?.phase === 'unbound_draft'; const topologyReadonly = readonly || managed || (nodeManagement && !managementAuthorized && !draftEditable); const logicalReadonly = readonly || (nodeManagement && !managementAuthorized && !draftEditable); const ownsSidebar = domain === Domain.Dataset || domain === Domain.Iceberg || domain === Domain.S3 || (domain === Domain.Chunk || domain === Domain.ChunkKV); const physicalActive = domain === Domain.Cluster; - const capacityActive = domain === Domain.Capacity; const { racks, nodes, services: managedServers, nodeStores, nodeHealthById, nodeDiskGroups: clusterDiskGroups, loadNodeDisks, loadGroupDisks, loading: physLoading, error: physError, refresh: refreshPhysical } = useClusterTree({ enabled: true, managed, @@ -170,7 +158,7 @@ function AppContent({ apiPrefix = '/api', readonly = false, modules, onEvent, ma pollIntervalActive: 1000, pollIntervalInactive: 30000, }); - const { instances: diskdbInstances, usage: capacityUsage, hardwareCapacity, scanStatus: capacityScanStatus, loading: capLoading, error: capError, refresh: refreshCapacity, refreshRuntime: observeCapacityRuntime, observations: capacityObservations } = useCapacityTree({ + const { instances: diskdbInstances, usage: capacityUsage, hardwareCapacity, scanStatus: capacityScanStatus, loading: capLoading, error: capError, refresh: refreshCapacity, refreshRuntime: observeCapacityRuntime } = useCapacityTree({ enabled: domain === Domain.Capacity || domain === Domain.Cluster, observeRuntime: capacityActive && (scopeEntity?.type === 'DiskGroup' || scopeEntity?.type === 'Disk'), diskGroupId: scopeForDomain(Domain.Cluster)?.parentIds?.disk_group_id !== undefined @@ -184,7 +172,7 @@ function AppContent({ apiPrefix = '/api', readonly = false, modules, onEvent, ma const nodeDiskGroups = clusterDiskGroups; const diskSelection = scopeForDomain(Domain.Cluster); const clusterSelection = scopeForDomain(Domain.Cluster); - const workspaceNode = activeDomain === Domain.Cluster && nodeManagement && !managed && clusterSelection?.type === 'Node' + const workspaceNode = activeDomain === Domain.Cluster && nodeManagement && !managed && clusterSelection?.type === 'Node' && (clusterSelection.section === 'storage' || clusterSelection.section === 'candidates') ? nodes.find(node => String(node.id) === clusterSelection.id) : undefined; useEffect(() => { if (managed) return; @@ -208,7 +196,7 @@ function AppContent({ apiPrefix = '/api', readonly = false, modules, onEvent, ma ); const loading = physLoading || logLoading || capLoading; - const dataError = (domain === Domain.Cluster ? physError : domain === Domain.KV ? logError : domain === Domain.Capacity ? capError ?? physError : null); + const dataError = (domain === Domain.Cluster ? physError ?? (capacityActive ? capError : null) : domain === Domain.KV ? logError : null); const servers = useMemo(() => buildCrowdbKVServers(nodes, racks), [nodes, racks]); const serverNodeIds = useMemo(() => crowdbKvServerNodeIds(servers), [servers]); const [standaloneServers, setAllServers] = useState([]); @@ -261,7 +249,7 @@ function AppContent({ apiPrefix = '/api', readonly = false, modules, onEvent, ma const servicePlans = useNodeServicePlans(stores, nodeDiskGroups, handleRefresh, !topologyReadonly && managementAuthorized); const clusterHealth: ClusterHealth = useMemo(() => { - if (dataError) return domain === Domain.Capacity ? 'Degraded' : 'Failed'; + if (dataError) return capacityActive && !physError ? 'Degraded' : 'Failed'; const statuses = [ ...groups.map((g) => toUiHealth(String((g as any).state || (g as any).health || ''))), ...nodes.map((node) => toUiHealth(String(node.kv_server?.health || ''))), @@ -275,7 +263,7 @@ function AppContent({ apiPrefix = '/api', readonly = false, modules, onEvent, ma if (planStates.some((state) => state === 'waiting' || state === 'deploying' || state === 'pending')) return 'Degraded'; if (statuses.every((status) => status === 'Healthy')) return 'Healthy'; return 'Unknown'; - }, [groups, nodes, allServers, servicePlans.plans, dataError, domain]); + }, [groups, nodes, allServers, servicePlans.plans, dataError, capacityActive, physError]); useEffect(() => { if (managed) return; for (const [id, plan] of Object.entries(servicePlans.plans)) { @@ -290,7 +278,7 @@ function AppContent({ apiPrefix = '/api', readonly = false, modules, onEvent, ma // After cluster init succeeds, refresh the tree so the system group // appears. Init only bootstraps store 0 / group 0; store creation is - // a separate step the user initiates via the "+" button. + // a separate step in the Datacenter context menu. const handleInitSuccess = useCallback(async () => { await handleRefresh(); setDialog((d) => ({ ...d, initCluster: false })); @@ -343,7 +331,7 @@ function AppContent({ apiPrefix = '/api', readonly = false, modules, onEvent, ma if (node) selectEntity({ domain: Domain.Cluster, type: 'Node', id: String(node.id), parentIds: { rack_id: node.rack_id }, section: 'storage' }); }, [nodes, selectEntity]); const manageNodeStorage = nodeManagement && !managed ? openNodeStorage : undefined; - const menuContext = { readonly, managed, nodeManagement, onManageNodeStorage: manageNodeStorage, managementAuthorized, domain: activeDomain, physicalActive: activeDomain === Domain.Cluster, modules, requestDelete, runMutation, serverNodeIds, diskdbNodeIds, allServers, setDialog, capacityUsage }; + const menuContext = { readonly, managed, nodeManagement, onAddNodeCandidate: openNodeCandidates, onManageNodeStorage: manageNodeStorage, managementAuthorized, draftEditable, clusterInitialized, domain: activeDomain, physicalActive: activeDomain === Domain.Cluster, modules, requestDelete, runMutation, serverNodeIds, diskdbNodeIds, allServers, setDialog, capacityUsage }; const buildMenuItems = useClusterMenus(menuContext); const buildCapacityMenuItems = useCapacityMenus(menuContext); @@ -372,9 +360,8 @@ function AppContent({ apiPrefix = '/api', readonly = false, modules, onEvent, ma ); const onTreeNodeClick = useCallback((node: TreeNode) => { - if (node.section) setClusterView(node.section === 'services' ? 'topology' : 'capacity'); setCanvasFocusRequest({ targetId: node.section ? `N-${node.rawId}` : node.id, subtree: true, nonce: Date.now() }); - }, [setClusterView]); + }, []); const handleAdd = useCallback(() => { if (readonly || (nodeManagement && !managementAuthorized && !draftEditable)) return; @@ -570,87 +557,42 @@ function AppContent({ apiPrefix = '/api', readonly = false, modules, onEvent, ma marginRight: selectedEntity && !ownsSidebar && !clusterLayout.drawer ? inspectorWidth : 0, }} > - {dataError &&
- {domain === Domain.Capacity ? `${dataError.message} — retrying` : 'Backend unreachable — retrying'} + {(dataError || nodeAuthority?.phase === 'authority_unavailable') &&
+ {nodeAuthority?.phase === 'authority_unavailable' ? 'Group 0 unavailable · confirmed view is stale' + : capacityActive ? `${dataError!.message} — retrying` : 'Backend unreachable — retrying'}
} - {activeDomain === Domain.Cluster && <> - - - - {!storageDetail && } - {overviewScope && domain === Domain.Cluster &&
- + {activeDomain === Domain.Cluster && } + {kvEnabled && ( )} - @@ -665,7 +607,7 @@ function AppContent({ apiPrefix = '/api', readonly = false, modules, onEvent, ma {!ownsSidebar && - + } {selectedEntity && !ownsSidebar && !clusterLayout.drawer && + + diff --git a/app/crowdb-web/ui/src/access/resourceMenus.test.tsx b/app/crowdb-web/ui/src/access/resourceMenus.test.tsx new file mode 100644 index 000000000..fec60bfc8 --- /dev/null +++ b/app/crowdb-web/ui/src/access/resourceMenus.test.tsx @@ -0,0 +1,49 @@ +// Copyright 2026-present Gian +// Licensed under the Apache License, Version 2.0. +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, expect, it, vi } from 'vitest'; +import { useIcebergMenu } from '../iceberg/ResourceMenu'; +import { useS3Menu } from '../s3/ResourceMenu'; +import { iceberg, s3 } from './native'; +vi.mock('./native', () => ({ iceberg: vi.fn(async () => ({})), s3: vi.fn(async () => new Response()), objectPath: (bucket: string, key?: string) => `/${bucket}${key === undefined ? '' : '/' + key}`, previewBytes: vi.fn() })); +vi.mock('./s3Transfer', () => ({ uploadObject: vi.fn(), downloadObject: vi.fn() })); +afterEach(() => { vi.clearAllMocks(); }); +function Catalog({ namespace = 'first', readonly = false }) { + const menu = useIcebergMenu(readonly, () => true, async () => {}); + return <>{menu.overlay}; +} +it('pins the exact Iceberg target across selection changes and requires confirmation', async () => { + const { rerender } = render(); + fireEvent.contextMenu(screen.getByRole('button', { name: 'Resource' })); + fireEvent.click(screen.getByRole('menuitem', { name: 'Drop table…' })); + expect(iceberg).not.toHaveBeenCalled(); + rerender(); + expect(screen.getByRole('dialog')).toHaveTextContent('first / table'); + fireEvent.click(screen.getByRole('button', { name: 'Drop table' })); + await waitFor(() => expect(iceberg).toHaveBeenCalledWith('/v1/namespaces/first/tables/table', '', 'DELETE')); +}); +it('keeps readonly Iceberg mutations disabled', () => { + render(); fireEvent.contextMenu(screen.getByRole('button', { name: 'Resource' })); + for (const item of screen.getAllByRole('menuitem')) expect(item).toBeDisabled(); + expect(iceberg).not.toHaveBeenCalled(); +}); +function ObjectMenu({ readonly = false }) { + const menu = useS3Menu(readonly, true, async () => {}, () => {}, () => {}); + return <>{menu.overlay}; +} +it('offers explicit object reads in readonly mode without permitting mutations', () => { + render(); fireEvent.contextMenu(screen.getByRole('button', { name: 'Object' })); + expect(screen.getByRole('menuitem', { name: 'Preview first 4 KiB' })).toBeEnabled(); + expect(screen.getByRole('menuitem', { name: 'Download' })).toBeEnabled(); + expect(screen.getByRole('menuitem', { name: 'Replace object…' })).toBeDisabled(); + expect(screen.getByRole('menuitem', { name: 'Delete object…' })).toBeDisabled(); + expect(s3).not.toHaveBeenCalled(); +}); +it('deletes only the confirmed S3 Object', async () => { + render(); fireEvent.contextMenu(screen.getByRole('button', { name: 'Object' })); + fireEvent.click(screen.getByRole('menuitem', { name: 'Delete object…' })); + expect(screen.getByRole('dialog')).toHaveTextContent('photos / a/b'); + expect(s3).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: 'Delete object' })); + await waitFor(() => expect(s3).toHaveBeenCalledWith('DELETE', '/photos/a/b')); +}); diff --git a/app/crowdb-web/ui/src/api.ts b/app/crowdb-web/ui/src/api.ts index ca463ff53..d9bb422fa 100644 --- a/app/crowdb-web/ui/src/api.ts +++ b/app/crowdb-web/ui/src/api.ts @@ -643,6 +643,7 @@ export async function clusterInitialization(options?: RequestOptions): Promise; enabled: boolean; target_cluster_id: string | null; mode: 'standard'; @@ -660,10 +661,10 @@ export async function nodeAutoJoin(options?: RequestOptions): Promise { +export async function setNodeAutoJoin(enabled: boolean, target?: 'local-draft', discoveryId?: string): Promise { return jsonOrThrow(await fetchWithOptions('/api/node/auto-join', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ enabled, target }), skipDeduplication: true, + body: JSON.stringify({ enabled, target, ...(discoveryId ? { discovery_id: discoveryId } : {}) }), skipDeduplication: true, })); } diff --git a/app/crowdb-web/ui/src/chunk/ChunkBrowser.tsx b/app/crowdb-web/ui/src/chunk/ChunkBrowser.tsx index bc86d14c7..5f7f20a53 100644 --- a/app/crowdb-web/ui/src/chunk/ChunkBrowser.tsx +++ b/app/crowdb-web/ui/src/chunk/ChunkBrowser.tsx @@ -247,7 +247,7 @@ export function ChunkBrowser({ active, onPlacement, openRequest, racks, nodes, s {!isPkvSelection && page &&

{page.owners} owners · scanned {page.scanned} records this page · observed {new Date(page.observed_at_ms).toLocaleTimeString()}

} {!isPkvSelection && page?.owners === 0 &&

No live ChunkDB service is registered. Deploy ChunkDB in Cluster before browsing chunks.

} {!isPkvSelection && !!page?.failures.length &&
Partial result. Retry this scope after owner recovery; no continuation is issued while an owner is missing.
} - + {!!rows.length && !visibleRows.length &&

No matches in this window. Clear the filter or continue to the next window.

} {!isPkvSelection && page && !rows.length && !busy &&

No matching chunks in this scan window.{page.next && ' Continue scanning to find later matches.'}

} {!isPkvSelection && page &&