Skip to content

OPS-442: Batch variable set fetching in ListTicketSchemas - #45

Open
arreyder wants to merge 2 commits into
mainfrom
arreyder/OPS-442-servicenow-n-plus-1-fix
Open

OPS-442: Batch variable set fetching in ListTicketSchemas#45
arreyder wants to merge 2 commits into
mainfrom
arreyder/OPS-442-servicenow-n-plus-1-fix

Conversation

@arreyder

Copy link
Copy Markdown

Summary

Replace per-catalog-item variable set queries with a single batch query using ServiceNow IN operator.

For 20 catalog items: ~61 API calls reduced to ~24.

Changes

  • GetVariableSetLinksForItems() batch variant using sc_cat_itemIN
  • GetCatalogItemVariablesPlusSetsMulti() fetches all set data at once
  • Extracted helpers: getSetVariablesAndChoices(), mergeVariables()
  • CatItem field added to VariableSetM2M for batch grouping
  • schemaForCatalogItemWithSetVars() accepts pre-fetched set variables
  • ListTicketSchemas uses batch path

Test plan

  • Verify ListTicketSchemas returns identical schemas
  • Verify reduced API call count
  • Test items with no variable sets
  • Test items with overlapping variable sets

@arreyder
arreyder requested review from a team and jemisonf March 15, 2026 14:20
@linear

linear Bot commented Mar 15, 2026

Copy link
Copy Markdown

@github-actions

github-actions Bot commented Mar 15, 2026

Copy link
Copy Markdown
Contributor

Claude finished @arreyder's task in 1m 23s —— View job


PR Review: OPS-442 - Batch variable set fetching

  • Read changed files and understand the diff
  • Review code quality, correctness, and patterns
  • Post review feedback

Overall: Good optimization — the N+1 pattern is correctly identified and the batch approach using ServiceNow's IN operator is the right fix. The refactoring into getSetVariablesAndChoices() and mergeVariables() is clean, and the existing single-item path (GetCatalogItemVariablesPlusSets) is nicely preserved as a thin wrapper.

Key findings (see inline comments):

  1. Pagination truncation risk (medium) — The batch query uses Limit: 500 but discards the next-page token. With multiple items batched together, hitting this limit is more likely than the single-item case. Should either paginate to completion or at least log when results are truncated.

  2. Duplicate set IDs in query (low) — When multiple catalog items share variable sets, getSetVariablesAndChoices sends duplicate set IDs in the variable_setIN query. Deduplicating would make the query cleaner.

  3. Unnecessary loop variable copy (low)catalogItem := catalogItem is unnecessary in Go 1.22+.

  4. Dead code consideration (low) — The old schemaForCatalogItem method appears unused after this change. Worth removing if there are no other callers.

Comment thread pkg/servicenow/client.go Outdated
}

// Batch: find all variable set links for all items at once
allLinks, _, err := c.GetVariableSetLinksForItems(ctx, itemSysIDs, PaginationVars{Limit: 500})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Pagination truncation risk. With a hardcoded Limit: 500, if there are more than 500 variable set links across all items, results will be silently truncated. The second page token is captured (allLinks, _, err) but discarded.

This was also an issue in the original single-item path (Limit: 200), but batching multiple items makes it much more likely to hit the limit. Consider either:

  1. Paginating in a loop until the next-page token is empty, or
  2. At minimum, logging a warning when the next-page token is non-empty so truncation is detectable.

Same concern applies to GetVariablesBySetIDs (limit 500) and GetChoicesForVariables (limit 1000) inside getSetVariablesAndChoices.

Comment thread pkg/connector/ticket.go Outdated

var ret []*v2.TicketSchema
for _, catalogItem := range catalogItems {
catalogItem := catalogItem

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Unnecessary loop variable re-declaration. Since Go 1.22, range loop variables are per-iteration. If this module targets Go 1.22+, catalogItem := catalogItem can be removed.

Suggested change
catalogItem := catalogItem
catalogItemSchema, err := s.schemaForCatalogItemWithSetVars(ctx, &catalogItem, setVarsByItem[catalogItem.Id])

Comment thread pkg/connector/ticket.go
Comment on lines +283 to 327
// schemaForCatalogItemWithSetVars builds a TicketSchema using pre-fetched set variables
// (from the batch query) plus per-item direct variables from the catalog API.
func (s *ServiceNow) schemaForCatalogItemWithSetVars(ctx context.Context, catalogItem *servicenow.CatalogItem, setVars []servicenow.CatalogItemVariable) (*v2.TicketSchema, error) {
var ticketTypes []*v2.TicketType
customFields := make(map[string]*v2.TicketCustomField)

// Fetch direct (non-set) variables per item — this still uses the catalog API per item
itemVars, err := s.client.GetCatalogItemVariables(ctx, catalogItem.Id)
if err != nil {
return nil, fmt.Errorf("servicenow-connector: failed to get direct variables for catalog item %s: %w", catalogItem.Id, err)
}

// Merge: direct vars first, then set vars (prefer direct on collision)
allVars := make([]servicenow.CatalogItemVariable, 0, len(itemVars)+len(setVars))
seen := make(map[string]struct{}, len(itemVars))
for _, v := range itemVars {
allVars = append(allVars, v)
seen[v.ID] = struct{}{}
}
for _, v := range setVars {
if _, dup := seen[v.ID]; !dup {
allVars = append(allVars, v)
}
}

for _, v := range allVars {
vCopy := v
cf := servicenow.ConvertVariableToSchemaCustomField(ctx, &vCopy)
if cf == nil {
continue
}
customFields[vCopy.Name] = cf
}

return &v2.TicketSchema{
Id: catalogItem.Id,
DisplayName: catalogItem.Name,
Types: ticketTypes,
CustomFields: customFields,
}, nil
}

func (s *ServiceNow) serviceCatalogRequestItemToTicket(ctx context.Context, requestedItem *servicenow.RequestedItem) (*v2.Ticket, annotations.Annotations, error) {
createdAt, err := time.Parse(time.DateTime, requestedItem.SysCreatedOn)
if err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Dead code potential. The old schemaForCatalogItem method (line 250) is no longer called from ListTicketSchemas. If it has no other callers, consider removing it to avoid maintaining two code paths. If it's intentionally kept for other call sites, a comment explaining that would be helpful.

@github-actions

github-actions Bot commented Mar 15, 2026

Copy link
Copy Markdown
Contributor

Claude finished @arreyder's task in 2m 38s —— View job


PR Review: OPS-442 - Batch variable set fetching in ListTicketSchemas

  • Gather PR context and diff
  • Review existing comments to avoid duplicates
  • Review changed files against criteria
  • Validate findings and post results

Blocking Issues: 0 | Suggestions: 2

Security Issues

None found.

Correctness Issues

None found. The batch approach is correctly implemented — the IN operator usage, grouping by CatItem, and merge logic all look correct.

Suggestions

  1. Deduplicate set IDs in getSetVariablesAndChoices (pkg/servicenow/client.go:654-657) — When multiple catalog items share the same variable set, setIDs will contain duplicates passed to the variable_setIN query. While ServiceNow handles this fine, deduplicating keeps the query string shorter:

    setIDs := make([]string, 0, len(links))
    seen := make(map[string]struct{}, len(links))
    for _, l := range links {
        if _, ok := seen[l.VariableSet]; !ok {
            setIDs = append(setIDs, l.VariableSet)
            seen[l.VariableSet] = struct{}{}
        }
    }
  2. Inner query limits may need scaling (pkg/servicenow/client.go:660,670) — The outer batch limit was increased to 2000 in the latest commit, but GetVariablesBySetIDs (limit 500) and GetChoicesForVariables (limit 1000) weren't adjusted. With more items batched, these are more likely to truncate silently. Consider increasing proportionally or adding pagination loops.

Notes on prior review

  • Loop variable redeclaration — Fixed in dd9e941. ✓
  • Batch limit — Increased from 500 to 2000 in dd9e941. ✓
  • schemaForCatalogItem is NOT dead code — Contrary to the prior review's suggestion, this method is still called from GetTicketSchema (line 241) for the single-item fetch path used by the SDK. It should be kept.

Overall

Clean optimization. The N+1 → batch refactor is well-structured: the single-item path is preserved as a thin wrapper, the new batch path correctly groups by CatItem, and the extracted helpers (getSetVariablesAndChoices, mergeVariables) are clean. No blocking issues.

Comment thread pkg/servicenow/client.go
@@ -600,7 +659,7 @@ func (c *Client) GetCatalogItemVariablesPlusSets(ctx context.Context, itemSysID
// Fetch variables that belong to those sets
setVars, _, err := c.GetVariablesBySetIDs(ctx, setIDs, PaginationVars{Limit: 500})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Inner limits may need scaling too. The outer batch limit was increased to 2000, but GetVariablesBySetIDs (limit 500) and GetChoicesForVariables (limit 1000) weren't adjusted proportionally. With more items batched, the number of unique variable sets — and therefore variables and choices — also grows. Consider increasing these or adding pagination loops, especially for GetVariablesBySetIDs which has the tightest limit.

@github-actions

github-actions Bot commented Mar 15, 2026

Copy link
Copy Markdown
Contributor

Claude finished @arreyder's task in 1m 24s —— View job


PR Review: OPS-442 - Batch variable set fetching in ListTicketSchemas

  • Read changed files and understand the diff
  • Review code quality, correctness, and patterns
  • Post review feedback

Overall: Clean, well-structured optimization. The N+1 → batch refactor correctly identifies the problem, the IN operator usage is idiomatic for ServiceNow, and the decomposition into getSetVariablesAndChoices() / mergeVariables() helpers is clean. The single-item path is correctly preserved for GetTicketSchema.

Blocking Issues: 0 | Suggestions: 2 | Nits: 1


Suggestions

1. Deduplicate set IDs in getSetVariablesAndChoicespkg/servicenow/client.go:654-657

When multiple catalog items share the same variable set, setIDs will contain duplicates. While ServiceNow handles this, deduplicating keeps the query string shorter and avoids redundant processing:

seen := make(map[string]struct{}, len(links))
setIDs := make([]string, 0, len(links))
for _, l := range links {
    if _, ok := seen[l.VariableSet]; !ok {
        setIDs = append(setIDs, l.VariableSet)
        seen[l.VariableSet] = struct{}{}
    }
}

Fix this →

2. Inner query limits may silently truncatepkg/servicenow/client.go:660,670

The outer batch limit was increased to 2000 (good), but GetVariablesBySetIDs (limit 500) and GetChoicesForVariables (limit 1000) are unchanged. With more items batched, these inner queries are more likely to hit their limits and silently discard remaining pages. Consider either:

  • Increasing these proportionally (e.g., 2000 for variables, 4000 for choices), or
  • Adding a pagination loop (most robust), or
  • At minimum, logging when the returned next-page token is non-empty

Fix this →

Nits

1. Double mapping in GetCatalogItemVariablesPlusSetsMultipkg/servicenow/client.go:630-632

MapItemOptionNewToCatalogItemVariable is called here to build varsBySet, then again inside mergeVariables (line 691) for the single-item path. In the multi path this is fine since the mapped results are consumed directly, but it's worth noting the two paths handle the mapping at different layers. Not a bug, just a minor inconsistency in where the conversion happens.

Confirmed Non-Issues

  • schemaForCatalogItem is NOT dead code — It's called from GetTicketSchema (line 241) for single-item fetches. Correctly preserved.
  • Loop variable redeclaration — Already fixed in dd9e941.
  • nolintlint cleanup — Handled in 0967a13.

arreyder and others added 2 commits April 17, 2026 09:49
Replace per-catalog-item variable set queries with a single batch
query using ServiceNow's IN operator. For 20 catalog items this
reduces API calls from ~61 to ~24 (20 direct variable calls + 1 batch
set links + 1 batch set variables + 1 batch choices + 1 states).

Changes:
- Add GetVariableSetLinksForItems() batch variant using sc_cat_itemIN
- Add GetCatalogItemVariablesPlusSetsMulti() that fetches set data for
  all items at once and returns results keyed by item ID
- Extract getSetVariablesAndChoices() and mergeVariables() helpers
- Add CatItem field to VariableSetM2M struct (needed for grouping)
- Add schemaForCatalogItemWithSetVars() that accepts pre-fetched set vars
- ListTicketSchemas now batch-fetches all set data upfront

The per-item GetCatalogItemVariables (catalog service API) call is
retained since it uses a different API endpoint that doesn't support
batch queries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove unnecessary catalogItem := catalogItem (Go 1.22+ per-iteration)
- Increase batch limit from 500 to 2000 to reduce truncation risk
  (matches concern about batching multiple items hitting the limit)
- schemaForCatalogItem kept for GetTicketSchema single-item path

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@arreyder
arreyder force-pushed the arreyder/OPS-442-servicenow-n-plus-1-fix branch from 0967a13 to 91eba09 Compare April 17, 2026 14:50
@github-actions

github-actions Bot commented Apr 17, 2026

Copy link
Copy Markdown
Contributor

Claude finished @arreyder's task in 1m 9s —— View job


PR Review: OPS-442 - Batch variable set fetching in ListTicketSchemas

  • Read and analyze the full diff
  • Review code quality, correctness, and patterns
  • Check for bugs, security issues, and performance problems
  • Post review feedback

Blocking Issues: 0 | Suggestions: 2 | Nits: 0

Overall: Well-structured optimization. The N+1 → batch refactor is cleanly decomposed — getSetVariablesAndChoices() and mergeVariables() are good helper extractions, the single-item path is correctly preserved as a thin wrapper via GetVariableSetLinksForItemGetVariableSetLinksForItems, and the IN operator usage is idiomatic ServiceNow. The grouping logic in GetCatalogItemVariablesPlusSetsMulti (group links by item, index vars by set, then build per-item results) is correct and easy to follow.


Suggestions

1. Deduplicate set IDs in getSetVariablesAndChoicespkg/servicenow/client.go:654-657

When multiple catalog items share the same variable set, setIDs will contain duplicates passed to the variable_setIN query. While ServiceNow handles this correctly, deduplicating keeps the query string shorter and avoids fetching/processing redundant data:

seen := make(map[string]struct{}, len(links))
setIDs := make([]string, 0, len(links))
for _, l := range links {
    if _, ok := seen[l.VariableSet]; !ok {
        setIDs = append(setIDs, l.VariableSet)
        seen[l.VariableSet] = struct{}{}
    }
}

This is especially worthwhile in the batch path where many items may reference the same sets.

Fix this →

2. Inner query limits may silently truncatepkg/servicenow/client.go:660,670

The outer batch limit was appropriately increased to 2000, but GetVariablesBySetIDs (limit 500) and GetChoicesForVariables (limit 1000) are unchanged. With more variable sets batched together, these inner queries are more likely to exceed their limits and silently drop remaining results. The next page token is captured but discarded at both call sites (lines 660, 670).

Options, in order of robustness:

  1. Paginate to completion in a loop until the next token is empty (most correct)
  2. Increase limits proportionally (e.g., 2000 for variables, 4000 for choices) — simple, but still a fixed ceiling
  3. At minimum, log a warning when the returned next-page token is non-empty, so truncation is observable

Fix this →

Confirmed Non-Issues

  • schemaForCatalogItem is NOT dead code — Still called from GetTicketSchema at pkg/connector/ticket.go:257 for single-item fetches. Correctly preserved.
  • Loop variable redeclaration — Already removed in 91eba09. Go 1.25 (per go.mod) uses per-iteration loop vars.
  • Typo in error message ("failde") — Fixed to "failed" in this PR. ✓

Architecture Notes

The batch approach correctly preserves behavioral parity with the single-item path: schemaForCatalogItemWithSetVars still fetches direct (non-set) variables per item via GetCatalogItemVariables, so the only change is that set variable fetching is batched. The merge logic (prefer direct vars on ID collision) is consistent between both paths.

The comment on line 607-610 acknowledging the fixed-limit trade-off is a good callout — this is the same limitation as the pre-existing single-item path, just at a larger scale.


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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant