Investigate production label counts to size a labels-per-work-item limit

Summary

The Work Item REST API returns all of a work item's labels as full objects, inline and unpaginated, via ?features=labels — on both the list and show endpoints. There is currently no limit on how many labels a work item can have (assignees are capped at 200; labels are not). On the list endpoint this multiplies (page size × labels/item), creating an unbounded response — a potential DoS vector.

This is a prerequisite investigation: before we choose a limit, we need production data on how many labels work items actually have, so the cap sits safely above real-world usage and doesn't break existing workflows.

Background

  • Measured impact: a single work item with 150 labels returns ~13.5 KB via ?features=labels; a 100-item list page ≈ 1.35 MB, unbounded.
  • Pre-existing gap: the legacy Issues REST API has the same behavior via with_labels_details=true. GraphQL is the only safe surface (labels are a paginated connection, max 100/page).
  • Precedent: assignees are already capped — Issuable::MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS = 200.

Goal of this issue

Run the queries below on a prod replica / Database Lab (not the primary — label_links is large) and record the results, so we can pick a limit above the p99.9 with headroom.

Distribution query

SELECT
  count(*)                                           AS work_items_with_labels,
  max(cnt)                                           AS max_labels,
  round(avg(cnt), 2)                                 AS avg_labels,
  percentile_cont(0.50)  WITHIN GROUP (ORDER BY cnt) AS p50,
  percentile_cont(0.95)  WITHIN GROUP (ORDER BY cnt) AS p95,
  percentile_cont(0.99)  WITHIN GROUP (ORDER BY cnt) AS p99,
  percentile_cont(0.999) WITHIN GROUP (ORDER BY cnt) AS p999,
  count(*) FILTER (WHERE cnt > 50)                   AS over_50,
  count(*) FILTER (WHERE cnt > 100)                  AS over_100,
  count(*) FILTER (WHERE cnt > 200)                  AS over_200,
  count(*) FILTER (WHERE cnt > 500)                  AS over_500
FROM (
  SELECT target_id, count(*) AS cnt
  FROM label_links
  WHERE target_type = 'Issue'   -- work items are stored in the issues table
  GROUP BY target_id
) per_item;

Worst-offenders query (is the tail legit usage or abuse?)

SELECT target_id, count(*) AS labels
FROM label_links
WHERE target_type = 'Issue'
GROUP BY target_id
ORDER BY labels DESC
LIMIT 25;

Results (to fill in)

metric value
work_items_with_labels
max / avg
p50 / p95 / p99 / p99.9
over_50 / over_100 / over_200 / over_500

Decision this feeds

  • Pick a configurable limit above the p99.9 (with headroom).
  • Do not hardcode — expose it as a configurable application/plan limit so we don't break self-managed on upgrade.
  • Validate on delta (enforce in Issuable::Callbacks::Labels: reject only when new > limit && new > existing), so existing over-limit items are grandfathered and you only get an error when adding beyond the cap.
  • Defense-in-depth: bound the new REST API response (summary/count on list + full on show).
  • Design doc: handbook.gitlab.com/handbook/engineering/architecture/design-documents/work_item_rest_api/
  • Part of epic #21503 (closed)
  • Related: single-item auth check #603898
  • A security/DoS issue for this is expected to be raised by the team — should be cross-linked.