@@ -459,6 +459,12 @@ func registerClientRoutes(app *fiber.App, policy httpauth.Policy, nodeCtx *nodeC
459459 return c .Send (openapiSpec )
460460 })
461461
462+ // /v1/cache/keys must be registered BEFORE the parameterized
463+ // /v1/cache/:key — Fiber matches in registration order and the
464+ // literal-path route would otherwise be shadowed by the
465+ // param-bound handler (handleGet would be invoked with
466+ // `key="keys"` and return 404).
467+ app .Get ("/v1/cache/keys" , read , func (c fiber.Ctx ) error { return handleListKeys (c , nodeCtx ) })
462468 app .Put ("/v1/cache/:key" , write , func (c fiber.Ctx ) error { return handlePut (c , nodeCtx ) })
463469 app .Get ("/v1/cache/:key" , read , func (c fiber.Ctx ) error { return handleGet (c , nodeCtx ) })
464470 app .Head ("/v1/cache/:key" , read , func (c fiber.Ctx ) error { return handleHead (c , nodeCtx ) })
@@ -646,6 +652,33 @@ type ownersResponse struct {
646652 Node string `json:"node"`
647653}
648654
655+ // listKeysResponse is the body of GET /v1/cache/keys — operator-
656+ // facing key browser. `NextCursor` is empty on the last page;
657+ // `TotalMatched` is the full deduplicated matched set (capped by
658+ // `max`). `Truncated` reports that the cluster-wide cap was hit
659+ // and the operator should refine the pattern. `PartialNodes`
660+ // lists peers whose fan-out failed; their keys may be missing.
661+ type listKeysResponse struct {
662+ Keys []string `json:"keys"`
663+ NextCursor string `json:"next_cursor"`
664+ TotalMatched int `json:"total_matched"`
665+ Truncated bool `json:"truncated"`
666+ Node string `json:"node"`
667+ PartialNodes []string `json:"partial_nodes,omitempty"`
668+ }
669+
670+ // list-keys query-parameter bounds. Defaults match the operator
671+ // "browse / refine" workflow; the hard caps bound the worst-case
672+ // memory and response size — operators needing a larger sweep
673+ // script against the per-node /internal/keys path with their own
674+ // paging instead of lifting these.
675+ const (
676+ listKeysDefaultLimit = 100
677+ listKeysMaxLimit = 500
678+ listKeysDefaultMax = 10000
679+ listKeysHardMax = 50000
680+ )
681+
649682// handlePut implements PUT /v1/cache/:key.
650683// Body is the raw value (any content type). Optional ?ttl=<dur>
651684// applies a relative expiration; empty/absent means no expiration.
@@ -1289,6 +1322,126 @@ func handleOwners(c fiber.Ctx, nodeCtx *nodeContext) error {
12891322 })
12901323}
12911324
1325+ // listKeysParams is the parsed-and-validated form of the
1326+ // /v1/cache/keys query string. Returned as a struct so
1327+ // parseListKeysQuery stays under the function-result-limit and
1328+ // the call site reads fields by name rather than position.
1329+ type listKeysParams struct {
1330+ Pattern string
1331+ Cursor int
1332+ Limit int
1333+ MaxResults int
1334+ }
1335+
1336+ // parseBoundedPositiveInt reads a query parameter as a positive int
1337+ // with a default fallback and a hard ceiling. Empty value → default.
1338+ // Out-of-range or non-numeric → caller-visible error (must surface
1339+ // as 400 BAD_REQUEST).
1340+ func parseBoundedPositiveInt (c fiber.Ctx , name string , def , hardMax int ) (int , error ) {
1341+ v := c .Query (name )
1342+ if v == "" {
1343+ return def , nil
1344+ }
1345+
1346+ n , err := strconv .Atoi (v )
1347+ if err != nil || n <= 0 {
1348+ return 0 , ewrap .New ("invalid " + name + ": must be a positive integer" )
1349+ }
1350+
1351+ if n > hardMax {
1352+ n = hardMax
1353+ }
1354+
1355+ return n , nil
1356+ }
1357+
1358+ // parseListKeysQuery extracts and validates the query parameters
1359+ // for GET /v1/cache/keys. Defaults and hard caps are applied here
1360+ // so handleListKeys keeps a single response-shape concern.
1361+ func parseListKeysQuery (c fiber.Ctx ) (listKeysParams , error ) {
1362+ out := listKeysParams {Pattern : c .Query ("q" )}
1363+
1364+ if cursorStr := c .Query ("cursor" ); cursorStr != "" {
1365+ n , err := strconv .Atoi (cursorStr )
1366+ if err != nil || n < 0 {
1367+ return listKeysParams {}, ewrap .New ("invalid cursor: must be a non-negative integer" )
1368+ }
1369+
1370+ out .Cursor = n
1371+ }
1372+
1373+ limit , err := parseBoundedPositiveInt (c , "limit" , listKeysDefaultLimit , listKeysMaxLimit )
1374+ if err != nil {
1375+ return listKeysParams {}, err
1376+ }
1377+
1378+ out .Limit = limit
1379+
1380+ maxResults , err := parseBoundedPositiveInt (c , "max" , listKeysDefaultMax , listKeysHardMax )
1381+ if err != nil {
1382+ return listKeysParams {}, err
1383+ }
1384+
1385+ out .MaxResults = maxResults
1386+
1387+ return out , nil
1388+ }
1389+
1390+ // handleListKeys implements GET /v1/cache/keys — operator-facing
1391+ // cluster-wide key browser. Fans out across every alive peer,
1392+ // merges + dedupes + sorts the result, then slices the page via
1393+ // cursor/limit. The full deduplicated set is held in memory for
1394+ // one request (bounded by `max`); paging re-fans out — fine for
1395+ // the operator-debug workflow this endpoint serves.
1396+ //
1397+ // Returns 501 when the underlying backend isn't a DistMemory
1398+ // (in-memory / Redis): the surface only makes sense in cluster
1399+ // mode and surfacing that explicitly is friendlier than a
1400+ // silently empty page.
1401+ func handleListKeys (c fiber.Ctx , nodeCtx * nodeContext ) error {
1402+ params , err := parseListKeysQuery (c )
1403+ if err != nil {
1404+ return jsonErr (c , fiber .StatusBadRequest , codeBadRequest , err .Error ())
1405+ }
1406+
1407+ res , err := nodeCtx .hc .ClusterKeys (c .Context (), params .Pattern , params .MaxResults )
1408+ if err != nil {
1409+ return jsonErr (c , fiber .StatusBadRequest , codeBadRequest , err .Error ())
1410+ }
1411+
1412+ if res == nil {
1413+ return jsonErr (
1414+ c ,
1415+ fiber .StatusNotImplemented ,
1416+ codeInternal ,
1417+ "list-keys requires a distributed backend" ,
1418+ )
1419+ }
1420+
1421+ total := len (res .Keys )
1422+
1423+ // Cursor past the end is a valid terminal state (last page +
1424+ // 1): respond with an empty page rather than 400. Mirrors how
1425+ // SQL OFFSET past the row count returns an empty result set.
1426+ start := min (params .Cursor , total )
1427+ end := min (start + params .Limit , total )
1428+ page := res .Keys [start :end ]
1429+
1430+ nextCursor := ""
1431+ if end < total {
1432+ nextCursor = strconv .Itoa (end )
1433+ }
1434+
1435+ return c .JSON (listKeysResponse {
1436+ Keys : page ,
1437+ NextCursor : nextCursor ,
1438+ TotalMatched : total ,
1439+ Truncated : res .Truncated ,
1440+ Node : nodeCtx .nodeID ,
1441+ PartialNodes : res .PartialNodes ,
1442+ })
1443+ }
1444+
12921445// meResponse is the body of GET /v1/me — the resolved caller identity
12931446// after auth middleware ran. Mirrors httpauth.Identity but written as
12941447// a wire type so the JSON tags are owned by the API surface, not the
0 commit comments