Truncate Jira deployment associations at 500-value API limit

What does this MR do and why?

Fixes the Jira API error "Sum of Association values across all association types can have a maximum of 500 elements." that prevents deployment data from syncing to Jira for projects with many commits, merge requests, or issue keys. Multiple customers have reported this issue.

What changed

This MR originally tried to split a >500-association deployment into multiple POSTs with the same deployment identity, assuming Jira would accumulate the associations across batches. That assumption was challenged in review. An empirical probe against a real Jira Cloud tenant showed the assumption is wrong — Jira's documented "replaced if updateSequenceNumber is less than the incoming" rule is accurate, and batches beyond the first are silently dropped. The implementation now:

  1. Client#truncate_associations_if_needed — when a deployment's total association values exceed 500, truncate to 500 in a single POST. issueKeys associations are prioritised (DeploymentEntity emits associationType: :issueKeys) so they are never starved by commit/merge-request entries. Any truncation is tracked as an AssociationsTruncatedError via Gitlab::ErrorTracking with the source total and dropped count, so we can monitor customer impact in Sentry.
  2. Client#store_deploy_info — makes a single bulk POST with the possibly-truncated deployment hashes. Removed the multi-POST batching loop and the MAX_DEPLOYMENT_BATCHES / ExcessBatchesError constants.

Evidence: six shapes probed against real Jira Cloud

None of these shapes accumulate associations. Truncation in a single request is the only documented-contract-compliant path.

# Shape What Jira does
1 Single POST, ≤500 values Stored as-is
2 Separate POSTs, same updateSequenceNumber Later POSTs silently dropped
3 Separate POSTs, increasing updateSequenceNumber Wholesale replace; last POST wins, earlier associations lost
4 Separate POSTs, same seq (the old batching approach) Only the first batch persists
5 Single POST, multiple deployments[] entries, same identity, same seq Jira dedupes deployments[]; first entry wins
6 Single POST, multiple deployments[] entries, same identity, increasing seq Jira dedupes; non-deterministic which entry wins across runs (we observed both the middle and the last in different runs)

Transcript (2026-04-24, real Jira Cloud, ISSUE_KEYS=TT-1,TT-2,TT-3)

=== TEST 1: single POST, single deployment, 2 associations (sanity) ===
  status=202  accepted=1  rejected=0  unknownIssueKeys=0
  GET .../deployments/43493246 -> status=200
    type=issueIdOrKeys  count=2  sample=["TT-1", "TT-2"]
  storedUpdateSequenceNumber=1777043493245

=== TEST 2: two separate POSTs, same identity, SAME updateSequenceNumber, disjoint values ===
  status=202  accepted=1  rejected=0  unknownIssueKeys=0
  status=202  accepted=1  rejected=0  unknownIssueKeys=0
  GET .../deployments/43493247 -> status=200
    type=issueIdOrKeys  count=1  sample=["TT-1"]
  storedUpdateSequenceNumber=1777043494245

=== TEST 3: two separate POSTs, same identity, INCREASING updateSequenceNumber, disjoint values ===
  status=202  accepted=1  rejected=0  unknownIssueKeys=0
  status=202  accepted=1  rejected=0  unknownIssueKeys=0
  GET .../deployments/43493248 -> status=200
    type=issueIdOrKeys  count=1  sample=["TT-2"]
  storedUpdateSequenceNumber=1777043495246

=== TEST 4: reproduces the MR's old batching (2 separate POSTs, same seq, split as batches) ===
  status=202  accepted=1  rejected=0  unknownIssueKeys=0
  status=202  accepted=1  rejected=0  unknownIssueKeys=0
  GET .../deployments/43493249 -> status=200
    type=issueIdOrKeys  count=2  sample=["TT-1", "TT-2"]
  storedUpdateSequenceNumber=1777043496245

=== TEST 5: SINGLE POST with multiple deployment entries, same identity, SAME updateSequenceNumber ===
  status=202  accepted=1  rejected=0  unknownIssueKeys=0
  GET .../deployments/43493250 -> status=200
    type=issueIdOrKeys  count=1  sample=["TT-1"]
  storedUpdateSequenceNumber=1777043497245

=== TEST 6: SINGLE POST with multiple deployment entries, same identity, INCREASING updateSequenceNumber ===
  status=202  accepted=1  rejected=0  unknownIssueKeys=0
  GET .../deployments/43493251 -> status=200
    type=issueIdOrKeys  count=1  sample=["TT-2"]    # middle entry this run; an earlier standalone run picked the last
  storedUpdateSequenceNumber=1777043498246

Interpretation:

  • Test 2 — equal updateSequenceNumber, later POSTs silently dropped. Matches the "replaced if less than the incoming" docs wording.
  • Test 3 — increasing updateSequenceNumber, later POST wholesale replaces the previous. No merge.
  • Test 4 — the exact MR behavior dropped the second batch.
  • Test 5 — single POST with duplicates at same seq, Jira takes the first and silently discards the rest.
  • Test 6 — single POST with duplicates at increasing seq, Jira picks one entry non-deterministically (the middle in this run, the last in an earlier run). No accumulation either way.

The probe script used

Reveal /tmp/jira_batch_probe.rb

# Probe Jira's /rest/deployments/0.1/bulk behavior across six shapes of
# "how do I get >500 associations into a single deployment?".
#
# Run with: bundle exec rails runner /tmp/jira_batch_probe.rb
# Env vars:
#   INSTALLATION_ID  (default: JiraConnectInstallation.last.id)
#   ISSUE_KEYS       comma-separated, at least 3 (default: TT-1,TT-2,TT-3)

INSTALLATION_ID = (ENV['INSTALLATION_ID'] || JiraConnectInstallation.last&.id).to_i
ISSUE_KEYS      = ENV.fetch('ISSUE_KEYS', 'TT-1,TT-2,TT-3').split(',')

raise 'Need at least 3 ISSUE_KEYS' if ISSUE_KEYS.size < 3

installation = JiraConnectInstallation.find(INSTALLATION_ID)
client       = installation.client

PIPELINE_PREFIX = "probe-#{SecureRandom.hex(4)}"
DEPLOY_SEQ_BASE = (Time.now.utc.to_f * 1000).to_i % 1_000_000_000

def pipeline_for(n)  = "#{PIPELINE_PREFIX}-#{n}"
def deploy_seq(n)    = DEPLOY_SEQ_BASE + n
def assoc_issue(*keys) = [{ associationType: 'issueKeys', values: keys }]

def base_for(test_n)
  {
    schemaVersion: '1.0',
    deploymentSequenceNumber: deploy_seq(test_n),
    displayName: "Probe T#{test_n}",
    url: 'https://example.invalid/probe',
    description: "jira-batch probe test #{test_n}",
    lastUpdated: Time.now.utc.iso8601,
    label: "probe-t#{test_n}",
    state: 'successful',
    pipeline:    { id: pipeline_for(test_n), displayName: 'probe', url: 'https://example.invalid/p' },
    environment: { id: 'env-1',              displayName: 'probe', type: 'testing' }
  }
end

def post_deploy(client, deployments)
  r = client.send(:post, '/rest/deployments/0.1/bulk', { deployments: deployments })
  body = JSON.parse(r.body) rescue r.body
  puts "  status=#{r.code}  accepted=#{body['acceptedDeployments']&.size}  rejected=#{body['rejectedDeployments']&.size}  unknownIssueKeys=#{body['unknownIssueKeys']&.size}"
  body
end

def fetch_deploy(client, test_n)
  path = "/rest/deployments/0.1/pipelines/#{pipeline_for(test_n)}/environments/env-1/deployments/#{deploy_seq(test_n)}"
  r = client.send(:get, path, {})
  puts "  GET #{path} -> status=#{r.code}"
  body = JSON.parse(r.body) rescue r.body
  if body.is_a?(Hash) && body['associations']
    body['associations'].each do |a|
      puts "    type=#{a['associationType']}  count=#{a['values'].size}  sample=#{a['values'].first(10).inspect}"
    end
    puts "  storedUpdateSequenceNumber=#{body['updateSequenceNumber']}"
  else
    puts "    body=#{body.inspect[0..300]}"
  end
end

seq_base = (Time.now.utc.to_f * 1000).to_i

puts '=== TEST 1: single POST, single deployment, 2 associations (sanity) ==='
post_deploy(client, [base_for(1).merge(updateSequenceNumber: seq_base, associations: assoc_issue(*ISSUE_KEYS.first(2)))])
fetch_deploy(client, 1)

puts
puts '=== TEST 2: two separate POSTs, same deployment identity, SAME updateSequenceNumber, disjoint values ==='
base = base_for(2); seq = seq_base + 1_000
post_deploy(client, [base.merge(updateSequenceNumber: seq, associations: assoc_issue(ISSUE_KEYS[0]))])
post_deploy(client, [base.merge(updateSequenceNumber: seq, associations: assoc_issue(ISSUE_KEYS[1]))])
fetch_deploy(client, 2)

puts
puts '=== TEST 3: two separate POSTs, same deployment identity, INCREASING updateSequenceNumber, disjoint values ==='
base = base_for(3)
post_deploy(client, [base.merge(updateSequenceNumber: seq_base + 2_000, associations: assoc_issue(ISSUE_KEYS[0]))])
post_deploy(client, [base.merge(updateSequenceNumber: seq_base + 2_001, associations: assoc_issue(ISSUE_KEYS[1]))])
fetch_deploy(client, 3)

puts
puts '=== TEST 4: reproduces the MR batching (2 separate POSTs, same seq, 2+1 values split as batches) ==='
base = base_for(4); seq = seq_base + 3_000
post_deploy(client, [base.merge(updateSequenceNumber: seq, associations: assoc_issue(ISSUE_KEYS[0], ISSUE_KEYS[1]))])
post_deploy(client, [base.merge(updateSequenceNumber: seq, associations: assoc_issue(ISSUE_KEYS[2]))])
fetch_deploy(client, 4)

puts
puts '=== TEST 5: SINGLE POST with multiple deployment entries, same identity, SAME updateSequenceNumber ==='
base = base_for(5); seq = seq_base + 4_000
deployments = ISSUE_KEYS.first(3).map do |k|
  base.merge(updateSequenceNumber: seq, associations: assoc_issue(k))
end
post_deploy(client, deployments)
fetch_deploy(client, 5)

puts
puts '=== TEST 6: SINGLE POST with multiple deployment entries, same identity, INCREASING updateSequenceNumber ==='
base = base_for(6); seq = seq_base + 5_000
deployments = ISSUE_KEYS.first(3).each_with_index.map do |k, i|
  base.merge(updateSequenceNumber: seq + i, associations: assoc_issue(k))
end
post_deploy(client, deployments)
fetch_deploy(client, 6)

References

  • HackerOne-reported Jira customer error: "Sum of Association values across all association types can have a maximum of 500 elements. Found 3143."

  • Atlassian OpenAPI spec (IssueIdOrKeysAssociation, ServiceIdOrKeysAssociation, EntityAssociation): "The number of values counted across all associationTypes must not exceed a limit of 500."

  • Atlassian bulk-deployment docs: "existing deployment data for the same deployment will be replaced if it exists and the updateSequenceNumber of existing data is less than the incoming data."

  • Deployments in the test Jira instance

    CleanShot 2026-04-24 at 17.23.29.png

How to set up and validate locally

  • bundle exec rspec spec/lib/atlassian/jira_connect/client_spec.rb — full file passes, including #truncate_associations_if_needed tests covering under/at/over-limit, nil values, mixed types, prioritisation, and empty-value skip.
  • FOSS_ONLY=1 bundle exec rspec spec/lib/atlassian/jira_connect/client_spec.rb — passes.
  • Monitor Sentry after deployment for Atlassian::JiraConnect::Client::AssociationsTruncatedError. extra includes total_values, dropped_values, deployment_sequence_number, and pipeline_id for each truncation.

Feature flag

The truncation path is gated behind the truncate_jira_deployment_associations ops-type feature flag (default_enabled: true). Disabling reverts to the pre-MR behaviour (single POST, no truncation), so customers with >500 associations again see the Jira "Sum of Association values..." error — use only as a kill switch.

ChatOps (production):

  • Disable globally: /chatops run feature set truncate_jira_deployment_associations false
  • Enable globally: /chatops run feature set truncate_jira_deployment_associations true
  • Disable for one project: /chatops run feature set --project=<full/path> truncate_jira_deployment_associations false

Rails console equivalents:

  • Disable: Feature.disable(:truncate_jira_deployment_associations)
  • Enable: Feature.enable(:truncate_jira_deployment_associations)
  • Per-project: Feature.disable(:truncate_jira_deployment_associations, Project.find(<id>))

Closes #470092

MR acceptance checklist

Edited by Jorge Tomás

Merge request reports

Loading