Skip to content

fix: add defensive chunking for SQL IN clauses and remove external_id length limit - #1665

Open
survivor998 wants to merge 1 commit into
looplj:unstablefrom
survivor998:fix/defensive-sqlite-variable-limit
Open

fix: add defensive chunking for SQL IN clauses and remove external_id length limit#1665
survivor998 wants to merge 1 commit into
looplj:unstablefrom
survivor998:fix/defensive-sqlite-variable-limit

Conversation

@survivor998

Copy link
Copy Markdown

Summary

Problem

The /models API 500 error was caused by unchunked SQL IN clauses exceeding SQLite variable limit. The original fix in openai.go only protected one call path, leaving several others vulnerable:

  1. queryConfiguredModelFacades - ModelIDIn without chunking
  2. BulkArchiveModels/BulkDisableModels/BulkEnableModels/BulkDeleteModels - IDIn without chunking
  3. BulkCreateModels - complex OR predicate with 2 variables per input

Additionally, external_id field had MaxLen(512) which caused MySQL Data too long errors with long external IDs (e.g. Copilot Response API IDs >400 chars, as reported in #1471).

Changes

Defensive Chunking (biz.SQLiteMaxVariableLimit = 500)

  • queryConfiguredModelFacades: chunk ModelIDIn calls
  • BulkArchiveModels/BulkDisableModels/BulkEnableModels/BulkDeleteModels: chunk IDIn calls
  • BulkCreateModels: chunk existence check (2 vars per predicate, effective chunk size 250)
  • openai.go ListModels: replace hardcoded 500 with biz.SQLiteMaxVariableLimit

Schema Changes

  • request.go: external_id from MaxLen(512) to SchemaType(text)
  • request_execution.go: same change

Post-merge Required

  • Run go generate ./internal/server/gql to regenerate Ent code after schema changes

Related Issues

… length limit

Prevents SQLite 'too many SQL variables' error by chunking all IDIn/ModelIDIn
calls with a safe limit of 500 variables. Also removes MaxLen(512) from
external_id fields and uses MySQL 'text' type instead, preventing truncation
issues with long external IDs (e.g. Copilot Response API IDs >400 chars).

Changes:
- Add exported SQLiteMaxVariableLimit constant (500) in biz package
- Chunk queryConfiguredModelFacades ModelIDIn calls
- Chunk BulkArchiveModels/BulkDisableModels/BulkEnableModels/BulkDeleteModels
- Chunk BulkCreateModels existence check (2 vars per predicate)
- Replace openai.go hardcoded 500 with biz.SQLiteMaxVariableLimit
- Change external_id from MaxLen(512) to SchemaType(text) in request schema
- Change external_id from MaxLen(512) to SchemaType(text) in request_execution schema

Note: Ent code regeneration required (go generate ./internal/server/gql)
@greptile-apps

greptile-apps Bot commented May 14, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds defensive chunking to all SQL IN clause calls in the model service (using SQLiteMaxVariableLimit = 500) to prevent the "too many SQL variables" error in SQLite, and removes the MaxLen(512) constraint on external_id fields in Request and RequestExecution schemas, replacing it with a MySQL text type override to accommodate IDs longer than 512 characters.

  • Read-path chunking (queryConfiguredModelFacades, BulkCreateModels existence check, openai.go ListModels) is safe: results from each chunk are accumulated into a slice before use.
  • Mutation-path chunking (BulkArchiveModels, BulkDisableModels, BulkEnableModels, BulkDeleteModels) runs multiple separate UPDATE/DELETE statements without a wrapping transaction, breaking the atomicity guarantee that the original single-query versions provided.
  • Schema changes swap MaxLen(512) for SchemaType(MySQL: \"text\") on both external_id fields; go generate ./internal/server/gql must be run after merge to regenerate Ent code.

Confidence Score: 3/5

The read-only chunking and schema changes are safe, but the four Bulk mutation methods are no longer atomic when more than 500 IDs are passed, which could leave models in a partially modified or permanently partially deleted state.

The chunked UPDATE/DELETE loops in the Bulk mutation methods commit each chunk independently. A failure on chunk 2+ returns an error to the caller while the already-committed chunks remain applied — making BulkDeleteModels in particular capable of permanently and partially deleting a set of models with no way to roll back. The rest of the PR (read-path chunking, schema type changes) is straightforward and correct.

internal/server/biz/model.go — the four Bulk mutation methods need a wrapping transaction around their chunk loops.

Important Files Changed

Filename Overview
internal/server/biz/model.go Adds SQLiteMaxVariableLimit constant and chunked looping for all SQL IN queries; chunked read paths are safe, but the four Bulk mutation methods (Archive/Disable/Enable/Delete) are no longer atomic when more than 500 IDs are provided.
internal/server/api/openai.go Replaces hardcoded 500 with biz.SQLiteMaxVariableLimit and wraps the ModelIDIn query in a chunk loop; read-only accumulation is safe.
internal/ent/schema/request.go Replaces MaxLen(512) with SchemaType(MySQL: "text") on external_id; fixes MySQL truncation. SQLite and PostgreSQL fall back to their ent defaults. Requires go generate after merge.
internal/ent/schema/request_execution.go Same MaxLen→SchemaType change as request.go applied to the RequestExecution.external_id field; consistent with companion schema change.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[BulkArchive/Disable/Enable/DeleteModels\ncalled with N IDs] --> B{N > 500?}
    B -- No --> C[Single UPDATE/DELETE\nchunk ✅ atomic]
    B -- Yes --> D[Chunk 1: UPDATE/DELETE\nIDs 1-500 ✅ committed]
    D --> E[Chunk 2: UPDATE/DELETE\nIDs 501-N]
    E -- success --> F[All IDs updated ✅]
    E -- error --> G[Error returned to caller\nbut Chunk 1 already committed ⚠️\nPartial mutation persisted]

    H[BulkCreateModels / queryConfiguredModelFacades /\nopenai.go ListModels\nread-only chunked queries] --> I[Chunk 1 SELECT → accumulate]
    I --> J[Chunk N SELECT → accumulate]
    J --> K[Return combined results ✅ safe]
Loading

Comments Outside Diff (1)

  1. internal/server/biz/model.go, line 449-508 (link)

    P1 Non-atomic chunked mutations across Bulk operations

    BulkArchiveModels, BulkDisableModels, BulkEnableModels, and BulkDeleteModels now issue multiple separate UPDATE/DELETE statements without a wrapping transaction. When more than 500 IDs are supplied, the first chunk is committed to the database before the next chunk is attempted. If any subsequent chunk fails, the operation returns an error to the caller while leaving the earlier chunks already applied — models are left partially archived/disabled/enabled, or (most critically for BulkDeleteModels) partially and irreversibly deleted. Wrapping the entire loop in an ent WithTx (or equivalent) transaction would restore the all-or-nothing guarantee the original single-query version had.

Reviews (1): Last reviewed commit: "fix: add defensive chunking for SQL IN c..." | Re-trigger Greptile

@looplj

looplj commented May 14, 2026

Copy link
Copy Markdown
Owner

Is it a real case for more than 500 models and external id more than 512 chars?

@gemini-code-assist gemini-code-assist Bot left a comment

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.

Code Review

This pull request addresses SQLite's variable limit by implementing chunking for bulk database operations and queries using IN clauses. It also updates the external_id field schema in the Request and RequestExecution entities to use the text type for MySQL. Feedback suggests wrapping chunked bulk operations in transactions to maintain atomicity and deduplicating input IDs to prevent duplicate results in model queries.

Save(ctx)
if err != nil {
return fmt.Errorf("failed to bulk archive models: %w", err)
for _, chunk := range lo.Chunk(ids, SQLiteMaxVariableLimit) {

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.

medium

Chunking the update operation breaks its atomicity. If one chunk fails, previous chunks remain committed, which changes the behavior from the original single-query implementation. Consider wrapping the loop in a transaction to ensure all-or-nothing behavior, matching the original intent of a bulk operation.

Comment on lines 683 to +684
if len(allowedModelIDs) > 0 {
query = query.Where(model.ModelIDIn(allowedModelIDs...))
}
// Chunk allowedModelIDs to avoid SQLite "too many SQL variables" error.

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.

medium

If allowedModelIDs contains duplicate IDs, the subsequent chunked queries will return duplicate model entities, which can lead to duplicate models in the API response. It is recommended to deduplicate the IDs before processing.

	if len(allowedModelIDs) > 0 {
		allowedModelIDs = lo.Uniq(allowedModelIDs)
		// Chunk allowedModelIDs to avoid SQLite "too many SQL variables" error.

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.

2 participants