Skip to content

Unbounded attribute values in ListAttributes and GetAttribute responses #3744

Open

Description

@eugenioenko

Summary

The ListAttributes and GetAttribute RPCs return all attribute values for each attribute with no pagination or limit. If an attribute definition accumulates a large number of values, this can cause excessive memory usage, large response payloads, and high latency.

Details

Root Cause

Both ListAttributes and GetAttribute use the same hydrateAttribute path, which deserializes the full valuesJSON blob from the database query. The underlying SQL (listAttributesDetail, getAttribute) aggregates values via JSON_AGG with no limit:

-- service/policy/db/attributes.sql.go (listAttributesDetail)
JSON_AGG(
    JSON_BUILD_OBJECT(
        'id', avt.id,
        'value', avt.value,
        'active', avt.active,
        'fqn', CONCAT(fqns.fqn, '/value/', avt.value)
    ) ORDER BY ARRAY_POSITION(ad.values_order, avt.id)
) FILTER (WHERE avt.id IS NOT NULL) AS values

The LEFT JOIN on attribute_values (lines 787–795 in attributes.sql.go) has no filter or limit on the number of values joined per attribute definition.

Affected RPCs

RPC Impact
ListAttributes Every attribute in the paginated response carries all of its values. Pagination only controls the number of attribute definitions returned, not the values within each.
GetAttribute Returns a single attribute with all of its values.
ListAttributesByFqns Same hydrateAttribute path — all values included.

No Alternative Endpoint

The ListAttributeValues RPC was deprecated and removed — it returns Unimplemented:

// service/policy/attributes/attributes.go:309
func (s *AttributesService) ListAttributeValues(_ context.Context, _ *connect.Request[attributes.ListAttributeValuesRequest]) (*connect.Response[attributes.ListAttributeValuesResponse], error) {
    return nil, connect.NewError(connect.CodeUnimplemented, ErrDeprecatedListAttributeValues)
}

This means there is currently no paginated way to retrieve attribute values for a given attribute definition.

Potential Impact

  • Memory pressure on Postgres: JSON_AGG builds the entire JSON array in memory server-side before returning.
  • Large gRPC responses: An attribute with hundreds of thousands of values produces a response payload in the tens or hundreds of MB.
  • Latency: Aggregation, serialization (protojson), and network transfer all scale linearly with value count.
  • Amplification on ListAttributes: The cost is multiplied by every attribute in the page — a page of 25 attributes each with large value sets compounds the problem.

Suggested Approaches

  1. Add a limit on values within the JSON_AGG: Cap the number of values returned per attribute in list/get queries (e.g., return the first N values and a has_more indicator).
  2. Re-introduce a paginated ListAttributeValues RPC: Provide a dedicated endpoint for fetching values for a specific attribute definition with proper pagination support.
  3. Add an option to exclude values from list responses: Allow callers to opt out of values in ListAttributes when they only need attribute metadata, reducing payload size for common use cases.

Activity

  1. eugenioenko commented on Jul 10, 2026

    @eugenioenko
    ContributorAuthor

    Implementation Pattern

    Per direction to match existing proto patterns, the best template to follow is ListRegisteredResourceValues in the registered resources service — it's a clean paginated sub-resource listing with a parent ID filter.

    Proto Pattern

    From service/policy/registeredresources/registered_resources.proto (lines 284-299):

    message ListRegisteredResourceValuesRequest {
      string resource_id = 1;               // optional parent filter
      policy.PageRequest pagination = 10;
    }
    message ListRegisteredResourceValuesResponse {
      repeated policy.RegisteredResourceValue values = 1;
      policy.PageResponse pagination = 10;
    }

    SQL Pattern

    From service/policy/db/queries/registered_resources.sql (lines 242-289), uses a WITH counted CTE + LIMIT/OFFSET:

    WITH counted AS (
        SELECT COUNT(v.id) AS total
        FROM registered_resource_values v
        WHERE sqlc.narg('registered_resource_id')::uuid IS NULL 
           OR v.registered_resource_id = sqlc.narg('registered_resource_id')::uuid
    )
    SELECT
        v.id, ...,
        counted.total
    FROM registered_resource_values v
    JOIN registered_resources r ON v.registered_resource_id = r.id
    CROSS JOIN counted
    WHERE
        sqlc.narg('registered_resource_id')::uuid IS NULL 
        OR v.registered_resource_id = sqlc.narg('registered_resource_id')::uuid
    GROUP BY v.id, ..., counted.total
    ORDER BY v.created_at DESC
    LIMIT @limit_
    OFFSET @offset_;

    Go DB Layer Pattern

    From service/policy/db/registered_resources.go (lines 467-532):

    • getRequestedLimitOffset() to extract pagination params with defaults
    • Max limit enforcement via c.listCfg.limitMax
    • sqlc query execution
    • Hydrate proto objects from rows
    • getNextOffset() to compute next page offset
    • Return response with PageResponse

    Go Service Handler Pattern

    From service/policy/registeredresources/registered_resources.go (lines 281-292) — thin wrapper:

    func (s *Service) ListValues(ctx context.Context, req *connect.Request[...]) (*connect.Response[...], error) {
        rsp, err := s.dbClient.ListAttributeValues(ctx, req.Msg)
        if err != nil {
            return nil, db.StatusifyError(ctx, s.logger, err, db.ErrTextListRetrievalFailed)
        }
        return connect.NewResponse(rsp), nil
    }

    Notes

    • The previously removed ListAttributeValues already followed this exact pattern before it was deprecated in PR fix(policy): deprecate ListAttributeValues in favor of existing GetAttribute #3108. The fix is essentially re-introducing it with current conventions.
    • Shared pagination types (PageRequest/PageResponse) are defined in service/policy/selectors.proto.
    • Pagination helpers (getRequestedLimitOffset, getNextOffset) are in service/policy/db/utils.go.
  2. eugenioenko commented on Jul 13, 2026

    @eugenioenko
    ContributorAuthor

    ADR with proposed fix
    #3747

  3. eugenioenko commented on Aug 6, 2026

    @eugenioenko
    ContributorAuthor

    The current API doesn't return RANK either and attribute values cannot be sorted by rank. This might be a separate issue but V2 implementation needs to include RANK and ability to sort by rank. This way if there is 1M attribute values and you want to get the one from lowers rank instead of highest.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions